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