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