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.reposPath(), repo, collabs)
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// NewFileBackend creates a new FileBackend.
121func NewFileBackend(path string) (*FileBackend, error) {
122 fb := &FileBackend{path: path}
123 for _, dir := range []string{repos, settings} {
124 if err := os.MkdirAll(filepath.Join(path, dir), 0755); err != nil {
125 return nil, err
126 }
127 }
128 for _, file := range []string{admins, anonAccess, allowKeyless, serverHost, serverName, serverPort} {
129 fp := filepath.Join(fb.settingsPath(), file)
130 _, err := os.Stat(fp)
131 if errors.Is(err, fs.ErrNotExist) {
132 f, err := os.Create(fp)
133 if err != nil {
134 return nil, err
135 }
136 if c, ok := defaults[file]; ok {
137 io.WriteString(f, c) // nolint:errcheck
138 }
139 _ = f.Close()
140 }
141 }
142 return fb, nil
143}
144
145// AccessLevel returns the access level for the given public key and repo.
146//
147// It implements backend.AccessMethod.
148func (fb *FileBackend) AccessLevel(repo string, pk gossh.PublicKey) backend.AccessLevel {
149 private := fb.IsPrivate(repo)
150 anon := fb.AnonAccess()
151 if pk != nil {
152 // Check if the key is an admin.
153 if fb.IsAdmin(pk) {
154 return backend.AdminAccess
155 }
156
157 // Check if the key is a collaborator.
158 if fb.IsCollaborator(pk, repo) {
159 if anon > backend.ReadWriteAccess {
160 return anon
161 }
162 return backend.ReadWriteAccess
163 }
164
165 // Check if repo is private.
166 if !private {
167 if anon > backend.ReadOnlyAccess {
168 return anon
169 }
170 return backend.ReadOnlyAccess
171 }
172 }
173
174 if private {
175 return backend.NoAccess
176 }
177
178 return anon
179}
180
181// AddAdmin adds a public key to the list of server admins.
182//
183// It implements backend.Backend.
184func (fb *FileBackend) AddAdmin(pk gossh.PublicKey) error {
185 // Skip if the key already exists.
186 if fb.IsAdmin(pk) {
187 return nil
188 }
189
190 ak := backend.MarshalAuthorizedKey(pk)
191 f, err := os.OpenFile(fb.adminsPath(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
192 if err != nil {
193 logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
194 return err
195 }
196
197 defer f.Close() //nolint:errcheck
198 _, err = fmt.Fprintln(f, ak)
199 return err
200}
201
202// AddCollaborator adds a public key to the list of collaborators for the given repo.
203//
204// It implements backend.Backend.
205func (fb *FileBackend) AddCollaborator(pk gossh.PublicKey, repo string) error {
206 // Skip if the key already exists.
207 if fb.IsCollaborator(pk, repo) {
208 return nil
209 }
210
211 ak := backend.MarshalAuthorizedKey(pk)
212 repo = sanatizeRepo(repo) + ".git"
213 f, err := os.OpenFile(fb.collabsPath(repo), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
214 if err != nil {
215 logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
216 return err
217 }
218
219 defer f.Close() //nolint:errcheck
220 _, err = fmt.Fprintln(f, ak)
221 return err
222}
223
224// AllowKeyless returns true if keyless access is allowed.
225//
226// It implements backend.Backend.
227func (fb *FileBackend) AllowKeyless() bool {
228 line, err := readOneLine(filepath.Join(fb.settingsPath(), allowKeyless))
229 if err != nil {
230 logger.Debug("failed to read allow-keyless file", "err", err)
231 return false
232 }
233
234 return line == "true"
235}
236
237// AnonAccess returns the level of anonymous access allowed.
238//
239// It implements backend.Backend.
240func (fb *FileBackend) AnonAccess() backend.AccessLevel {
241 line, err := readOneLine(filepath.Join(fb.settingsPath(), anonAccess))
242 if err != nil {
243 logger.Debug("failed to read anon-access file", "err", err)
244 return backend.NoAccess
245 }
246
247 switch line {
248 case backend.NoAccess.String():
249 return backend.NoAccess
250 case backend.ReadOnlyAccess.String():
251 return backend.ReadOnlyAccess
252 case backend.ReadWriteAccess.String():
253 return backend.ReadWriteAccess
254 case backend.AdminAccess.String():
255 return backend.AdminAccess
256 default:
257 return backend.NoAccess
258 }
259}
260
261// Description returns the description of the given repo.
262//
263// It implements backend.Backend.
264func (fb *FileBackend) Description(repo string) string {
265 repo = sanatizeRepo(repo) + ".git"
266 r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
267 return r.Description()
268}
269
270// IsAdmin checks if the given public key is a server admin.
271//
272// It implements backend.Backend.
273func (fb *FileBackend) IsAdmin(pk gossh.PublicKey) bool {
274 // Check if the key is an additional admin.
275 ak := backend.MarshalAuthorizedKey(pk)
276 for _, admin := range fb.AdditionalAdmins {
277 if ak == admin {
278 return true
279 }
280 }
281
282 f, err := os.Open(fb.adminsPath())
283 if err != nil {
284 logger.Debug("failed to open admins file", "err", err, "path", fb.adminsPath())
285 return false
286 }
287
288 defer f.Close() //nolint:errcheck
289 s := bufio.NewScanner(f)
290 for s.Scan() {
291 apk, _, err := backend.ParseAuthorizedKey(s.Text())
292 if err != nil {
293 continue
294 }
295 if ssh.KeysEqual(apk, pk) {
296 return true
297 }
298 }
299
300 return false
301}
302
303// IsCollaborator returns true if the given public key is a collaborator on the
304// given repo.
305//
306// It implements backend.Backend.
307func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, repo string) bool {
308 repo = sanatizeRepo(repo) + ".git"
309 _, err := os.Stat(filepath.Join(fb.reposPath(), repo))
310 if errors.Is(err, os.ErrNotExist) {
311 return false
312 }
313
314 f, err := os.Open(fb.collabsPath(repo))
315 if err != nil && errors.Is(err, os.ErrNotExist) {
316 return false
317 }
318 if err != nil {
319 logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
320 return false
321 }
322
323 defer f.Close() //nolint:errcheck
324 s := bufio.NewScanner(f)
325 for s.Scan() {
326 apk, _, err := backend.ParseAuthorizedKey(s.Text())
327 if err != nil {
328 continue
329 }
330 if ssh.KeysEqual(apk, pk) {
331 return true
332 }
333 }
334
335 return false
336}
337
338// IsPrivate returns true if the given repo is private.
339//
340// It implements backend.Backend.
341func (fb *FileBackend) IsPrivate(repo string) bool {
342 repo = sanatizeRepo(repo) + ".git"
343 r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
344 return r.IsPrivate()
345}
346
347// ServerHost returns the server host.
348//
349// It implements backend.Backend.
350func (fb *FileBackend) ServerHost() string {
351 line, err := readOneLine(filepath.Join(fb.settingsPath(), serverHost))
352 if err != nil {
353 logger.Debug("failed to read server-host file", "err", err)
354 return ""
355 }
356
357 return line
358}
359
360// ServerName returns the server name.
361//
362// It implements backend.Backend.
363func (fb *FileBackend) ServerName() string {
364 line, err := readOneLine(filepath.Join(fb.settingsPath(), serverName))
365 if err != nil {
366 logger.Debug("failed to read server-name file", "err", err)
367 return ""
368 }
369
370 return line
371}
372
373// ServerPort returns the server port.
374//
375// It implements backend.Backend.
376func (fb *FileBackend) ServerPort() string {
377 line, err := readOneLine(filepath.Join(fb.settingsPath(), serverPort))
378 if err != nil {
379 logger.Debug("failed to read server-port file", "err", err)
380 return ""
381 }
382
383 if _, err := strconv.Atoi(line); err != nil {
384 logger.Debug("failed to parse server-port file", "err", err)
385 return ""
386 }
387
388 return line
389}
390
391// SetAllowKeyless sets whether or not to allow keyless access.
392//
393// It implements backend.Backend.
394func (fb *FileBackend) SetAllowKeyless(allow bool) error {
395 f, err := os.OpenFile(filepath.Join(fb.settingsPath(), allowKeyless), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
396 if err != nil {
397 return fmt.Errorf("failed to open allow-keyless file: %w", err)
398 }
399
400 defer f.Close() //nolint:errcheck
401 _, err = fmt.Fprintln(f, allow)
402 return err
403}
404
405// SetAnonAccess sets the anonymous access level.
406//
407// It implements backend.Backend.
408func (fb *FileBackend) SetAnonAccess(level backend.AccessLevel) error {
409 f, err := os.OpenFile(filepath.Join(fb.settingsPath(), anonAccess), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
410 if err != nil {
411 return fmt.Errorf("failed to open anon-access file: %w", err)
412 }
413
414 defer f.Close() //nolint:errcheck
415 _, err = fmt.Fprintln(f, level.String())
416 return err
417}
418
419// SetDescription sets the description of the given repo.
420//
421// It implements backend.Backend.
422func (fb *FileBackend) SetDescription(repo string, desc string) error {
423 repo = sanatizeRepo(repo) + ".git"
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 := os.Stat(rp); err == nil {
505 return nil, os.ErrExist
506 }
507
508 if _, err := git.Init(rp, true); err != nil {
509 logger.Debug("failed to create repository", "err", err)
510 return nil, err
511 }
512
513 fb.SetPrivate(name, private)
514 fb.SetDescription(name, "")
515
516 return &Repo{path: rp, root: fb.reposPath()}, nil
517}
518
519// DeleteRepository deletes the given repository.
520//
521// It implements backend.Backend.
522func (fb *FileBackend) DeleteRepository(name string) error {
523 name = sanatizeRepo(name) + ".git"
524 return os.RemoveAll(filepath.Join(fb.reposPath(), name))
525}
526
527// RenameRepository renames the given repository.
528//
529// It implements backend.Backend.
530func (fb *FileBackend) RenameRepository(oldName string, newName string) error {
531 oldName = filepath.Join(fb.reposPath(), sanatizeRepo(oldName)+".git")
532 newName = filepath.Join(fb.reposPath(), sanatizeRepo(newName)+".git")
533 if _, err := os.Stat(oldName); errors.Is(err, os.ErrNotExist) {
534 return fmt.Errorf("repository %q does not exist", strings.TrimSuffix(filepath.Base(oldName), ".git"))
535 }
536 if _, err := os.Stat(newName); err == nil {
537 return fmt.Errorf("repository %q already exists", strings.TrimSuffix(filepath.Base(newName), ".git"))
538 }
539
540 return os.Rename(oldName, newName)
541}
542
543// Repository finds the given repository.
544//
545// It implements backend.Backend.
546func (fb *FileBackend) Repository(repo string) (backend.Repository, error) {
547 repo = sanatizeRepo(repo) + ".git"
548 rp := filepath.Join(fb.reposPath(), repo)
549 _, err := os.Stat(rp)
550 if err != nil {
551 if errors.Is(err, os.ErrNotExist) {
552 return nil, os.ErrNotExist
553 }
554 return nil, err
555 }
556
557 return &Repo{path: rp, root: fb.reposPath()}, nil
558}
559
560// Repositories returns a list of all repositories.
561//
562// It implements backend.Backend.
563func (fb *FileBackend) Repositories() ([]backend.Repository, error) {
564 repos := make([]backend.Repository, 0)
565 err := filepath.WalkDir(fb.reposPath(), func(path string, d fs.DirEntry, _ error) error {
566 // Skip non-directories.
567 if !d.IsDir() {
568 return nil
569 }
570
571 // Skip non-repositories.
572 if !strings.HasSuffix(path, ".git") {
573 return nil
574 }
575
576 repos = append(repos, &Repo{path: path, root: fb.reposPath()})
577
578 return nil
579 })
580 if err != nil {
581 return nil, err
582 }
583
584 return repos, nil
585}