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