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 gitm "github.com/gogs/git-module"
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 serverHost = "host"
46 serverName = "name"
47 serverPort = "port"
48 repos = "repos"
49 collabs = "collaborators"
50 description = "description"
51 exportOk = "git-daemon-export-ok"
52 settings = "settings"
53)
54
55var (
56 logger = log.WithPrefix("backend.file")
57
58 defaults = map[string]string{
59 serverName: "Soft Serve",
60 serverHost: "localhost",
61 serverPort: "23231",
62 anonAccess: backend.ReadOnlyAccess.String(),
63 allowKeyless: "true",
64 }
65)
66
67var _ backend.Backend = &FileBackend{}
68
69var _ backend.AccessMethod = &FileBackend{}
70
71// FileBackend is a backend that uses the filesystem.
72type FileBackend struct { // nolint:revive
73 // path is the path to the directory containing the repositories and config
74 // files.
75 path string
76 // AdditionalAdmins additional admins to the server.
77 AdditionalAdmins []string
78}
79
80func (fb *FileBackend) reposPath() string {
81 return filepath.Join(fb.path, repos)
82}
83
84func (fb *FileBackend) settingsPath() string {
85 return filepath.Join(fb.path, settings)
86}
87
88func (fb *FileBackend) adminsPath() string {
89 return filepath.Join(fb.settingsPath(), admins)
90}
91
92func (fb *FileBackend) collabsPath(repo string) string {
93 return filepath.Join(fb.reposPath(), repo, collabs)
94}
95
96func sanatizeRepo(repo string) string {
97 return strings.TrimSuffix(repo, ".git")
98}
99
100func readOneLine(path string) (string, error) {
101 f, err := os.Open(path)
102 if err != nil {
103 return "", err
104 }
105 defer f.Close() // nolint:errcheck
106 s := bufio.NewScanner(f)
107 s.Scan()
108 return s.Text(), s.Err()
109}
110
111func readAll(path string) (string, error) {
112 f, err := os.Open(path)
113 if err != nil {
114 return "", err
115 }
116
117 bts, err := io.ReadAll(f)
118 return string(bts), err
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} {
125 if err := os.MkdirAll(filepath.Join(path, dir), 0755); err != nil {
126 return nil, err
127 }
128 }
129 for _, file := range []string{admins, anonAccess, allowKeyless, serverHost, serverName, serverPort} {
130 fp := filepath.Join(fb.settingsPath(), file)
131 _, err := os.Stat(fp)
132 if errors.Is(err, fs.ErrNotExist) {
133 f, err := os.Create(fp)
134 if err != nil {
135 return nil, err
136 }
137 if c, ok := defaults[file]; ok {
138 io.WriteString(f, c) // nolint:errcheck
139 }
140 _ = f.Close()
141 }
142 }
143 return fb, nil
144}
145
146// AccessLevel returns the access level for the given public key and repo.
147//
148// It implements backend.AccessMethod.
149func (fb *FileBackend) AccessLevel(repo string, pk gossh.PublicKey) backend.AccessLevel {
150 private := fb.IsPrivate(repo)
151 anon := fb.AnonAccess()
152 if pk != nil {
153 // Check if the key is an admin.
154 if fb.IsAdmin(pk) {
155 return backend.AdminAccess
156 }
157
158 // Check if the key is a collaborator.
159 if fb.IsCollaborator(pk, repo) {
160 if anon > backend.ReadWriteAccess {
161 return anon
162 }
163 return backend.ReadWriteAccess
164 }
165
166 // Check if repo is private.
167 if !private {
168 if anon > backend.ReadOnlyAccess {
169 return anon
170 }
171 return backend.ReadOnlyAccess
172 }
173 }
174
175 if private {
176 return backend.NoAccess
177 }
178
179 return anon
180}
181
182// AddAdmin adds a public key to the list of server admins.
183//
184// It implements backend.Backend.
185func (fb *FileBackend) AddAdmin(pk gossh.PublicKey) error {
186 // Skip if the key already exists.
187 if fb.IsAdmin(pk) {
188 return nil
189 }
190
191 ak := backend.MarshalAuthorizedKey(pk)
192 f, err := os.OpenFile(fb.adminsPath(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
193 if err != nil {
194 logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
195 return err
196 }
197
198 defer f.Close() //nolint:errcheck
199 _, err = fmt.Fprintln(f, ak)
200 return err
201}
202
203// AddCollaborator adds a public key to the list of collaborators for the given repo.
204//
205// It implements backend.Backend.
206func (fb *FileBackend) AddCollaborator(pk gossh.PublicKey, repo string) error {
207 // Skip if the key already exists.
208 if fb.IsCollaborator(pk, repo) {
209 return nil
210 }
211
212 ak := backend.MarshalAuthorizedKey(pk)
213 repo = sanatizeRepo(repo) + ".git"
214 f, err := os.OpenFile(fb.collabsPath(repo), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
215 if err != nil {
216 logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
217 return err
218 }
219
220 defer f.Close() //nolint:errcheck
221 _, err = fmt.Fprintln(f, ak)
222 return err
223}
224
225// AllowKeyless returns true if keyless access is allowed.
226//
227// It implements backend.Backend.
228func (fb *FileBackend) AllowKeyless() bool {
229 line, err := readOneLine(filepath.Join(fb.settingsPath(), allowKeyless))
230 if err != nil {
231 logger.Debug("failed to read allow-keyless file", "err", err)
232 return false
233 }
234
235 return line == "true"
236}
237
238// AnonAccess returns the level of anonymous access allowed.
239//
240// It implements backend.Backend.
241func (fb *FileBackend) AnonAccess() backend.AccessLevel {
242 line, err := readOneLine(filepath.Join(fb.settingsPath(), anonAccess))
243 if err != nil {
244 logger.Debug("failed to read anon-access file", "err", err)
245 return backend.NoAccess
246 }
247
248 switch line {
249 case backend.NoAccess.String():
250 return backend.NoAccess
251 case backend.ReadOnlyAccess.String():
252 return backend.ReadOnlyAccess
253 case backend.ReadWriteAccess.String():
254 return backend.ReadWriteAccess
255 case backend.AdminAccess.String():
256 return backend.AdminAccess
257 default:
258 return backend.NoAccess
259 }
260}
261
262// Description returns the description of the given repo.
263//
264// It implements backend.Backend.
265func (fb *FileBackend) Description(repo string) string {
266 repo = sanatizeRepo(repo) + ".git"
267 r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
268 return r.Description()
269}
270
271// IsAdmin checks if the given public key is a server admin.
272//
273// It implements backend.Backend.
274func (fb *FileBackend) IsAdmin(pk gossh.PublicKey) bool {
275 // Check if the key is an additional admin.
276 ak := backend.MarshalAuthorizedKey(pk)
277 for _, admin := range fb.AdditionalAdmins {
278 if ak == admin {
279 return true
280 }
281 }
282
283 f, err := os.Open(fb.adminsPath())
284 if err != nil {
285 logger.Debug("failed to open admins file", "err", err, "path", fb.adminsPath())
286 return false
287 }
288
289 defer f.Close() //nolint:errcheck
290 s := bufio.NewScanner(f)
291 for s.Scan() {
292 apk, _, err := backend.ParseAuthorizedKey(s.Text())
293 if err != nil {
294 continue
295 }
296 if ssh.KeysEqual(apk, pk) {
297 return true
298 }
299 }
300
301 return false
302}
303
304// IsCollaborator returns true if the given public key is a collaborator on the
305// given repo.
306//
307// It implements backend.Backend.
308func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, repo string) bool {
309 repo = sanatizeRepo(repo) + ".git"
310 _, err := os.Stat(filepath.Join(fb.reposPath(), repo))
311 if errors.Is(err, os.ErrNotExist) {
312 return false
313 }
314
315 f, err := os.Open(fb.collabsPath(repo))
316 if err != nil && errors.Is(err, os.ErrNotExist) {
317 return false
318 }
319 if err != nil {
320 logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
321 return false
322 }
323
324 defer f.Close() //nolint:errcheck
325 s := bufio.NewScanner(f)
326 for s.Scan() {
327 apk, _, err := backend.ParseAuthorizedKey(s.Text())
328 if err != nil {
329 continue
330 }
331 if ssh.KeysEqual(apk, pk) {
332 return true
333 }
334 }
335
336 return false
337}
338
339// IsPrivate returns true if the given repo is private.
340//
341// It implements backend.Backend.
342func (fb *FileBackend) IsPrivate(repo string) bool {
343 repo = sanatizeRepo(repo) + ".git"
344 r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
345 return r.IsPrivate()
346}
347
348// ServerHost returns the server host.
349//
350// It implements backend.Backend.
351func (fb *FileBackend) ServerHost() string {
352 line, err := readOneLine(filepath.Join(fb.settingsPath(), serverHost))
353 if err != nil {
354 logger.Debug("failed to read server-host file", "err", err)
355 return ""
356 }
357
358 return line
359}
360
361// ServerName returns the server name.
362//
363// It implements backend.Backend.
364func (fb *FileBackend) ServerName() string {
365 line, err := readOneLine(filepath.Join(fb.settingsPath(), serverName))
366 if err != nil {
367 logger.Debug("failed to read server-name file", "err", err)
368 return ""
369 }
370
371 return line
372}
373
374// ServerPort returns the server port.
375//
376// It implements backend.Backend.
377func (fb *FileBackend) ServerPort() string {
378 line, err := readOneLine(filepath.Join(fb.settingsPath(), serverPort))
379 if err != nil {
380 logger.Debug("failed to read server-port file", "err", err)
381 return ""
382 }
383
384 if _, err := strconv.Atoi(line); err != nil {
385 logger.Debug("failed to parse server-port file", "err", err)
386 return ""
387 }
388
389 return line
390}
391
392// SetAllowKeyless sets whether or not to allow keyless access.
393//
394// It implements backend.Backend.
395func (fb *FileBackend) SetAllowKeyless(allow bool) error {
396 f, err := os.OpenFile(filepath.Join(fb.settingsPath(), allowKeyless), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
397 if err != nil {
398 return fmt.Errorf("failed to open allow-keyless file: %w", err)
399 }
400
401 defer f.Close() //nolint:errcheck
402 _, err = fmt.Fprintln(f, allow)
403 return err
404}
405
406// SetAnonAccess sets the anonymous access level.
407//
408// It implements backend.Backend.
409func (fb *FileBackend) SetAnonAccess(level backend.AccessLevel) error {
410 f, err := os.OpenFile(filepath.Join(fb.settingsPath(), anonAccess), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
411 if err != nil {
412 return fmt.Errorf("failed to open anon-access file: %w", err)
413 }
414
415 defer f.Close() //nolint:errcheck
416 _, err = fmt.Fprintln(f, level.String())
417 return err
418}
419
420// SetDescription sets the description of the given repo.
421//
422// It implements backend.Backend.
423func (fb *FileBackend) SetDescription(repo string, desc string) error {
424 f, err := os.OpenFile(filepath.Join(fb.reposPath(), repo, description), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
425 if err != nil {
426 return fmt.Errorf("failed to open description file: %w", err)
427 }
428
429 defer f.Close() //nolint:errcheck
430 _, err = fmt.Fprintln(f, desc)
431 return err
432}
433
434// SetPrivate sets the private status of the given repo.
435//
436// It implements backend.Backend.
437func (fb *FileBackend) SetPrivate(repo string, priv bool) error {
438 repo = sanatizeRepo(repo) + ".git"
439 daemonExport := filepath.Join(fb.reposPath(), repo, exportOk)
440 if priv {
441 _ = os.Remove(daemonExport)
442 } else {
443 // Create git-daemon-export-ok file if repo is public.
444 f, err := os.Create(daemonExport)
445 if err != nil {
446 logger.Warn("failed to create git-daemon-export-ok file", "err", err)
447 } else {
448 _ = f.Close() //nolint:errcheck
449 }
450 }
451 return nil
452}
453
454// SetServerHost sets the server host.
455//
456// It implements backend.Backend.
457func (fb *FileBackend) SetServerHost(host string) error {
458 f, err := os.Create(filepath.Join(fb.settingsPath(), serverHost))
459 if err != nil {
460 return fmt.Errorf("failed to create server-host file: %w", err)
461 }
462
463 defer f.Close() //nolint:errcheck
464 _, err = fmt.Fprintln(f, host)
465 return err
466}
467
468// SetServerName sets the server name.
469//
470// It implements backend.Backend.
471func (fb *FileBackend) SetServerName(name string) error {
472 f, err := os.Create(filepath.Join(fb.settingsPath(), serverName))
473 if err != nil {
474 return fmt.Errorf("failed to create server-name file: %w", err)
475 }
476
477 defer f.Close() //nolint:errcheck
478 _, err = fmt.Fprintln(f, name)
479 return err
480}
481
482// SetServerPort sets the server port.
483//
484// It implements backend.Backend.
485func (fb *FileBackend) SetServerPort(port string) error {
486 f, err := os.Create(filepath.Join(fb.settingsPath(), serverPort))
487 if err != nil {
488 return fmt.Errorf("failed to create server-port file: %w", err)
489 }
490
491 defer f.Close() //nolint:errcheck
492 _, err = fmt.Fprintln(f, port)
493 return err
494}
495
496// CreateRepository creates a new repository.
497//
498// Created repositories are always bare.
499//
500// It implements backend.Backend.
501func (fb *FileBackend) CreateRepository(name string, private bool) (backend.Repository, error) {
502 name = sanatizeRepo(name) + ".git"
503 rp := filepath.Join(fb.reposPath(), name)
504 if _, err := git.Init(rp, true); err != nil {
505 logger.Debug("failed to create repository", "err", err)
506 return nil, err
507 }
508
509 fb.SetPrivate(name, private)
510 fb.SetDescription(name, "")
511
512 return &Repo{path: rp, root: fb.reposPath()}, nil
513}
514
515// DeleteRepository deletes the given repository.
516//
517// It implements backend.Backend.
518func (fb *FileBackend) DeleteRepository(name string) error {
519 name = sanatizeRepo(name) + ".git"
520 return os.RemoveAll(filepath.Join(fb.reposPath(), name))
521}
522
523// RenameRepository renames the given repository.
524//
525// It implements backend.Backend.
526func (fb *FileBackend) RenameRepository(oldName string, newName string) error {
527 oldName = sanatizeRepo(oldName) + ".git"
528 newName = sanatizeRepo(newName) + ".git"
529 return os.Rename(filepath.Join(fb.reposPath(), oldName), filepath.Join(fb.reposPath(), newName))
530}
531
532// Repository finds the given repository.
533//
534// It implements backend.Backend.
535func (fb *FileBackend) Repository(repo string) (backend.Repository, error) {
536 repo = sanatizeRepo(repo) + ".git"
537 rp := filepath.Join(fb.reposPath(), repo)
538 _, err := os.Stat(rp)
539 if !errors.Is(err, os.ErrExist) {
540 return nil, err
541 }
542
543 return &Repo{path: rp, root: fb.reposPath()}, nil
544}
545
546// Repositories returns a list of all repositories.
547//
548// It implements backend.Backend.
549func (fb *FileBackend) Repositories() ([]backend.Repository, error) {
550 repos := make([]backend.Repository, 0)
551 err := filepath.WalkDir(fb.reposPath(), func(path string, d fs.DirEntry, _ error) error {
552 // Skip non-directories.
553 if !d.IsDir() {
554 return nil
555 }
556
557 // Skip non-repositories.
558 if !strings.HasSuffix(path, ".git") {
559 return nil
560 }
561
562 repos = append(repos, &Repo{path: path, root: fb.reposPath()})
563
564 return nil
565 })
566 if err != nil {
567 return nil, err
568 }
569
570 return repos, nil
571}
572
573// DefaultBranch returns the default branch of the given repository.
574//
575// It implements backend.Backend.
576func (fb *FileBackend) DefaultBranch(repo string) (string, error) {
577 rr, err := fb.Repository(repo)
578 if err != nil {
579 logger.Debug("failed to get default branch", "err", err)
580 return "", err
581 }
582
583 r, err := rr.Repository()
584 if err != nil {
585 logger.Debug("failed to open repository for default branch", "err", err)
586 return "", err
587 }
588
589 head, err := r.HEAD()
590 if err != nil {
591 logger.Debug("failed to get HEAD for default branch", "err", err)
592 return "", err
593 }
594
595 return head.Name().Short(), nil
596}
597
598// SetDefaultBranch sets the default branch for the given repository.
599//
600// It implements backend.Backend.
601func (fb *FileBackend) SetDefaultBranch(repo string, branch string) error {
602 rr, err := fb.Repository(repo)
603 if err != nil {
604 logger.Debug("failed to get repository for default branch", "err", err)
605 return err
606 }
607
608 r, err := rr.Repository()
609 if err != nil {
610 logger.Debug("failed to open repository for default branch", "err", err)
611 return err
612 }
613
614 if _, err := r.SymbolicRef("HEAD", gitm.RefsHeads+branch); err != nil {
615 logger.Debug("failed to set default branch", "err", err)
616 return err
617 }
618
619 return nil
620}