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
83// RepositoryStorePath returns the path to the repository store.
84func (fb *FileBackend) RepositoryStorePath() string {
85 return fb.reposPath()
86}
87
88func (fb *FileBackend) settingsPath() string {
89 return filepath.Join(fb.path, settings)
90}
91
92func (fb *FileBackend) adminsPath() string {
93 return filepath.Join(fb.settingsPath(), admins)
94}
95
96func (fb *FileBackend) collabsPath(repo string) string {
97 return filepath.Join(fb.path, collabs, repo)
98}
99
100func sanatizeRepo(repo string) string {
101 return strings.TrimSuffix(repo, ".git")
102}
103
104func readOneLine(path string) (string, error) {
105 f, err := os.Open(path)
106 if err != nil {
107 return "", err
108 }
109 defer f.Close() // nolint:errcheck
110 s := bufio.NewScanner(f)
111 s.Scan()
112 return s.Text(), s.Err()
113}
114
115func readAll(path string) (string, error) {
116 f, err := os.Open(path)
117 if err != nil {
118 return "", err
119 }
120
121 bts, err := io.ReadAll(f)
122 return string(bts), err
123}
124
125// exists returns true if the given path exists.
126func exists(path string) bool {
127 _, err := os.Stat(path)
128 return err == nil
129}
130
131// NewFileBackend creates a new FileBackend.
132func NewFileBackend(path string) (*FileBackend, error) {
133 fb := &FileBackend{path: path}
134 for _, dir := range []string{repos, settings, collabs} {
135 if err := os.MkdirAll(filepath.Join(path, dir), 0755); err != nil {
136 return nil, err
137 }
138 }
139 for _, file := range []string{admins, anonAccess, allowKeyless, serverHost, serverName, serverPort} {
140 fp := filepath.Join(fb.settingsPath(), file)
141 _, err := os.Stat(fp)
142 if errors.Is(err, fs.ErrNotExist) {
143 f, err := os.Create(fp)
144 if err != nil {
145 return nil, err
146 }
147 if c, ok := defaults[file]; ok {
148 io.WriteString(f, c) // nolint:errcheck
149 }
150 _ = f.Close()
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, name string) error {
220 // Check if repo exists
221 if !exists(filepath.Join(fb.reposPath(), sanatizeRepo(name)+".git")) {
222 return fmt.Errorf("repository %s does not exist", name)
223 }
224
225 // Skip if the key already exists.
226 if fb.IsCollaborator(pk, name) {
227 return fmt.Errorf("key already exists")
228 }
229
230 ak := backend.MarshalAuthorizedKey(pk)
231 name = sanatizeRepo(name)
232 if err := os.MkdirAll(filepath.Dir(fb.collabsPath(name)), 0755); err != nil {
233 logger.Debug("failed to create collaborators directory",
234 "err", err, "path", filepath.Dir(fb.collabsPath(name)))
235 return err
236 }
237
238 f, err := os.OpenFile(fb.collabsPath(name), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
239 if err != nil {
240 logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(name))
241 return err
242 }
243
244 defer f.Close() //nolint:errcheck
245 if memo != "" {
246 memo = " " + memo
247 }
248 _, err = fmt.Fprintf(f, "%s%s\n", ak, memo)
249 return err
250}
251
252// Admins returns a list of public keys that are admins.
253//
254// It implements backend.Backend.
255func (fb *FileBackend) Admins() ([]string, error) {
256 admins := make([]string, 0)
257 f, err := os.Open(fb.adminsPath())
258 if err != nil {
259 logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
260 return nil, err
261 }
262
263 defer f.Close() //nolint:errcheck
264 s := bufio.NewScanner(f)
265 for s.Scan() {
266 admins = append(admins, s.Text())
267 }
268
269 return admins, s.Err()
270}
271
272// Collaborators returns a list of public keys that are collaborators for the given repo.
273//
274// It implements backend.Backend.
275func (fb *FileBackend) Collaborators(repo string) ([]string, error) {
276 // Check if repo exists
277 if !exists(filepath.Join(fb.reposPath(), sanatizeRepo(repo)+".git")) {
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 implements backend.Backend
301func (fb *FileBackend) RemoveAdmin(pk gossh.PublicKey) error {
302 f, err := os.OpenFile(fb.adminsPath(), os.O_RDWR, 0644)
303 if err != nil {
304 logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
305 return err
306 }
307
308 defer f.Close() //nolint:errcheck
309 s := bufio.NewScanner(f)
310 lines := make([]string, 0)
311 for s.Scan() {
312 apk, _, err := backend.ParseAuthorizedKey(s.Text())
313 if err != nil {
314 logger.Debug("failed to parse admin key", "err", err, "path", fb.adminsPath())
315 continue
316 }
317
318 if !ssh.KeysEqual(apk, pk) {
319 lines = append(lines, s.Text())
320 }
321 }
322
323 if err := s.Err(); err != nil {
324 logger.Debug("failed to scan admin keys file", "err", err, "path", fb.adminsPath())
325 return err
326 }
327
328 if err := f.Truncate(0); err != nil {
329 logger.Debug("failed to truncate admin keys file", "err", err, "path", fb.adminsPath())
330 return err
331 }
332
333 if _, err := f.Seek(0, 0); err != nil {
334 logger.Debug("failed to seek admin keys file", "err", err, "path", fb.adminsPath())
335 return err
336 }
337
338 w := bufio.NewWriter(f)
339 for _, line := range lines {
340 if _, err := fmt.Fprintln(w, line); err != nil {
341 logger.Debug("failed to write admin keys file", "err", err, "path", fb.adminsPath())
342 return err
343 }
344 }
345
346 return w.Flush()
347}
348
349// RemoveCollaborator removes a public key from the list of collaborators for the given repo.
350//
351// It implements backend.Backend.
352func (fb *FileBackend) RemoveCollaborator(pk gossh.PublicKey, repo string) error {
353 // Check if repo exists
354 if !exists(filepath.Join(fb.reposPath(), sanatizeRepo(repo)+".git")) {
355 return fmt.Errorf("repository %s does not exist", repo)
356 }
357
358 f, err := os.OpenFile(fb.collabsPath(repo), os.O_RDWR, 0644)
359 if err != nil && errors.Is(err, os.ErrNotExist) {
360 return nil
361 }
362
363 if err != nil {
364 logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
365 return err
366 }
367
368 defer f.Close() //nolint:errcheck
369 s := bufio.NewScanner(f)
370 lines := make([]string, 0)
371 for s.Scan() {
372 apk, _, err := backend.ParseAuthorizedKey(s.Text())
373 if err != nil {
374 logger.Debug("failed to parse collaborator key", "err", err, "path", fb.collabsPath(repo))
375 continue
376 }
377
378 if !ssh.KeysEqual(apk, pk) {
379 lines = append(lines, s.Text())
380 }
381 }
382
383 if err := s.Err(); err != nil {
384 logger.Debug("failed to scan collaborators file", "err", err, "path", fb.collabsPath(repo))
385 return err
386 }
387
388 if err := f.Truncate(0); err != nil {
389 logger.Debug("failed to truncate collaborators file", "err", err, "path", fb.collabsPath(repo))
390 return err
391 }
392
393 if _, err := f.Seek(0, 0); err != nil {
394 logger.Debug("failed to seek collaborators file", "err", err, "path", fb.collabsPath(repo))
395 return err
396 }
397
398 w := bufio.NewWriter(f)
399 for _, line := range lines {
400 if _, err := fmt.Fprintln(w, line); err != nil {
401 logger.Debug("failed to write collaborators file", "err", err, "path", fb.collabsPath(repo))
402 return err
403 }
404 }
405
406 return w.Flush()
407}
408
409// AllowKeyless returns true if keyless access is allowed.
410//
411// It implements backend.Backend.
412func (fb *FileBackend) AllowKeyless() bool {
413 line, err := readOneLine(filepath.Join(fb.settingsPath(), allowKeyless))
414 if err != nil {
415 logger.Debug("failed to read allow-keyless file", "err", err)
416 return false
417 }
418
419 return line == "true"
420}
421
422// AnonAccess returns the level of anonymous access allowed.
423//
424// It implements backend.Backend.
425func (fb *FileBackend) AnonAccess() backend.AccessLevel {
426 line, err := readOneLine(filepath.Join(fb.settingsPath(), anonAccess))
427 if err != nil {
428 logger.Debug("failed to read anon-access file", "err", err)
429 return backend.NoAccess
430 }
431
432 switch line {
433 case backend.NoAccess.String():
434 return backend.NoAccess
435 case backend.ReadOnlyAccess.String():
436 return backend.ReadOnlyAccess
437 case backend.ReadWriteAccess.String():
438 return backend.ReadWriteAccess
439 case backend.AdminAccess.String():
440 return backend.AdminAccess
441 default:
442 return backend.NoAccess
443 }
444}
445
446// Description returns the description of the given repo.
447//
448// It implements backend.Backend.
449func (fb *FileBackend) Description(repo string) string {
450 repo = sanatizeRepo(repo) + ".git"
451 r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
452 return r.Description()
453}
454
455// IsAdmin checks if the given public key is a server admin.
456//
457// It implements backend.Backend.
458func (fb *FileBackend) IsAdmin(pk gossh.PublicKey) bool {
459 // Check if the key is an additional admin.
460 ak := backend.MarshalAuthorizedKey(pk)
461 for _, admin := range fb.AdditionalAdmins {
462 if ak == admin {
463 return true
464 }
465 }
466
467 f, err := os.Open(fb.adminsPath())
468 if err != nil {
469 logger.Debug("failed to open admins file", "err", err, "path", fb.adminsPath())
470 return false
471 }
472
473 defer f.Close() //nolint:errcheck
474 s := bufio.NewScanner(f)
475 for s.Scan() {
476 apk, _, err := backend.ParseAuthorizedKey(s.Text())
477 if err != nil {
478 continue
479 }
480 if ssh.KeysEqual(apk, pk) {
481 return true
482 }
483 }
484
485 return false
486}
487
488// IsCollaborator returns true if the given public key is a collaborator on the
489// given repo.
490//
491// It implements backend.Backend.
492func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, name string) bool {
493 name = sanatizeRepo(name)
494 _, err := os.Stat(fb.collabsPath(name))
495 if err != nil {
496 return false
497 }
498
499 f, err := os.Open(fb.collabsPath(name))
500 if err != nil {
501 logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(name))
502 return false
503 }
504
505 defer f.Close() //nolint:errcheck
506 s := bufio.NewScanner(f)
507 for s.Scan() {
508 apk, _, err := backend.ParseAuthorizedKey(s.Text())
509 if err != nil {
510 continue
511 }
512 if ssh.KeysEqual(apk, pk) {
513 return true
514 }
515 }
516
517 return false
518}
519
520// IsPrivate returns true if the given repo is private.
521//
522// It implements backend.Backend.
523func (fb *FileBackend) IsPrivate(repo string) bool {
524 repo = sanatizeRepo(repo) + ".git"
525 r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
526 return r.IsPrivate()
527}
528
529// ServerHost returns the server host.
530//
531// It implements backend.Backend.
532func (fb *FileBackend) ServerHost() string {
533 line, err := readOneLine(filepath.Join(fb.settingsPath(), serverHost))
534 if err != nil {
535 logger.Debug("failed to read server-host file", "err", err)
536 return ""
537 }
538
539 return line
540}
541
542// ServerName returns the server name.
543//
544// It implements backend.Backend.
545func (fb *FileBackend) ServerName() string {
546 line, err := readOneLine(filepath.Join(fb.settingsPath(), serverName))
547 if err != nil {
548 logger.Debug("failed to read server-name file", "err", err)
549 return ""
550 }
551
552 return line
553}
554
555// ServerPort returns the server port.
556//
557// It implements backend.Backend.
558func (fb *FileBackend) ServerPort() string {
559 line, err := readOneLine(filepath.Join(fb.settingsPath(), serverPort))
560 if err != nil {
561 logger.Debug("failed to read server-port file", "err", err)
562 return ""
563 }
564
565 if _, err := strconv.Atoi(line); err != nil {
566 logger.Debug("failed to parse server-port file", "err", err)
567 return ""
568 }
569
570 return line
571}
572
573// SetAllowKeyless sets whether or not to allow keyless access.
574//
575// It implements backend.Backend.
576func (fb *FileBackend) SetAllowKeyless(allow bool) error {
577 f, err := os.OpenFile(filepath.Join(fb.settingsPath(), allowKeyless), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
578 if err != nil {
579 return fmt.Errorf("failed to open allow-keyless file: %w", err)
580 }
581
582 defer f.Close() //nolint:errcheck
583 _, err = fmt.Fprintln(f, allow)
584 return err
585}
586
587// SetAnonAccess sets the anonymous access level.
588//
589// It implements backend.Backend.
590func (fb *FileBackend) SetAnonAccess(level backend.AccessLevel) error {
591 f, err := os.OpenFile(filepath.Join(fb.settingsPath(), anonAccess), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
592 if err != nil {
593 return fmt.Errorf("failed to open anon-access file: %w", err)
594 }
595
596 defer f.Close() //nolint:errcheck
597 _, err = fmt.Fprintln(f, level.String())
598 return err
599}
600
601// SetDescription sets the description of the given repo.
602//
603// It implements backend.Backend.
604func (fb *FileBackend) SetDescription(repo string, desc string) error {
605 repo = sanatizeRepo(repo) + ".git"
606 f, err := os.OpenFile(filepath.Join(fb.reposPath(), repo, description), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
607 if err != nil {
608 return fmt.Errorf("failed to open description file: %w", err)
609 }
610
611 defer f.Close() //nolint:errcheck
612 _, err = fmt.Fprintln(f, desc)
613 return err
614}
615
616// SetPrivate sets the private status of the given repo.
617//
618// It implements backend.Backend.
619func (fb *FileBackend) SetPrivate(repo string, priv bool) error {
620 repo = sanatizeRepo(repo) + ".git"
621 daemonExport := filepath.Join(fb.reposPath(), repo, exportOk)
622 if priv {
623 _ = os.Remove(daemonExport)
624 } else {
625 // Create git-daemon-export-ok file if repo is public.
626 f, err := os.Create(daemonExport)
627 if err != nil {
628 logger.Warn("failed to create git-daemon-export-ok file", "err", err)
629 } else {
630 _ = f.Close() //nolint:errcheck
631 }
632 }
633 return nil
634}
635
636// SetServerHost sets the server host.
637//
638// It implements backend.Backend.
639func (fb *FileBackend) SetServerHost(host string) error {
640 f, err := os.Create(filepath.Join(fb.settingsPath(), serverHost))
641 if err != nil {
642 return fmt.Errorf("failed to create server-host file: %w", err)
643 }
644
645 defer f.Close() //nolint:errcheck
646 _, err = fmt.Fprintln(f, host)
647 return err
648}
649
650// SetServerName sets the server name.
651//
652// It implements backend.Backend.
653func (fb *FileBackend) SetServerName(name string) error {
654 f, err := os.Create(filepath.Join(fb.settingsPath(), serverName))
655 if err != nil {
656 return fmt.Errorf("failed to create server-name file: %w", err)
657 }
658
659 defer f.Close() //nolint:errcheck
660 _, err = fmt.Fprintln(f, name)
661 return err
662}
663
664// SetServerPort sets the server port.
665//
666// It implements backend.Backend.
667func (fb *FileBackend) SetServerPort(port string) error {
668 f, err := os.Create(filepath.Join(fb.settingsPath(), serverPort))
669 if err != nil {
670 return fmt.Errorf("failed to create server-port file: %w", err)
671 }
672
673 defer f.Close() //nolint:errcheck
674 _, err = fmt.Fprintln(f, port)
675 return err
676}
677
678// CreateRepository creates a new repository.
679//
680// Created repositories are always bare.
681//
682// It implements backend.Backend.
683func (fb *FileBackend) CreateRepository(name string, private bool) (backend.Repository, error) {
684 name = sanatizeRepo(name) + ".git"
685 rp := filepath.Join(fb.reposPath(), name)
686 if _, err := os.Stat(rp); err == nil {
687 return nil, os.ErrExist
688 }
689
690 if _, err := git.Init(rp, true); err != nil {
691 logger.Debug("failed to create repository", "err", err)
692 return nil, err
693 }
694
695 fb.SetPrivate(name, private)
696 fb.SetDescription(name, "")
697
698 return &Repo{path: rp, root: fb.reposPath()}, nil
699}
700
701// DeleteRepository deletes the given repository.
702//
703// It implements backend.Backend.
704func (fb *FileBackend) DeleteRepository(name string) error {
705 name = sanatizeRepo(name) + ".git"
706 return os.RemoveAll(filepath.Join(fb.reposPath(), name))
707}
708
709// RenameRepository renames the given repository.
710//
711// It implements backend.Backend.
712func (fb *FileBackend) RenameRepository(oldName string, newName string) error {
713 oldName = filepath.Join(fb.reposPath(), sanatizeRepo(oldName)+".git")
714 newName = filepath.Join(fb.reposPath(), sanatizeRepo(newName)+".git")
715 if _, err := os.Stat(oldName); errors.Is(err, os.ErrNotExist) {
716 return fmt.Errorf("repository %q does not exist", strings.TrimSuffix(filepath.Base(oldName), ".git"))
717 }
718 if _, err := os.Stat(newName); err == nil {
719 return fmt.Errorf("repository %q already exists", strings.TrimSuffix(filepath.Base(newName), ".git"))
720 }
721
722 return os.Rename(oldName, newName)
723}
724
725// Repository finds the given repository.
726//
727// It implements backend.Backend.
728func (fb *FileBackend) Repository(repo string) (backend.Repository, error) {
729 repo = sanatizeRepo(repo) + ".git"
730 rp := filepath.Join(fb.reposPath(), repo)
731 _, err := os.Stat(rp)
732 if err != nil {
733 if errors.Is(err, os.ErrNotExist) {
734 return nil, os.ErrNotExist
735 }
736 return nil, err
737 }
738
739 return &Repo{path: rp, root: fb.reposPath()}, nil
740}
741
742// Repositories returns a list of all repositories.
743//
744// It implements backend.Backend.
745func (fb *FileBackend) Repositories() ([]backend.Repository, error) {
746 repos := make([]backend.Repository, 0)
747 err := filepath.WalkDir(fb.reposPath(), func(path string, d fs.DirEntry, _ error) error {
748 // Skip non-directories.
749 if !d.IsDir() {
750 return nil
751 }
752
753 // Skip non-repositories.
754 if !strings.HasSuffix(path, ".git") {
755 return nil
756 }
757
758 repos = append(repos, &Repo{path: path, root: fb.reposPath()})
759
760 return nil
761 })
762 if err != nil {
763 return nil, err
764 }
765
766 return repos, nil
767}