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