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 private = "private"
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)}
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 f, err := os.Open(fb.collabsPath(repo))
311 if err != nil {
312 logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
313 return false
314 }
315
316 defer f.Close() //nolint:errcheck
317 s := bufio.NewScanner(f)
318 for s.Scan() {
319 apk, _, err := backend.ParseAuthorizedKey(s.Text())
320 if err != nil {
321 continue
322 }
323 if ssh.KeysEqual(apk, pk) {
324 return true
325 }
326 }
327
328 return false
329}
330
331// IsPrivate returns true if the given repo is private.
332//
333// It implements backend.Backend.
334func (fb *FileBackend) IsPrivate(repo string) bool {
335 repo = sanatizeRepo(repo) + ".git"
336 r := &Repo{path: filepath.Join(fb.reposPath(), repo)}
337 return r.IsPrivate()
338}
339
340// ServerHost returns the server host.
341//
342// It implements backend.Backend.
343func (fb *FileBackend) ServerHost() string {
344 line, err := readOneLine(filepath.Join(fb.settingsPath(), serverHost))
345 if err != nil {
346 logger.Debug("failed to read server-host file", "err", err)
347 return ""
348 }
349
350 return line
351}
352
353// ServerName returns the server name.
354//
355// It implements backend.Backend.
356func (fb *FileBackend) ServerName() string {
357 line, err := readOneLine(filepath.Join(fb.settingsPath(), serverName))
358 if err != nil {
359 logger.Debug("failed to read server-name file", "err", err)
360 return ""
361 }
362
363 return line
364}
365
366// ServerPort returns the server port.
367//
368// It implements backend.Backend.
369func (fb *FileBackend) ServerPort() string {
370 line, err := readOneLine(filepath.Join(fb.settingsPath(), serverPort))
371 if err != nil {
372 logger.Debug("failed to read server-port file", "err", err)
373 return ""
374 }
375
376 if _, err := strconv.Atoi(line); err != nil {
377 logger.Debug("failed to parse server-port file", "err", err)
378 return ""
379 }
380
381 return line
382}
383
384// SetAllowKeyless sets whether or not to allow keyless access.
385//
386// It implements backend.Backend.
387func (fb *FileBackend) SetAllowKeyless(allow bool) error {
388 f, err := os.OpenFile(filepath.Join(fb.settingsPath(), allowKeyless), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
389 if err != nil {
390 return fmt.Errorf("failed to open allow-keyless file: %w", err)
391 }
392
393 defer f.Close() //nolint:errcheck
394 _, err = fmt.Fprintln(f, allow)
395 return err
396}
397
398// SetAnonAccess sets the anonymous access level.
399//
400// It implements backend.Backend.
401func (fb *FileBackend) SetAnonAccess(level backend.AccessLevel) error {
402 f, err := os.OpenFile(filepath.Join(fb.settingsPath(), anonAccess), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
403 if err != nil {
404 return fmt.Errorf("failed to open anon-access file: %w", err)
405 }
406
407 defer f.Close() //nolint:errcheck
408 _, err = fmt.Fprintln(f, level.String())
409 return err
410}
411
412// SetDescription sets the description of the given repo.
413//
414// It implements backend.Backend.
415func (fb *FileBackend) SetDescription(repo string, desc string) error {
416 f, err := os.OpenFile(filepath.Join(fb.reposPath(), repo, description), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
417 if err != nil {
418 return fmt.Errorf("failed to open description file: %w", err)
419 }
420
421 defer f.Close() //nolint:errcheck
422 _, err = fmt.Fprintln(f, desc)
423 return err
424}
425
426// SetPrivate sets the private status of the given repo.
427//
428// It implements backend.Backend.
429func (fb *FileBackend) SetPrivate(repo string, priv bool) error {
430 repo = sanatizeRepo(repo) + ".git"
431 daemonExport := filepath.Join(fb.reposPath(), repo, "git-daemon-export-ok")
432 if priv {
433 _ = os.Remove(daemonExport)
434 f, err := os.Create(filepath.Join(fb.reposPath(), repo, private))
435 if err != nil {
436 return fmt.Errorf("failed to create private file: %w", err)
437 }
438
439 _ = f.Close() //nolint:errcheck
440 } else {
441 // Create git-daemon-export-ok file if repo is public.
442 f, err := os.Create(daemonExport)
443 if err != nil {
444 logger.Warn("failed to create git-daemon-export-ok file", "err", err)
445 } else {
446 _ = f.Close() //nolint:errcheck
447 }
448 }
449 return nil
450}
451
452// SetServerHost sets the server host.
453//
454// It implements backend.Backend.
455func (fb *FileBackend) SetServerHost(host string) error {
456 f, err := os.Create(filepath.Join(fb.settingsPath(), serverHost))
457 if err != nil {
458 return fmt.Errorf("failed to create server-host file: %w", err)
459 }
460
461 defer f.Close() //nolint:errcheck
462 _, err = fmt.Fprintln(f, host)
463 return err
464}
465
466// SetServerName sets the server name.
467//
468// It implements backend.Backend.
469func (fb *FileBackend) SetServerName(name string) error {
470 f, err := os.Create(filepath.Join(fb.settingsPath(), serverName))
471 if err != nil {
472 return fmt.Errorf("failed to create server-name file: %w", err)
473 }
474
475 defer f.Close() //nolint:errcheck
476 _, err = fmt.Fprintln(f, name)
477 return err
478}
479
480// SetServerPort sets the server port.
481//
482// It implements backend.Backend.
483func (fb *FileBackend) SetServerPort(port string) error {
484 f, err := os.Create(filepath.Join(fb.settingsPath(), serverPort))
485 if err != nil {
486 return fmt.Errorf("failed to create server-port file: %w", err)
487 }
488
489 defer f.Close() //nolint:errcheck
490 _, err = fmt.Fprintln(f, port)
491 return err
492}
493
494// CreateRepository creates a new repository.
495//
496// Created repositories are always bare.
497//
498// It implements backend.Backend.
499func (fb *FileBackend) CreateRepository(name string, private bool) (backend.Repository, error) {
500 name = sanatizeRepo(name) + ".git"
501 rp := filepath.Join(fb.reposPath(), name)
502 if _, err := git.Init(rp, true); err != nil {
503 logger.Debug("failed to create repository", "err", err)
504 return nil, err
505 }
506
507 fb.SetPrivate(name, private)
508
509 return &Repo{path: rp}, nil
510}
511
512// DeleteRepository deletes the given repository.
513//
514// It implements backend.Backend.
515func (fb *FileBackend) DeleteRepository(name string) error {
516 name = sanatizeRepo(name) + ".git"
517 return os.RemoveAll(filepath.Join(fb.reposPath(), name))
518}
519
520// RenameRepository renames the given repository.
521//
522// It implements backend.Backend.
523func (fb *FileBackend) RenameRepository(oldName string, newName string) error {
524 oldName = sanatizeRepo(oldName) + ".git"
525 newName = sanatizeRepo(newName) + ".git"
526 return os.Rename(filepath.Join(fb.reposPath(), oldName), filepath.Join(fb.reposPath(), newName))
527}
528
529// Repository finds the given repository.
530//
531// It implements backend.Backend.
532func (fb *FileBackend) Repository(repo string) (backend.Repository, error) {
533 repo = sanatizeRepo(repo) + ".git"
534 rp := filepath.Join(fb.reposPath(), repo)
535 _, err := os.Stat(rp)
536 if !errors.Is(err, os.ErrExist) {
537 return nil, err
538 }
539
540 return &Repo{path: rp}, nil
541}
542
543// Repositories returns a list of all repositories.
544//
545// It implements backend.Backend.
546func (fb *FileBackend) Repositories() ([]backend.Repository, error) {
547 repos := make([]backend.Repository, 0)
548 err := filepath.WalkDir(fb.reposPath(), func(path string, d fs.DirEntry, _ error) error {
549 // Skip non-directories.
550 if !d.IsDir() {
551 return nil
552 }
553
554 // Skip non-repositories.
555 if !strings.HasSuffix(path, ".git") {
556 return nil
557 }
558
559 repos = append(repos, &Repo{path: path})
560
561 return nil
562 })
563 if err != nil {
564 return nil, err
565 }
566
567 return repos, nil
568}
569
570// DefaultBranch returns the default branch of the given repository.
571//
572// It implements backend.Backend.
573func (fb *FileBackend) DefaultBranch(repo string) (string, error) {
574 rr, err := fb.Repository(repo)
575 if err != nil {
576 logger.Debug("failed to get default branch", "err", err)
577 return "", err
578 }
579
580 r, err := rr.Repository()
581 if err != nil {
582 logger.Debug("failed to open repository for default branch", "err", err)
583 return "", err
584 }
585
586 head, err := r.HEAD()
587 if err != nil {
588 logger.Debug("failed to get HEAD for default branch", "err", err)
589 return "", err
590 }
591
592 return head.Name().Short(), nil
593}
594
595// SetDefaultBranch sets the default branch for the given repository.
596//
597// It implements backend.Backend.
598func (fb *FileBackend) SetDefaultBranch(repo string, branch string) error {
599 rr, err := fb.Repository(repo)
600 if err != nil {
601 logger.Debug("failed to get repository for default branch", "err", err)
602 return err
603 }
604
605 r, err := rr.Repository()
606 if err != nil {
607 logger.Debug("failed to open repository for default branch", "err", err)
608 return err
609 }
610
611 if _, err := r.SymbolicRef("HEAD", gitm.RefsHeads+branch); err != nil {
612 logger.Debug("failed to set default branch", "err", err)
613 return err
614 }
615
616 return nil
617}