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}