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