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