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 "errors"
24 "fmt"
25 "io"
26 "io/fs"
27 "os"
28 "path/filepath"
29 "strconv"
30 "strings"
31
32 "github.com/charmbracelet/log"
33 "github.com/charmbracelet/soft-serve/git"
34 "github.com/charmbracelet/soft-serve/server/backend"
35 "github.com/charmbracelet/soft-serve/server/utils"
36 "github.com/charmbracelet/ssh"
37 gossh "golang.org/x/crypto/ssh"
38)
39
40// sub file and directory names.
41const (
42 anonAccess = "anon-access"
43 defaultBranch = "default-branch"
44 allowKeyless = "allow-keyless"
45 admins = "admins"
46 repos = "repos"
47 collabs = "collaborators"
48 description = "description"
49 exportOk = "git-daemon-export-ok"
50 private = "private"
51 settings = "settings"
52)
53
54var (
55 logger = log.WithPrefix("backend.file")
56
57 defaults = map[string]string{
58 anonAccess: backend.ReadOnlyAccess.String(),
59 allowKeyless: "true",
60 }
61)
62
63var _ backend.Backend = &FileBackend{}
64
65// FileBackend is a backend that uses the filesystem.
66type FileBackend struct { // nolint:revive
67 // path is the path to the directory containing the repositories and config
68 // files.
69 path string
70
71 // repos is a map of repositories.
72 repos map[string]*Repo
73
74 // AdditionalAdmins additional admins to the server.
75 AdditionalAdmins []string
76}
77
78func (fb *FileBackend) reposPath() string {
79 return filepath.Join(fb.path, repos)
80}
81
82func (fb *FileBackend) settingsPath() string {
83 return filepath.Join(fb.path, settings)
84}
85
86func (fb *FileBackend) adminsPath() string {
87 return filepath.Join(fb.settingsPath(), admins)
88}
89
90func (fb *FileBackend) collabsPath(repo string) string {
91 repo = utils.SanitizeRepo(repo) + ".git"
92 return filepath.Join(fb.reposPath(), repo, collabs)
93}
94
95func readOneLine(path string) (string, error) {
96 f, err := os.Open(path)
97 if err != nil {
98 return "", err
99 }
100 defer f.Close() // nolint:errcheck
101 s := bufio.NewScanner(f)
102 s.Scan()
103 return s.Text(), s.Err()
104}
105
106func readAll(path string) (string, error) {
107 f, err := os.Open(path)
108 if err != nil {
109 return "", err
110 }
111
112 bts, err := io.ReadAll(f)
113 return string(bts), err
114}
115
116// exists returns true if the given path exists.
117func exists(path string) bool {
118 _, err := os.Stat(path)
119 return err == nil
120}
121
122// NewFileBackend creates a new FileBackend.
123func NewFileBackend(path string) (*FileBackend, error) {
124 fb := &FileBackend{path: path}
125 for _, dir := range []string{repos, settings, collabs} {
126 if err := os.MkdirAll(filepath.Join(path, dir), 0755); err != nil {
127 return nil, err
128 }
129 }
130
131 for _, file := range []string{admins, anonAccess, allowKeyless, defaultBranch} {
132 fp := filepath.Join(fb.settingsPath(), file)
133 _, err := os.Stat(fp)
134 if errors.Is(err, fs.ErrNotExist) {
135 f, err := os.Create(fp)
136 if err != nil {
137 return nil, err
138 }
139 if c, ok := defaults[file]; ok {
140 io.WriteString(f, c) // nolint:errcheck
141 }
142 _ = f.Close()
143 }
144 }
145
146 if err := fb.initRepos(); err != nil {
147 return nil, err
148 }
149
150 return fb, nil
151}
152
153// AccessLevel returns the access level for the given public key and repo.
154//
155// It implements backend.AccessMethod.
156func (fb *FileBackend) AccessLevel(repo string, pk gossh.PublicKey) backend.AccessLevel {
157 private := fb.IsPrivate(repo)
158 anon := fb.AnonAccess()
159 if pk != nil {
160 // Check if the key is an admin.
161 if fb.IsAdmin(pk) {
162 return backend.AdminAccess
163 }
164
165 // Check if the key is a collaborator.
166 if fb.IsCollaborator(pk, repo) {
167 if anon > backend.ReadWriteAccess {
168 return anon
169 }
170 return backend.ReadWriteAccess
171 }
172
173 // Check if repo is private.
174 if !private {
175 if anon > backend.ReadOnlyAccess {
176 return anon
177 }
178 return backend.ReadOnlyAccess
179 }
180 }
181
182 if private {
183 return backend.NoAccess
184 }
185
186 return anon
187}
188
189// AddAdmin adds a public key to the list of server admins.
190//
191// It implements backend.Backend.
192func (fb *FileBackend) AddAdmin(pk gossh.PublicKey, memo string) error {
193 // Skip if the key already exists.
194 if fb.IsAdmin(pk) {
195 return fmt.Errorf("key already exists")
196 }
197
198 ak := backend.MarshalAuthorizedKey(pk)
199 f, err := os.OpenFile(fb.adminsPath(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
200 if err != nil {
201 logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
202 return err
203 }
204
205 defer f.Close() //nolint:errcheck
206 if memo != "" {
207 memo = " " + memo
208 }
209 _, err = fmt.Fprintf(f, "%s%s\n", ak, memo)
210 return err
211}
212
213// AddCollaborator adds a public key to the list of collaborators for the given repo.
214//
215// It implements backend.Backend.
216func (fb *FileBackend) AddCollaborator(pk gossh.PublicKey, memo string, repo string) error {
217 name := utils.SanitizeRepo(repo)
218 repo = name + ".git"
219 // Check if repo exists
220 if !exists(filepath.Join(fb.reposPath(), repo)) {
221 return fmt.Errorf("repository %s does not exist", repo)
222 }
223
224 // Skip if the key already exists.
225 if fb.IsCollaborator(pk, repo) {
226 return fmt.Errorf("key already exists")
227 }
228
229 ak := backend.MarshalAuthorizedKey(pk)
230 if err := os.MkdirAll(filepath.Dir(fb.collabsPath(repo)), 0755); err != nil {
231 logger.Debug("failed to create collaborators directory",
232 "err", err, "path", filepath.Dir(fb.collabsPath(repo)))
233 return err
234 }
235
236 f, err := os.OpenFile(fb.collabsPath(repo), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
237 if err != nil {
238 logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
239 return err
240 }
241
242 defer f.Close() //nolint:errcheck
243 if memo != "" {
244 memo = " " + memo
245 }
246 _, err = fmt.Fprintf(f, "%s%s\n", ak, memo)
247 return err
248}
249
250// Admins returns a list of public keys that are admins.
251//
252// It implements backend.Backend.
253func (fb *FileBackend) Admins() ([]string, error) {
254 admins := make([]string, 0)
255 f, err := os.Open(fb.adminsPath())
256 if err != nil {
257 logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
258 return nil, err
259 }
260
261 defer f.Close() //nolint:errcheck
262 s := bufio.NewScanner(f)
263 for s.Scan() {
264 admins = append(admins, s.Text())
265 }
266
267 return admins, s.Err()
268}
269
270// Collaborators returns a list of public keys that are collaborators for the given repo.
271//
272// It implements backend.Backend.
273func (fb *FileBackend) Collaborators(repo string) ([]string, error) {
274 name := utils.SanitizeRepo(repo)
275 repo = name + ".git"
276 // Check if repo exists
277 if !exists(filepath.Join(fb.reposPath(), repo)) {
278 return nil, fmt.Errorf("repository %s does not exist", repo)
279 }
280
281 collabs := make([]string, 0)
282 f, err := os.Open(fb.collabsPath(repo))
283 if err != nil && errors.Is(err, os.ErrNotExist) {
284 return collabs, nil
285 }
286 if err != nil {
287 logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
288 return nil, err
289 }
290
291 defer f.Close() //nolint:errcheck
292 s := bufio.NewScanner(f)
293 for s.Scan() {
294 collabs = append(collabs, s.Text())
295 }
296
297 return collabs, s.Err()
298}
299
300// RemoveAdmin removes a public key from the list of server admins.
301//
302// It implements backend.Backend.
303func (fb *FileBackend) RemoveAdmin(pk gossh.PublicKey) error {
304 f, err := os.OpenFile(fb.adminsPath(), os.O_RDWR, 0644)
305 if err != nil {
306 logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
307 return err
308 }
309
310 defer f.Close() //nolint:errcheck
311 s := bufio.NewScanner(f)
312 lines := make([]string, 0)
313 for s.Scan() {
314 apk, _, err := backend.ParseAuthorizedKey(s.Text())
315 if err != nil {
316 logger.Debug("failed to parse admin key", "err", err, "path", fb.adminsPath())
317 continue
318 }
319
320 if !ssh.KeysEqual(apk, pk) {
321 lines = append(lines, s.Text())
322 }
323 }
324
325 if err := s.Err(); err != nil {
326 logger.Debug("failed to scan admin keys file", "err", err, "path", fb.adminsPath())
327 return err
328 }
329
330 if err := f.Truncate(0); err != nil {
331 logger.Debug("failed to truncate admin keys file", "err", err, "path", fb.adminsPath())
332 return err
333 }
334
335 if _, err := f.Seek(0, 0); err != nil {
336 logger.Debug("failed to seek admin keys file", "err", err, "path", fb.adminsPath())
337 return err
338 }
339
340 w := bufio.NewWriter(f)
341 for _, line := range lines {
342 if _, err := fmt.Fprintln(w, line); err != nil {
343 logger.Debug("failed to write admin keys file", "err", err, "path", fb.adminsPath())
344 return err
345 }
346 }
347
348 return w.Flush()
349}
350
351// RemoveCollaborator removes a public key from the list of collaborators for the given repo.
352//
353// It implements backend.Backend.
354func (fb *FileBackend) RemoveCollaborator(pk gossh.PublicKey, repo string) error {
355 name := utils.SanitizeRepo(repo)
356 repo = name + ".git"
357 // Check if repo exists
358 if !exists(filepath.Join(fb.reposPath(), repo)) {
359 return fmt.Errorf("repository %s does not exist", repo)
360 }
361
362 f, err := os.OpenFile(fb.collabsPath(repo), os.O_RDWR, 0644)
363 if err != nil && errors.Is(err, os.ErrNotExist) {
364 return nil
365 }
366
367 if err != nil {
368 logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
369 return err
370 }
371
372 defer f.Close() //nolint:errcheck
373 s := bufio.NewScanner(f)
374 lines := make([]string, 0)
375 for s.Scan() {
376 apk, _, err := backend.ParseAuthorizedKey(s.Text())
377 if err != nil {
378 logger.Debug("failed to parse collaborator key", "err", err, "path", fb.collabsPath(repo))
379 continue
380 }
381
382 if !ssh.KeysEqual(apk, pk) {
383 lines = append(lines, s.Text())
384 }
385 }
386
387 if err := s.Err(); err != nil {
388 logger.Debug("failed to scan collaborators file", "err", err, "path", fb.collabsPath(repo))
389 return err
390 }
391
392 if err := f.Truncate(0); err != nil {
393 logger.Debug("failed to truncate collaborators file", "err", err, "path", fb.collabsPath(repo))
394 return err
395 }
396
397 if _, err := f.Seek(0, 0); err != nil {
398 logger.Debug("failed to seek collaborators file", "err", err, "path", fb.collabsPath(repo))
399 return err
400 }
401
402 w := bufio.NewWriter(f)
403 for _, line := range lines {
404 if _, err := fmt.Fprintln(w, line); err != nil {
405 logger.Debug("failed to write collaborators file", "err", err, "path", fb.collabsPath(repo))
406 return err
407 }
408 }
409
410 return w.Flush()
411}
412
413// AllowKeyless returns true if keyless access is allowed.
414//
415// It implements backend.Backend.
416func (fb *FileBackend) AllowKeyless() bool {
417 line, err := readOneLine(filepath.Join(fb.settingsPath(), allowKeyless))
418 if err != nil {
419 logger.Debug("failed to read allow-keyless file", "err", err)
420 return false
421 }
422
423 return line == "true"
424}
425
426// AnonAccess returns the level of anonymous access allowed.
427//
428// It implements backend.Backend.
429func (fb *FileBackend) AnonAccess() backend.AccessLevel {
430 line, err := readOneLine(filepath.Join(fb.settingsPath(), anonAccess))
431 if err != nil {
432 logger.Debug("failed to read anon-access file", "err", err)
433 return backend.NoAccess
434 }
435
436 switch line {
437 case backend.NoAccess.String():
438 return backend.NoAccess
439 case backend.ReadOnlyAccess.String():
440 return backend.ReadOnlyAccess
441 case backend.ReadWriteAccess.String():
442 return backend.ReadWriteAccess
443 case backend.AdminAccess.String():
444 return backend.AdminAccess
445 default:
446 return backend.NoAccess
447 }
448}
449
450// DefaultBranch returns the default branch for new repositories.
451//
452// It implements backend.Backend.
453func (fb *FileBackend) DefaultBranch() string {
454 line, err := readOneLine(filepath.Join(fb.settingsPath(), defaultBranch))
455 if err != nil {
456 logger.Debug("failed to read default-branch file", "err", err)
457 return defaults[defaultBranch]
458 }
459
460 return line
461}
462
463// SetDefaultBranch sets the default branch for new repositories.
464//
465// It implements backend.Backend.
466func (fb *FileBackend) SetDefaultBranch(branch string) error {
467 return os.WriteFile(filepath.Join(fb.settingsPath(), defaultBranch), []byte(branch), 0600)
468}
469
470// Description returns the description of the given repo.
471//
472// It implements backend.Backend.
473func (fb *FileBackend) Description(repo string) string {
474 repo = utils.SanitizeRepo(repo) + ".git"
475 r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
476 return r.Description()
477}
478
479// IsAdmin checks if the given public key is a server admin.
480//
481// It implements backend.Backend.
482func (fb *FileBackend) IsAdmin(pk gossh.PublicKey) bool {
483 // Check if the key is an additional admin.
484 ak := backend.MarshalAuthorizedKey(pk)
485 for _, admin := range fb.AdditionalAdmins {
486 if ak == admin {
487 return true
488 }
489 }
490
491 f, err := os.Open(fb.adminsPath())
492 if err != nil {
493 logger.Debug("failed to open admins file", "err", err, "path", fb.adminsPath())
494 return false
495 }
496
497 defer f.Close() //nolint:errcheck
498 s := bufio.NewScanner(f)
499 for s.Scan() {
500 apk, _, err := backend.ParseAuthorizedKey(s.Text())
501 if err != nil {
502 continue
503 }
504 if ssh.KeysEqual(apk, pk) {
505 return true
506 }
507 }
508
509 return false
510}
511
512// IsCollaborator returns true if the given public key is a collaborator on the
513// given repo.
514//
515// It implements backend.Backend.
516func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, repo string) bool {
517 repo = utils.SanitizeRepo(repo) + ".git"
518 _, err := os.Stat(fb.collabsPath(repo))
519 if err != nil {
520 return false
521 }
522
523 f, err := os.Open(fb.collabsPath(repo))
524 if err != nil {
525 logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
526 return false
527 }
528
529 defer f.Close() //nolint:errcheck
530 s := bufio.NewScanner(f)
531 for s.Scan() {
532 apk, _, err := backend.ParseAuthorizedKey(s.Text())
533 if err != nil {
534 continue
535 }
536 if ssh.KeysEqual(apk, pk) {
537 return true
538 }
539 }
540
541 return false
542}
543
544// IsPrivate returns true if the given repo is private.
545//
546// It implements backend.Backend.
547func (fb *FileBackend) IsPrivate(repo string) bool {
548 repo = utils.SanitizeRepo(repo) + ".git"
549 r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
550 return r.IsPrivate()
551}
552
553// SetAllowKeyless sets whether or not to allow keyless access.
554//
555// It implements backend.Backend.
556func (fb *FileBackend) SetAllowKeyless(allow bool) error {
557 return os.WriteFile(filepath.Join(fb.settingsPath(), allowKeyless), []byte(strconv.FormatBool(allow)), 0600)
558}
559
560// SetAnonAccess sets the anonymous access level.
561//
562// It implements backend.Backend.
563func (fb *FileBackend) SetAnonAccess(level backend.AccessLevel) error {
564 return os.WriteFile(filepath.Join(fb.settingsPath(), anonAccess), []byte(level.String()), 0600)
565}
566
567// SetDescription sets the description of the given repo.
568//
569// It implements backend.Backend.
570func (fb *FileBackend) SetDescription(repo string, desc string) error {
571 repo = utils.SanitizeRepo(repo) + ".git"
572 return os.WriteFile(filepath.Join(fb.reposPath(), repo, description), []byte(desc), 0600)
573}
574
575// SetPrivate sets the private status of the given repo.
576//
577// It implements backend.Backend.
578func (fb *FileBackend) SetPrivate(repo string, priv bool) error {
579 repo = utils.SanitizeRepo(repo) + ".git"
580 daemonExport := filepath.Join(fb.reposPath(), repo, exportOk)
581 if priv {
582 _ = os.Remove(daemonExport)
583 f, err := os.Create(filepath.Join(fb.reposPath(), repo, private))
584 if err != nil {
585 return fmt.Errorf("failed to create private file: %w", err)
586 }
587
588 _ = f.Close() //nolint:errcheck
589 } else {
590 // Create git-daemon-export-ok file if repo is public.
591 f, err := os.Create(daemonExport)
592 if err != nil {
593 logger.Warn("failed to create git-daemon-export-ok file", "err", err)
594 } else {
595 _ = f.Close() //nolint:errcheck
596 }
597 }
598 return nil
599}
600
601// CreateRepository creates a new repository.
602//
603// Created repositories are always bare.
604//
605// It implements backend.Backend.
606func (fb *FileBackend) CreateRepository(repo string, private bool) (backend.Repository, error) {
607 name := utils.SanitizeRepo(repo)
608 repo = name + ".git"
609 rp := filepath.Join(fb.reposPath(), repo)
610 if _, err := os.Stat(rp); err == nil {
611 return nil, os.ErrExist
612 }
613
614 if _, err := git.Init(rp, true); err != nil {
615 logger.Debug("failed to create repository", "err", err)
616 return nil, err
617 }
618
619 fb.SetPrivate(repo, private)
620 fb.SetDescription(repo, "")
621
622 r := &Repo{path: rp, root: fb.reposPath()}
623 // Add to cache.
624 fb.repos[name] = r
625 return r, nil
626}
627
628// DeleteRepository deletes the given repository.
629//
630// It implements backend.Backend.
631func (fb *FileBackend) DeleteRepository(repo string) error {
632 name := utils.SanitizeRepo(repo)
633 delete(fb.repos, name)
634 repo = name + ".git"
635 return os.RemoveAll(filepath.Join(fb.reposPath(), repo))
636}
637
638// RenameRepository renames the given repository.
639//
640// It implements backend.Backend.
641func (fb *FileBackend) RenameRepository(oldName string, newName string) error {
642 oldName = filepath.Join(fb.reposPath(), utils.SanitizeRepo(oldName)+".git")
643 newName = filepath.Join(fb.reposPath(), utils.SanitizeRepo(newName)+".git")
644 if _, err := os.Stat(oldName); errors.Is(err, os.ErrNotExist) {
645 return fmt.Errorf("repository %q does not exist", strings.TrimSuffix(filepath.Base(oldName), ".git"))
646 }
647 if _, err := os.Stat(newName); err == nil {
648 return fmt.Errorf("repository %q already exists", strings.TrimSuffix(filepath.Base(newName), ".git"))
649 }
650
651 return os.Rename(oldName, newName)
652}
653
654// Repository finds the given repository.
655//
656// It implements backend.Backend.
657func (fb *FileBackend) Repository(repo string) (backend.Repository, error) {
658 name := utils.SanitizeRepo(repo)
659 if r, ok := fb.repos[name]; ok {
660 return r, nil
661 }
662
663 repo = name + ".git"
664 rp := filepath.Join(fb.reposPath(), repo)
665 _, err := os.Stat(rp)
666 if err != nil {
667 if errors.Is(err, os.ErrNotExist) {
668 return nil, os.ErrNotExist
669 }
670 return nil, err
671 }
672
673 return &Repo{path: rp, root: fb.reposPath()}, nil
674}
675
676// Returns true if path is a directory containing an `objects` directory and a
677// `HEAD` file.
678func isGitDir(path string) bool {
679 stat, err := os.Stat(filepath.Join(path, "objects"))
680 if err != nil {
681 return false
682 }
683 if !stat.IsDir() {
684 return false
685 }
686
687 stat, err = os.Stat(filepath.Join(path, "HEAD"))
688 if err != nil {
689 return false
690 }
691 if stat.IsDir() {
692 return false
693 }
694
695 return true
696}
697
698// initRepos initializes the repository cache.
699func (fb *FileBackend) initRepos() error {
700 fb.repos = make(map[string]*Repo)
701 repos := make([]backend.Repository, 0)
702 err := filepath.WalkDir(fb.reposPath(), func(path string, d fs.DirEntry, _ error) error {
703 // Skip non-directories.
704 if !d.IsDir() {
705 return nil
706 }
707
708 // Skip non-repositories.
709 if !strings.HasSuffix(path, ".git") {
710 return nil
711 }
712
713 if isGitDir(path) {
714 r := &Repo{path: path, root: fb.reposPath()}
715 fb.repos[r.Name()] = r
716 repos = append(repos, r)
717 }
718
719 return nil
720 })
721 if err != nil {
722 return err
723 }
724
725 return nil
726}
727
728// Repositories returns a list of all repositories.
729//
730// It implements backend.Backend.
731func (fb *FileBackend) Repositories() ([]backend.Repository, error) {
732 repos := make([]backend.Repository, 0)
733 for _, r := range fb.repos {
734 repos = append(repos, r)
735 }
736
737 return repos, nil
738}