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 = utils.SanitizeRepo(oldName)
654	oldRepo := filepath.Join(fb.reposPath(), oldName+".git")
655	newName = utils.SanitizeRepo(newName)
656	newRepo := filepath.Join(fb.reposPath(), newName+".git")
657	if _, err := os.Stat(oldRepo); errors.Is(err, os.ErrNotExist) {
658		return fmt.Errorf("repository %q does not exist", strings.TrimSuffix(filepath.Base(oldRepo), ".git"))
659	}
660	if _, err := os.Stat(newRepo); err == nil {
661		return fmt.Errorf("repository %q already exists", strings.TrimSuffix(filepath.Base(newRepo), ".git"))
662	}
663
664	if err := os.Rename(oldRepo, newRepo); err != nil {
665		return err
666	}
667
668	// Update cache.
669	if r, ok := fb.repos[oldName]; ok {
670		r.path = newRepo
671		delete(fb.repos, oldName)
672		fb.repos[newName] = r
673	}
674
675	return nil
676}
677
678// Repository finds the given repository.
679//
680// It implements backend.Backend.
681func (fb *FileBackend) Repository(repo string) (backend.Repository, error) {
682	name := utils.SanitizeRepo(repo)
683	if r, ok := fb.repos[name]; ok {
684		return r, nil
685	}
686
687	repo = name + ".git"
688	rp := filepath.Join(fb.reposPath(), repo)
689	_, err := os.Stat(rp)
690	if err != nil {
691		if errors.Is(err, os.ErrNotExist) {
692			return nil, os.ErrNotExist
693		}
694		return nil, err
695	}
696
697	return &Repo{path: rp, root: fb.reposPath()}, nil
698}
699
700// Returns true if path is a directory containing an `objects` directory and a
701// `HEAD` file.
702func isGitDir(path string) bool {
703	stat, err := os.Stat(filepath.Join(path, "objects"))
704	if err != nil {
705		return false
706	}
707	if !stat.IsDir() {
708		return false
709	}
710
711	stat, err = os.Stat(filepath.Join(path, "HEAD"))
712	if err != nil {
713		return false
714	}
715	if stat.IsDir() {
716		return false
717	}
718
719	return true
720}
721
722// initRepos initializes the repository cache.
723func (fb *FileBackend) initRepos() error {
724	fb.repos = make(map[string]*Repo)
725	repos := make([]backend.Repository, 0)
726	err := filepath.WalkDir(fb.reposPath(), func(path string, d fs.DirEntry, _ error) error {
727		// Skip non-directories.
728		if !d.IsDir() {
729			return nil
730		}
731
732		// Skip non-repositories.
733		if !strings.HasSuffix(path, ".git") {
734			return nil
735		}
736
737		if isGitDir(path) {
738			r := &Repo{path: path, root: fb.reposPath()}
739			fb.repos[r.Name()] = r
740			repos = append(repos, r)
741			if err := fb.InitializeHooks(r.Name()); err != nil {
742				logger.Warn("failed to initialize hooks", "err", err, "repo", r.Name())
743			}
744		}
745
746		return nil
747	})
748	if err != nil {
749		return err
750	}
751
752	return nil
753}
754
755// Repositories returns a list of all repositories.
756//
757// It implements backend.Backend.
758func (fb *FileBackend) Repositories() ([]backend.Repository, error) {
759	repos := make([]backend.Repository, 0)
760	for _, r := range fb.repos {
761		repos = append(repos, r)
762	}
763
764	return repos, nil
765}
766
767var (
768	hookNames = []string{"pre-receive", "update", "post-update", "post-receive"}
769	hookTpls  = []string{
770		// for pre-receive
771		`#!/usr/bin/env bash
772# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
773data=$(cat)
774exitcodes=""
775hookname=$(basename $0)
776GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
777for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
778  test -x "${hook}" && test -f "${hook}" || continue
779  echo "${data}" | "${hook}"
780  exitcodes="${exitcodes} $?"
781done
782for i in ${exitcodes}; do
783  [ ${i} -eq 0 ] || exit ${i}
784done
785`,
786
787		// for update
788		`#!/usr/bin/env bash
789# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
790exitcodes=""
791hookname=$(basename $0)
792GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
793for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
794  test -x "${hook}" && test -f "${hook}" || continue
795  "${hook}" $1 $2 $3
796  exitcodes="${exitcodes} $?"
797done
798for i in ${exitcodes}; do
799  [ ${i} -eq 0 ] || exit ${i}
800done
801`,
802
803		// for post-update
804		`#!/usr/bin/env bash
805# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
806data=$(cat)
807exitcodes=""
808hookname=$(basename $0)
809GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
810for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
811  test -x "${hook}" && test -f "${hook}" || continue
812  "${hook}" $@
813  exitcodes="${exitcodes} $?"
814done
815for i in ${exitcodes}; do
816  [ ${i} -eq 0 ] || exit ${i}
817done
818`,
819
820		// for post-receive
821		`#!/usr/bin/env bash
822# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
823data=$(cat)
824exitcodes=""
825hookname=$(basename $0)
826GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
827for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
828  test -x "${hook}" && test -f "${hook}" || continue
829  echo "${data}" | "${hook}"
830  exitcodes="${exitcodes} $?"
831done
832for i in ${exitcodes}; do
833  [ ${i} -eq 0 ] || exit ${i}
834done
835`,
836	}
837)
838
839// InitializeHooks updates the hooks for the given repository.
840//
841// It implements backend.Backend.
842func (fb *FileBackend) InitializeHooks(repo string) error {
843	hookTmpl, err := template.New("hook").Parse(`#!/usr/bin/env bash
844# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
845{{ range $_, $env := .Envs }}
846{{ $env }} \{{ end }}
847{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }}
848`)
849	if err != nil {
850		return err
851	}
852
853	repo = utils.SanitizeRepo(repo) + ".git"
854	hooksPath := filepath.Join(fb.reposPath(), repo, "hooks")
855	if err := os.MkdirAll(hooksPath, 0755); err != nil {
856		return err
857	}
858
859	ex, err := os.Executable()
860	if err != nil {
861		return err
862	}
863
864	dp, err := filepath.Abs(fb.path)
865	if err != nil {
866		return fmt.Errorf("failed to get absolute path for data path: %w", err)
867	}
868
869	cp := filepath.Join(dp, "config.yaml")
870	envs := []string{}
871	for i, hook := range hookNames {
872		var data bytes.Buffer
873		var args string
874		hp := filepath.Join(hooksPath, hook)
875		if err := os.WriteFile(hp, []byte(hookTpls[i]), 0755); err != nil {
876			return err
877		}
878
879		// Create hook.d directory.
880		hp += ".d"
881		if err := os.MkdirAll(hp, 0755); err != nil {
882			return err
883		}
884
885		if hook == "update" {
886			args = "$1 $2 $3"
887		} else if hook == "post-update" {
888			args = "$@"
889		}
890
891		err = hookTmpl.Execute(&data, struct {
892			Executable string
893			Hook       string
894			Args       string
895			Envs       []string
896			Config     string
897		}{
898			Executable: ex,
899			Hook:       hook,
900			Args:       args,
901			Envs:       envs,
902			Config:     cp,
903		})
904		if err != nil {
905			logger.Error("failed to execute hook template", "err", err)
906			continue
907		}
908
909		hp = filepath.Join(hp, "soft-serve")
910		err = os.WriteFile(hp, data.Bytes(), 0755) //nolint:gosec
911		if err != nil {
912			logger.Error("failed to write hook", "err", err)
913			continue
914		}
915	}
916
917	return nil
918}