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}