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