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