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