file.go

  1// Package file implements a backend that uses the filesystem to store non-Git related data
  2//
  3// The following files and directories are used:
  4//
  5//   - anon-access: contains the access level for anonymous users
  6//   - allow-keyless: contains a boolean value indicating whether or not keyless access is allowed
  7//   - admins: contains a list of authorized keys for admin users
  8//   - host: contains the server's server hostname
  9//   - name: contains the server's name
 10//   - port: contains the server's port
 11//   - repos: is a the directory containing all Git repositories
 12//
 13// Each repository has the following files and directories:
 14//   - collaborators: contains a list of authorized keys for collaborators
 15//   - description: contains the repository's description
 16//   - private: when present, indicates that the repository is private
 17//   - git-daemon-export-ok: when present, indicates that the repository is public
 18//   - project-name: contains the repository's project name
 19package file
 20
 21import (
 22	"bufio"
 23	"bytes"
 24	"errors"
 25	"fmt"
 26	"html/template"
 27	"io"
 28	"io/fs"
 29	"os"
 30	"path/filepath"
 31	"strconv"
 32	"strings"
 33
 34	"github.com/charmbracelet/log"
 35	"github.com/charmbracelet/soft-serve/git"
 36	"github.com/charmbracelet/soft-serve/server/backend"
 37	"github.com/charmbracelet/soft-serve/server/utils"
 38	"github.com/charmbracelet/ssh"
 39	gossh "golang.org/x/crypto/ssh"
 40)
 41
 42// sub file and directory names.
 43const (
 44	anonAccess   = "anon-access"
 45	allowKeyless = "allow-keyless"
 46	admins       = "admins"
 47	repos        = "repos"
 48	collabs      = "collaborators"
 49	description  = "description"
 50	exportOk     = "git-daemon-export-ok"
 51	private      = "private"
 52	projectName  = "project-name"
 53	settings     = "settings"
 54	mirror       = "mirror"
 55)
 56
 57var (
 58	logger = log.WithPrefix("backend.file")
 59
 60	defaults = map[string]string{
 61		anonAccess:   backend.ReadOnlyAccess.String(),
 62		allowKeyless: "true",
 63	}
 64)
 65
 66var _ backend.Backend = &FileBackend{}
 67
 68// FileBackend is a backend that uses the filesystem.
 69type FileBackend struct { // nolint:revive
 70	// path is the path to the directory containing the repositories and config
 71	// files.
 72	path string
 73
 74	// repos is a map of repositories.
 75	repos map[string]*Repo
 76
 77	// AdditionalAdmins additional admins to the server.
 78	AdditionalAdmins []string
 79}
 80
 81func (fb *FileBackend) reposPath() string {
 82	return filepath.Join(fb.path, repos)
 83}
 84
 85func (fb *FileBackend) settingsPath() string {
 86	return filepath.Join(fb.path, settings)
 87}
 88
 89func (fb *FileBackend) adminsPath() string {
 90	return filepath.Join(fb.settingsPath(), admins)
 91}
 92
 93func (fb *FileBackend) collabsPath(repo string) string {
 94	repo = utils.SanitizeRepo(repo) + ".git"
 95	return filepath.Join(fb.reposPath(), repo, collabs)
 96}
 97
 98func readOneLine(path string) (string, error) {
 99	f, err := os.Open(path)
100	if err != nil {
101		return "", err
102	}
103	defer f.Close() // nolint:errcheck
104	s := bufio.NewScanner(f)
105	s.Scan()
106	return s.Text(), s.Err()
107}
108
109func readAll(path string) (string, error) {
110	f, err := os.Open(path)
111	if err != nil {
112		return "", err
113	}
114
115	bts, err := io.ReadAll(f)
116	return string(bts), err
117}
118
119// exists returns true if the given path exists.
120func exists(path string) bool {
121	_, err := os.Stat(path)
122	return err == nil
123}
124
125// NewFileBackend creates a new FileBackend.
126func NewFileBackend(path string) (*FileBackend, error) {
127	fb := &FileBackend{path: path}
128	for _, dir := range []string{repos, settings, collabs} {
129		if err := os.MkdirAll(filepath.Join(path, dir), 0755); err != nil {
130			return nil, err
131		}
132	}
133
134	for _, file := range []string{admins, anonAccess, allowKeyless} {
135		fp := filepath.Join(fb.settingsPath(), file)
136		_, err := os.Stat(fp)
137		if errors.Is(err, fs.ErrNotExist) {
138			f, err := os.Create(fp)
139			if err != nil {
140				return nil, err
141			}
142			if c, ok := defaults[file]; ok {
143				io.WriteString(f, c) // nolint:errcheck
144			}
145			_ = f.Close()
146		}
147	}
148
149	if err := fb.initRepos(); err != nil {
150		return nil, err
151	}
152
153	return fb, nil
154}
155
156// AccessLevel returns the access level for the given public key and repo.
157//
158// It implements backend.AccessMethod.
159func (fb *FileBackend) AccessLevel(repo string, pk gossh.PublicKey) backend.AccessLevel {
160	private := fb.IsPrivate(repo)
161	anon := fb.AnonAccess()
162	if pk != nil {
163		// Check if the key is an admin.
164		if fb.IsAdmin(pk) {
165			return backend.AdminAccess
166		}
167
168		// Check if the key is a collaborator.
169		if fb.IsCollaborator(pk, repo) {
170			if anon > backend.ReadWriteAccess {
171				return anon
172			}
173			return backend.ReadWriteAccess
174		}
175
176		// Check if repo is private.
177		if !private {
178			if anon > backend.ReadOnlyAccess {
179				return anon
180			}
181			return backend.ReadOnlyAccess
182		}
183	}
184
185	if private {
186		return backend.NoAccess
187	}
188
189	return anon
190}
191
192// AddAdmin adds a public key to the list of server admins.
193//
194// It implements backend.Backend.
195func (fb *FileBackend) AddAdmin(pk gossh.PublicKey, memo string) error {
196	// Skip if the key already exists.
197	if fb.IsAdmin(pk) {
198		return fmt.Errorf("key already exists")
199	}
200
201	ak := backend.MarshalAuthorizedKey(pk)
202	f, err := os.OpenFile(fb.adminsPath(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
203	if err != nil {
204		logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
205		return err
206	}
207
208	defer f.Close() //nolint:errcheck
209	if memo != "" {
210		memo = " " + memo
211	}
212	_, err = fmt.Fprintf(f, "%s%s\n", ak, memo)
213	return err
214}
215
216// AddCollaborator adds a public key to the list of collaborators for the given repo.
217//
218// It implements backend.Backend.
219func (fb *FileBackend) AddCollaborator(pk gossh.PublicKey, memo string, repo string) error {
220	name := utils.SanitizeRepo(repo)
221	repo = name + ".git"
222	// Check if repo exists
223	if !exists(filepath.Join(fb.reposPath(), repo)) {
224		return fmt.Errorf("repository %s does not exist", repo)
225	}
226
227	// Skip if the key already exists.
228	if fb.IsCollaborator(pk, repo) {
229		return fmt.Errorf("key already exists")
230	}
231
232	ak := backend.MarshalAuthorizedKey(pk)
233	if err := os.MkdirAll(filepath.Dir(fb.collabsPath(repo)), 0755); err != nil {
234		logger.Debug("failed to create collaborators directory",
235			"err", err, "path", filepath.Dir(fb.collabsPath(repo)))
236		return err
237	}
238
239	f, err := os.OpenFile(fb.collabsPath(repo), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
240	if err != nil {
241		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
242		return err
243	}
244
245	defer f.Close() //nolint:errcheck
246	if memo != "" {
247		memo = " " + memo
248	}
249	_, err = fmt.Fprintf(f, "%s%s\n", ak, memo)
250	return err
251}
252
253// Admins returns a list of public keys that are admins.
254//
255// It implements backend.Backend.
256func (fb *FileBackend) Admins() ([]string, error) {
257	admins := make([]string, 0)
258	f, err := os.Open(fb.adminsPath())
259	if err != nil {
260		logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
261		return nil, err
262	}
263
264	defer f.Close() //nolint:errcheck
265	s := bufio.NewScanner(f)
266	for s.Scan() {
267		admins = append(admins, s.Text())
268	}
269
270	return admins, s.Err()
271}
272
273// Collaborators returns a list of public keys that are collaborators for the given repo.
274//
275// It implements backend.Backend.
276func (fb *FileBackend) Collaborators(repo string) ([]string, error) {
277	name := utils.SanitizeRepo(repo)
278	repo = name + ".git"
279	// Check if repo exists
280	if !exists(filepath.Join(fb.reposPath(), repo)) {
281		return nil, fmt.Errorf("repository %s does not exist", repo)
282	}
283
284	collabs := make([]string, 0)
285	f, err := os.Open(fb.collabsPath(repo))
286	if err != nil && errors.Is(err, os.ErrNotExist) {
287		return collabs, nil
288	}
289	if err != nil {
290		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
291		return nil, err
292	}
293
294	defer f.Close() //nolint:errcheck
295	s := bufio.NewScanner(f)
296	for s.Scan() {
297		collabs = append(collabs, s.Text())
298	}
299
300	return collabs, s.Err()
301}
302
303// RemoveAdmin removes a public key from the list of server admins.
304//
305// It implements backend.Backend.
306func (fb *FileBackend) RemoveAdmin(pk gossh.PublicKey) error {
307	f, err := os.OpenFile(fb.adminsPath(), os.O_RDWR, 0644)
308	if err != nil {
309		logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
310		return err
311	}
312
313	defer f.Close() //nolint:errcheck
314	s := bufio.NewScanner(f)
315	lines := make([]string, 0)
316	for s.Scan() {
317		apk, _, err := backend.ParseAuthorizedKey(s.Text())
318		if err != nil {
319			logger.Debug("failed to parse admin key", "err", err, "path", fb.adminsPath())
320			continue
321		}
322
323		if !ssh.KeysEqual(apk, pk) {
324			lines = append(lines, s.Text())
325		}
326	}
327
328	if err := s.Err(); err != nil {
329		logger.Debug("failed to scan admin keys file", "err", err, "path", fb.adminsPath())
330		return err
331	}
332
333	if err := f.Truncate(0); err != nil {
334		logger.Debug("failed to truncate admin keys file", "err", err, "path", fb.adminsPath())
335		return err
336	}
337
338	if _, err := f.Seek(0, 0); err != nil {
339		logger.Debug("failed to seek admin keys file", "err", err, "path", fb.adminsPath())
340		return err
341	}
342
343	w := bufio.NewWriter(f)
344	for _, line := range lines {
345		if _, err := fmt.Fprintln(w, line); err != nil {
346			logger.Debug("failed to write admin keys file", "err", err, "path", fb.adminsPath())
347			return err
348		}
349	}
350
351	return w.Flush()
352}
353
354// RemoveCollaborator removes a public key from the list of collaborators for the given repo.
355//
356// It implements backend.Backend.
357func (fb *FileBackend) RemoveCollaborator(pk gossh.PublicKey, repo string) error {
358	name := utils.SanitizeRepo(repo)
359	repo = name + ".git"
360	// Check if repo exists
361	if !exists(filepath.Join(fb.reposPath(), repo)) {
362		return fmt.Errorf("repository %s does not exist", repo)
363	}
364
365	f, err := os.OpenFile(fb.collabsPath(repo), os.O_RDWR, 0644)
366	if err != nil && errors.Is(err, os.ErrNotExist) {
367		return nil
368	}
369
370	if err != nil {
371		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
372		return err
373	}
374
375	defer f.Close() //nolint:errcheck
376	s := bufio.NewScanner(f)
377	lines := make([]string, 0)
378	for s.Scan() {
379		apk, _, err := backend.ParseAuthorizedKey(s.Text())
380		if err != nil {
381			logger.Debug("failed to parse collaborator key", "err", err, "path", fb.collabsPath(repo))
382			continue
383		}
384
385		if !ssh.KeysEqual(apk, pk) {
386			lines = append(lines, s.Text())
387		}
388	}
389
390	if err := s.Err(); err != nil {
391		logger.Debug("failed to scan collaborators file", "err", err, "path", fb.collabsPath(repo))
392		return err
393	}
394
395	if err := f.Truncate(0); err != nil {
396		logger.Debug("failed to truncate collaborators file", "err", err, "path", fb.collabsPath(repo))
397		return err
398	}
399
400	if _, err := f.Seek(0, 0); err != nil {
401		logger.Debug("failed to seek collaborators file", "err", err, "path", fb.collabsPath(repo))
402		return err
403	}
404
405	w := bufio.NewWriter(f)
406	for _, line := range lines {
407		if _, err := fmt.Fprintln(w, line); err != nil {
408			logger.Debug("failed to write collaborators file", "err", err, "path", fb.collabsPath(repo))
409			return err
410		}
411	}
412
413	return w.Flush()
414}
415
416// AllowKeyless returns true if keyless access is allowed.
417//
418// It implements backend.Backend.
419func (fb *FileBackend) AllowKeyless() bool {
420	line, err := readOneLine(filepath.Join(fb.settingsPath(), allowKeyless))
421	if err != nil {
422		logger.Debug("failed to read allow-keyless file", "err", err)
423		return false
424	}
425
426	return line == "true"
427}
428
429// AnonAccess returns the level of anonymous access allowed.
430//
431// It implements backend.Backend.
432func (fb *FileBackend) AnonAccess() backend.AccessLevel {
433	line, err := readOneLine(filepath.Join(fb.settingsPath(), anonAccess))
434	if err != nil {
435		logger.Debug("failed to read anon-access file", "err", err)
436		return backend.NoAccess
437	}
438
439	al := backend.ParseAccessLevel(line)
440	if al < 0 {
441		return backend.NoAccess
442	}
443
444	return al
445}
446
447// Description returns the description of the given repo.
448//
449// It implements backend.Backend.
450func (fb *FileBackend) Description(repo string) string {
451	repo = utils.SanitizeRepo(repo) + ".git"
452	r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
453	return r.Description()
454}
455
456// IsAdmin checks if the given public key is a server admin.
457//
458// It implements backend.Backend.
459func (fb *FileBackend) IsAdmin(pk gossh.PublicKey) bool {
460	// Check if the key is an additional admin.
461	ak := backend.MarshalAuthorizedKey(pk)
462	for _, admin := range fb.AdditionalAdmins {
463		if ak == admin {
464			return true
465		}
466	}
467
468	f, err := os.Open(fb.adminsPath())
469	if err != nil {
470		logger.Debug("failed to open admins file", "err", err, "path", fb.adminsPath())
471		return false
472	}
473
474	defer f.Close() //nolint:errcheck
475	s := bufio.NewScanner(f)
476	for s.Scan() {
477		apk, _, err := backend.ParseAuthorizedKey(s.Text())
478		if err != nil {
479			continue
480		}
481		if ssh.KeysEqual(apk, pk) {
482			return true
483		}
484	}
485
486	return false
487}
488
489// IsCollaborator returns true if the given public key is a collaborator on the
490// given repo.
491//
492// It implements backend.Backend.
493func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, repo string) bool {
494	repo = utils.SanitizeRepo(repo) + ".git"
495	_, err := os.Stat(fb.collabsPath(repo))
496	if err != nil {
497		return false
498	}
499
500	f, err := os.Open(fb.collabsPath(repo))
501	if err != nil {
502		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
503		return false
504	}
505
506	defer f.Close() //nolint:errcheck
507	s := bufio.NewScanner(f)
508	for s.Scan() {
509		apk, _, err := backend.ParseAuthorizedKey(s.Text())
510		if err != nil {
511			continue
512		}
513		if ssh.KeysEqual(apk, pk) {
514			return true
515		}
516	}
517
518	return false
519}
520
521// IsPrivate returns true if the given repo is private.
522//
523// It implements backend.Backend.
524func (fb *FileBackend) IsPrivate(repo string) bool {
525	repo = utils.SanitizeRepo(repo) + ".git"
526	r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
527	return r.IsPrivate()
528}
529
530// SetAllowKeyless sets whether or not to allow keyless access.
531//
532// It implements backend.Backend.
533func (fb *FileBackend) SetAllowKeyless(allow bool) error {
534	return os.WriteFile(filepath.Join(fb.settingsPath(), allowKeyless), []byte(strconv.FormatBool(allow)), 0600)
535}
536
537// SetAnonAccess sets the anonymous access level.
538//
539// It implements backend.Backend.
540func (fb *FileBackend) SetAnonAccess(level backend.AccessLevel) error {
541	return os.WriteFile(filepath.Join(fb.settingsPath(), anonAccess), []byte(level.String()), 0600)
542}
543
544// SetDescription sets the description of the given repo.
545//
546// It implements backend.Backend.
547func (fb *FileBackend) SetDescription(repo string, desc string) error {
548	repo = utils.SanitizeRepo(repo) + ".git"
549	return os.WriteFile(filepath.Join(fb.reposPath(), repo, description), []byte(desc), 0600)
550}
551
552// SetPrivate sets the private status of the given repo.
553//
554// It implements backend.Backend.
555func (fb *FileBackend) SetPrivate(repo string, priv bool) error {
556	repo = utils.SanitizeRepo(repo) + ".git"
557	daemonExport := filepath.Join(fb.reposPath(), repo, exportOk)
558	if priv {
559		_ = os.Remove(daemonExport)
560		f, err := os.Create(filepath.Join(fb.reposPath(), repo, private))
561		if err != nil {
562			return fmt.Errorf("failed to create private file: %w", err)
563		}
564
565		_ = f.Close() //nolint:errcheck
566	} else {
567		// Create git-daemon-export-ok file if repo is public.
568		f, err := os.Create(daemonExport)
569		if err != nil {
570			logger.Warn("failed to create git-daemon-export-ok file", "err", err)
571		} else {
572			_ = f.Close() //nolint:errcheck
573		}
574	}
575	return nil
576}
577
578// ProjectName returns the project name.
579//
580// It implements backend.Backend.
581func (fb *FileBackend) ProjectName(repo string) string {
582	repo = utils.SanitizeRepo(repo) + ".git"
583	r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
584	return r.ProjectName()
585}
586
587// SetProjectName sets the project name of the given repo.
588//
589// It implements backend.Backend.
590func (fb *FileBackend) SetProjectName(repo string, name string) error {
591	repo = utils.SanitizeRepo(repo) + ".git"
592	return os.WriteFile(filepath.Join(fb.reposPath(), repo, projectName), []byte(name), 0600)
593}
594
595// IsMirror returns true if the given repo is a mirror.
596func (fb *FileBackend) IsMirror(repo string) bool {
597	repo = utils.SanitizeRepo(repo) + ".git"
598	r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
599	return r.IsMirror()
600}
601
602// CreateRepository creates a new repository.
603//
604// Created repositories are always bare.
605//
606// It implements backend.Backend.
607func (fb *FileBackend) CreateRepository(repo string, opts backend.RepositoryOptions) (backend.Repository, error) {
608	name := utils.SanitizeRepo(repo)
609	repo = name + ".git"
610	rp := filepath.Join(fb.reposPath(), repo)
611	if _, err := os.Stat(rp); err == nil {
612		return nil, os.ErrExist
613	}
614
615	if opts.Mirror != "" {
616		if err := git.Clone(opts.Mirror, rp, git.CloneOptions{
617			Mirror: true,
618		}); err != nil {
619			logger.Debug("failed to clone mirror repository", "err", err)
620			return nil, err
621		}
622
623		if err := os.WriteFile(filepath.Join(rp, mirror), nil, 0600); err != nil {
624			logger.Debug("failed to create mirror file", "err", err)
625			return nil, err
626		}
627	}
628
629	rr, err := git.Init(rp, true)
630	if err != nil {
631		logger.Debug("failed to create repository", "err", err)
632		return nil, err
633	}
634
635	if err := rr.UpdateServerInfo(); err != nil {
636		logger.Debug("failed to update server info", "err", err)
637		return nil, err
638	}
639
640	if err := fb.SetPrivate(repo, opts.Private); err != nil {
641		logger.Debug("failed to set private status", "err", err)
642		return nil, err
643	}
644
645	if err := fb.SetDescription(repo, opts.Description); err != nil {
646		logger.Debug("failed to set description", "err", err)
647		return nil, err
648	}
649
650	if err := fb.SetProjectName(repo, opts.ProjectName); err != nil {
651		logger.Debug("failed to set project name", "err", err)
652		return nil, err
653	}
654
655	r := &Repo{path: rp, root: fb.reposPath()}
656	// Add to cache.
657	fb.repos[name] = r
658	return r, fb.InitializeHooks(name)
659}
660
661// DeleteRepository deletes the given repository.
662//
663// It implements backend.Backend.
664func (fb *FileBackend) DeleteRepository(repo string) error {
665	name := utils.SanitizeRepo(repo)
666	delete(fb.repos, name)
667	repo = name + ".git"
668	return os.RemoveAll(filepath.Join(fb.reposPath(), repo))
669}
670
671// RenameRepository renames the given repository.
672//
673// It implements backend.Backend.
674func (fb *FileBackend) RenameRepository(oldName string, newName string) error {
675	oldName = utils.SanitizeRepo(oldName)
676	oldRepo := filepath.Join(fb.reposPath(), oldName+".git")
677	newName = utils.SanitizeRepo(newName)
678	newRepo := filepath.Join(fb.reposPath(), newName+".git")
679	if _, err := os.Stat(oldRepo); errors.Is(err, os.ErrNotExist) {
680		return fmt.Errorf("repository %q does not exist", strings.TrimSuffix(filepath.Base(oldRepo), ".git"))
681	}
682	if _, err := os.Stat(newRepo); err == nil {
683		return fmt.Errorf("repository %q already exists", strings.TrimSuffix(filepath.Base(newRepo), ".git"))
684	}
685
686	if err := os.Rename(oldRepo, newRepo); err != nil {
687		return err
688	}
689
690	// Update cache.
691	if r, ok := fb.repos[oldName]; ok {
692		r.path = newRepo
693		delete(fb.repos, oldName)
694		fb.repos[newName] = r
695	}
696
697	return nil
698}
699
700// Repository finds the given repository.
701//
702// It implements backend.Backend.
703func (fb *FileBackend) Repository(repo string) (backend.Repository, error) {
704	name := utils.SanitizeRepo(repo)
705	if r, ok := fb.repos[name]; ok {
706		return r, nil
707	}
708
709	repo = name + ".git"
710	rp := filepath.Join(fb.reposPath(), repo)
711	_, err := os.Stat(rp)
712	if err != nil {
713		if errors.Is(err, os.ErrNotExist) {
714			return nil, os.ErrNotExist
715		}
716		return nil, err
717	}
718
719	return &Repo{path: rp, root: fb.reposPath()}, nil
720}
721
722// Returns true if path is a directory containing an `objects` directory and a
723// `HEAD` file.
724func isGitDir(path string) bool {
725	stat, err := os.Stat(filepath.Join(path, "objects"))
726	if err != nil {
727		return false
728	}
729	if !stat.IsDir() {
730		return false
731	}
732
733	stat, err = os.Stat(filepath.Join(path, "HEAD"))
734	if err != nil {
735		return false
736	}
737	if stat.IsDir() {
738		return false
739	}
740
741	return true
742}
743
744// initRepos initializes the repository cache.
745func (fb *FileBackend) initRepos() error {
746	fb.repos = make(map[string]*Repo)
747	repos := make([]backend.Repository, 0)
748	err := filepath.WalkDir(fb.reposPath(), func(path string, d fs.DirEntry, _ error) error {
749		// Skip non-directories.
750		if !d.IsDir() {
751			return nil
752		}
753
754		// Skip non-repositories.
755		if !strings.HasSuffix(path, ".git") {
756			return nil
757		}
758
759		if isGitDir(path) {
760			r := &Repo{path: path, root: fb.reposPath()}
761			fb.repos[r.Name()] = r
762			repos = append(repos, r)
763			if err := fb.InitializeHooks(r.Name()); err != nil {
764				logger.Warn("failed to initialize hooks", "err", err, "repo", r.Name())
765			}
766		}
767
768		return nil
769	})
770	if err != nil {
771		return err
772	}
773
774	return nil
775}
776
777// Repositories returns a list of all repositories.
778//
779// It implements backend.Backend.
780func (fb *FileBackend) Repositories() ([]backend.Repository, error) {
781	repos := make([]backend.Repository, 0)
782	for _, r := range fb.repos {
783		repos = append(repos, r)
784	}
785
786	return repos, nil
787}
788
789var (
790	hookNames = []string{"pre-receive", "update", "post-update", "post-receive"}
791	hookTpls  = []string{
792		// for pre-receive
793		`#!/usr/bin/env bash
794# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
795data=$(cat)
796exitcodes=""
797hookname=$(basename $0)
798GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
799for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
800  test -x "${hook}" && test -f "${hook}" || continue
801  echo "${data}" | "${hook}"
802  exitcodes="${exitcodes} $?"
803done
804for i in ${exitcodes}; do
805  [ ${i} -eq 0 ] || exit ${i}
806done
807`,
808
809		// for update
810		`#!/usr/bin/env bash
811# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
812exitcodes=""
813hookname=$(basename $0)
814GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
815for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
816  test -x "${hook}" && test -f "${hook}" || continue
817  "${hook}" $1 $2 $3
818  exitcodes="${exitcodes} $?"
819done
820for i in ${exitcodes}; do
821  [ ${i} -eq 0 ] || exit ${i}
822done
823`,
824
825		// for post-update
826		`#!/usr/bin/env bash
827# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
828data=$(cat)
829exitcodes=""
830hookname=$(basename $0)
831GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
832for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
833  test -x "${hook}" && test -f "${hook}" || continue
834  "${hook}" $@
835  exitcodes="${exitcodes} $?"
836done
837for i in ${exitcodes}; do
838  [ ${i} -eq 0 ] || exit ${i}
839done
840`,
841
842		// for post-receive
843		`#!/usr/bin/env bash
844# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
845data=$(cat)
846exitcodes=""
847hookname=$(basename $0)
848GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
849for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
850  test -x "${hook}" && test -f "${hook}" || continue
851  echo "${data}" | "${hook}"
852  exitcodes="${exitcodes} $?"
853done
854for i in ${exitcodes}; do
855  [ ${i} -eq 0 ] || exit ${i}
856done
857`,
858	}
859)
860
861// InitializeHooks updates the hooks for the given repository.
862//
863// It implements backend.Backend.
864func (fb *FileBackend) InitializeHooks(repo string) error {
865	hookTmpl, err := template.New("hook").Parse(`#!/usr/bin/env bash
866# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
867{{ range $_, $env := .Envs }}
868{{ $env }} \{{ end }}
869{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }}
870`)
871	if err != nil {
872		return err
873	}
874
875	repo = utils.SanitizeRepo(repo) + ".git"
876	hooksPath := filepath.Join(fb.reposPath(), repo, "hooks")
877	if err := os.MkdirAll(hooksPath, 0755); err != nil {
878		return err
879	}
880
881	ex, err := os.Executable()
882	if err != nil {
883		return err
884	}
885
886	dp, err := filepath.Abs(fb.path)
887	if err != nil {
888		return fmt.Errorf("failed to get absolute path for data path: %w", err)
889	}
890
891	cp := filepath.Join(dp, "config.yaml")
892	envs := []string{}
893	for i, hook := range hookNames {
894		var data bytes.Buffer
895		var args string
896		hp := filepath.Join(hooksPath, hook)
897		if err := os.WriteFile(hp, []byte(hookTpls[i]), 0755); err != nil {
898			return err
899		}
900
901		// Create hook.d directory.
902		hp += ".d"
903		if err := os.MkdirAll(hp, 0755); err != nil {
904			return err
905		}
906
907		if hook == "update" {
908			args = "$1 $2 $3"
909		} else if hook == "post-update" {
910			args = "$@"
911		}
912
913		err = hookTmpl.Execute(&data, struct {
914			Executable string
915			Hook       string
916			Args       string
917			Envs       []string
918			Config     string
919		}{
920			Executable: ex,
921			Hook:       hook,
922			Args:       args,
923			Envs:       envs,
924			Config:     cp,
925		})
926		if err != nil {
927			logger.Error("failed to execute hook template", "err", err)
928			continue
929		}
930
931		hp = filepath.Join(hp, "soft-serve")
932		err = os.WriteFile(hp, data.Bytes(), 0755) //nolint:gosec
933		if err != nil {
934			logger.Error("failed to write hook", "err", err)
935			continue
936		}
937	}
938
939	return nil
940}