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	"strings"
 30
 31	"github.com/charmbracelet/log"
 32	"github.com/charmbracelet/soft-serve/git"
 33	"github.com/charmbracelet/soft-serve/server/backend"
 34	"github.com/charmbracelet/soft-serve/server/utils"
 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	repos        = "repos"
 45	collabs      = "collaborators"
 46	description  = "description"
 47	exportOk     = "git-daemon-export-ok"
 48	private      = "private"
 49	settings     = "settings"
 50)
 51
 52var (
 53	logger = log.WithPrefix("backend.file")
 54
 55	defaults = map[string]string{
 56		anonAccess:   backend.ReadOnlyAccess.String(),
 57		allowKeyless: "true",
 58	}
 59)
 60
 61var _ backend.Backend = &FileBackend{}
 62
 63// FileBackend is a backend that uses the filesystem.
 64type FileBackend struct { // nolint:revive
 65	// path is the path to the directory containing the repositories and config
 66	// files.
 67	path string
 68
 69	// repos is a map of repositories.
 70	repos map[string]*Repo
 71
 72	// AdditionalAdmins additional admins to the server.
 73	AdditionalAdmins []string
 74}
 75
 76func (fb *FileBackend) reposPath() string {
 77	return filepath.Join(fb.path, repos)
 78}
 79
 80func (fb *FileBackend) settingsPath() string {
 81	return filepath.Join(fb.path, settings)
 82}
 83
 84func (fb *FileBackend) adminsPath() string {
 85	return filepath.Join(fb.settingsPath(), admins)
 86}
 87
 88func (fb *FileBackend) collabsPath(repo string) string {
 89	return filepath.Join(fb.path, collabs, repo, collabs)
 90}
 91
 92func readOneLine(path string) (string, error) {
 93	f, err := os.Open(path)
 94	if err != nil {
 95		return "", err
 96	}
 97	defer f.Close() // nolint:errcheck
 98	s := bufio.NewScanner(f)
 99	s.Scan()
100	return s.Text(), s.Err()
101}
102
103func readAll(path string) (string, error) {
104	f, err := os.Open(path)
105	if err != nil {
106		return "", err
107	}
108
109	bts, err := io.ReadAll(f)
110	return string(bts), err
111}
112
113// exists returns true if the given path exists.
114func exists(path string) bool {
115	_, err := os.Stat(path)
116	return err == nil
117}
118
119// NewFileBackend creates a new FileBackend.
120func NewFileBackend(path string) (*FileBackend, error) {
121	fb := &FileBackend{path: path}
122	for _, dir := range []string{repos, settings, collabs} {
123		if err := os.MkdirAll(filepath.Join(path, dir), 0755); err != nil {
124			return nil, err
125		}
126	}
127
128	for _, file := range []string{admins, anonAccess, allowKeyless} {
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
143	if err := fb.initRepos(); err != nil {
144		return nil, err
145	}
146
147	return fb, nil
148}
149
150// AccessLevel returns the access level for the given public key and repo.
151//
152// It implements backend.AccessMethod.
153func (fb *FileBackend) AccessLevel(repo string, pk gossh.PublicKey) backend.AccessLevel {
154	private := fb.IsPrivate(repo)
155	anon := fb.AnonAccess()
156	if pk != nil {
157		// Check if the key is an admin.
158		if fb.IsAdmin(pk) {
159			return backend.AdminAccess
160		}
161
162		// Check if the key is a collaborator.
163		if fb.IsCollaborator(pk, repo) {
164			if anon > backend.ReadWriteAccess {
165				return anon
166			}
167			return backend.ReadWriteAccess
168		}
169
170		// Check if repo is private.
171		if !private {
172			if anon > backend.ReadOnlyAccess {
173				return anon
174			}
175			return backend.ReadOnlyAccess
176		}
177	}
178
179	if private {
180		return backend.NoAccess
181	}
182
183	return anon
184}
185
186// AddAdmin adds a public key to the list of server admins.
187//
188// It implements backend.Backend.
189func (fb *FileBackend) AddAdmin(pk gossh.PublicKey, memo string) error {
190	// Skip if the key already exists.
191	if fb.IsAdmin(pk) {
192		return fmt.Errorf("key already exists")
193	}
194
195	ak := backend.MarshalAuthorizedKey(pk)
196	f, err := os.OpenFile(fb.adminsPath(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
197	if err != nil {
198		logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
199		return err
200	}
201
202	defer f.Close() //nolint:errcheck
203	if memo != "" {
204		memo = " " + memo
205	}
206	_, err = fmt.Fprintf(f, "%s%s\n", ak, memo)
207	return err
208}
209
210// AddCollaborator adds a public key to the list of collaborators for the given repo.
211//
212// It implements backend.Backend.
213func (fb *FileBackend) AddCollaborator(pk gossh.PublicKey, memo string, repo string) error {
214	name := utils.SanitizeRepo(repo)
215	repo = name + ".git"
216	// Check if repo exists
217	if !exists(filepath.Join(fb.reposPath(), repo)) {
218		return fmt.Errorf("repository %s does not exist", repo)
219	}
220
221	// Skip if the key already exists.
222	if fb.IsCollaborator(pk, repo) {
223		return fmt.Errorf("key already exists")
224	}
225
226	ak := backend.MarshalAuthorizedKey(pk)
227	if err := os.MkdirAll(filepath.Dir(fb.collabsPath(repo)), 0755); err != nil {
228		logger.Debug("failed to create collaborators directory",
229			"err", err, "path", filepath.Dir(fb.collabsPath(repo)))
230		return err
231	}
232
233	f, err := os.OpenFile(fb.collabsPath(repo), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
234	if err != nil {
235		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
236		return err
237	}
238
239	defer f.Close() //nolint:errcheck
240	if memo != "" {
241		memo = " " + memo
242	}
243	_, err = fmt.Fprintf(f, "%s%s\n", ak, memo)
244	return err
245}
246
247// Admins returns a list of public keys that are admins.
248//
249// It implements backend.Backend.
250func (fb *FileBackend) Admins() ([]string, error) {
251	admins := make([]string, 0)
252	f, err := os.Open(fb.adminsPath())
253	if err != nil {
254		logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
255		return nil, err
256	}
257
258	defer f.Close() //nolint:errcheck
259	s := bufio.NewScanner(f)
260	for s.Scan() {
261		admins = append(admins, s.Text())
262	}
263
264	return admins, s.Err()
265}
266
267// Collaborators returns a list of public keys that are collaborators for the given repo.
268//
269// It implements backend.Backend.
270func (fb *FileBackend) Collaborators(repo string) ([]string, error) {
271	name := utils.SanitizeRepo(repo)
272	repo = name + ".git"
273	// Check if repo exists
274	if !exists(filepath.Join(fb.reposPath(), repo)) {
275		return nil, fmt.Errorf("repository %s does not exist", repo)
276	}
277
278	collabs := make([]string, 0)
279	f, err := os.Open(fb.collabsPath(repo))
280	if err != nil && errors.Is(err, os.ErrNotExist) {
281		return collabs, nil
282	}
283	if err != nil {
284		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
285		return nil, err
286	}
287
288	defer f.Close() //nolint:errcheck
289	s := bufio.NewScanner(f)
290	for s.Scan() {
291		collabs = append(collabs, s.Text())
292	}
293
294	return collabs, s.Err()
295}
296
297// RemoveAdmin removes a public key from the list of server admins.
298//
299// It implements backend.Backend.
300func (fb *FileBackend) RemoveAdmin(pk gossh.PublicKey) error {
301	f, err := os.OpenFile(fb.adminsPath(), os.O_RDWR, 0644)
302	if err != nil {
303		logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
304		return err
305	}
306
307	defer f.Close() //nolint:errcheck
308	s := bufio.NewScanner(f)
309	lines := make([]string, 0)
310	for s.Scan() {
311		apk, _, err := backend.ParseAuthorizedKey(s.Text())
312		if err != nil {
313			logger.Debug("failed to parse admin key", "err", err, "path", fb.adminsPath())
314			continue
315		}
316
317		if !ssh.KeysEqual(apk, pk) {
318			lines = append(lines, s.Text())
319		}
320	}
321
322	if err := s.Err(); err != nil {
323		logger.Debug("failed to scan admin keys file", "err", err, "path", fb.adminsPath())
324		return err
325	}
326
327	if err := f.Truncate(0); err != nil {
328		logger.Debug("failed to truncate admin keys file", "err", err, "path", fb.adminsPath())
329		return err
330	}
331
332	if _, err := f.Seek(0, 0); err != nil {
333		logger.Debug("failed to seek admin keys file", "err", err, "path", fb.adminsPath())
334		return err
335	}
336
337	w := bufio.NewWriter(f)
338	for _, line := range lines {
339		if _, err := fmt.Fprintln(w, line); err != nil {
340			logger.Debug("failed to write admin keys file", "err", err, "path", fb.adminsPath())
341			return err
342		}
343	}
344
345	return w.Flush()
346}
347
348// RemoveCollaborator removes a public key from the list of collaborators for the given repo.
349//
350// It implements backend.Backend.
351func (fb *FileBackend) RemoveCollaborator(pk gossh.PublicKey, repo string) error {
352	name := utils.SanitizeRepo(repo)
353	repo = name + ".git"
354	// Check if repo exists
355	if !exists(filepath.Join(fb.reposPath(), repo)) {
356		return fmt.Errorf("repository %s does not exist", repo)
357	}
358
359	f, err := os.OpenFile(fb.collabsPath(repo), os.O_RDWR, 0644)
360	if err != nil && errors.Is(err, os.ErrNotExist) {
361		return nil
362	}
363
364	if err != nil {
365		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
366		return err
367	}
368
369	defer f.Close() //nolint:errcheck
370	s := bufio.NewScanner(f)
371	lines := make([]string, 0)
372	for s.Scan() {
373		apk, _, err := backend.ParseAuthorizedKey(s.Text())
374		if err != nil {
375			logger.Debug("failed to parse collaborator key", "err", err, "path", fb.collabsPath(repo))
376			continue
377		}
378
379		if !ssh.KeysEqual(apk, pk) {
380			lines = append(lines, s.Text())
381		}
382	}
383
384	if err := s.Err(); err != nil {
385		logger.Debug("failed to scan collaborators file", "err", err, "path", fb.collabsPath(repo))
386		return err
387	}
388
389	if err := f.Truncate(0); err != nil {
390		logger.Debug("failed to truncate collaborators file", "err", err, "path", fb.collabsPath(repo))
391		return err
392	}
393
394	if _, err := f.Seek(0, 0); err != nil {
395		logger.Debug("failed to seek collaborators file", "err", err, "path", fb.collabsPath(repo))
396		return err
397	}
398
399	w := bufio.NewWriter(f)
400	for _, line := range lines {
401		if _, err := fmt.Fprintln(w, line); err != nil {
402			logger.Debug("failed to write collaborators file", "err", err, "path", fb.collabsPath(repo))
403			return err
404		}
405	}
406
407	return w.Flush()
408}
409
410// AllowKeyless returns true if keyless access is allowed.
411//
412// It implements backend.Backend.
413func (fb *FileBackend) AllowKeyless() bool {
414	line, err := readOneLine(filepath.Join(fb.settingsPath(), allowKeyless))
415	if err != nil {
416		logger.Debug("failed to read allow-keyless file", "err", err)
417		return false
418	}
419
420	return line == "true"
421}
422
423// AnonAccess returns the level of anonymous access allowed.
424//
425// It implements backend.Backend.
426func (fb *FileBackend) AnonAccess() backend.AccessLevel {
427	line, err := readOneLine(filepath.Join(fb.settingsPath(), anonAccess))
428	if err != nil {
429		logger.Debug("failed to read anon-access file", "err", err)
430		return backend.NoAccess
431	}
432
433	switch line {
434	case backend.NoAccess.String():
435		return backend.NoAccess
436	case backend.ReadOnlyAccess.String():
437		return backend.ReadOnlyAccess
438	case backend.ReadWriteAccess.String():
439		return backend.ReadWriteAccess
440	case backend.AdminAccess.String():
441		return backend.AdminAccess
442	default:
443		return backend.NoAccess
444	}
445}
446
447// Description returns the description of the given repo.
448//
449// It implements backend.Backend.
450func (fb *FileBackend) Description(repo string) string {
451	repo = utils.SanitizeRepo(repo) + ".git"
452	r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
453	return r.Description()
454}
455
456// IsAdmin checks if the given public key is a server admin.
457//
458// It implements backend.Backend.
459func (fb *FileBackend) IsAdmin(pk gossh.PublicKey) bool {
460	// Check if the key is an additional admin.
461	ak := backend.MarshalAuthorizedKey(pk)
462	for _, admin := range fb.AdditionalAdmins {
463		if ak == admin {
464			return true
465		}
466	}
467
468	f, err := os.Open(fb.adminsPath())
469	if err != nil {
470		logger.Debug("failed to open admins file", "err", err, "path", fb.adminsPath())
471		return false
472	}
473
474	defer f.Close() //nolint:errcheck
475	s := bufio.NewScanner(f)
476	for s.Scan() {
477		apk, _, err := backend.ParseAuthorizedKey(s.Text())
478		if err != nil {
479			continue
480		}
481		if ssh.KeysEqual(apk, pk) {
482			return true
483		}
484	}
485
486	return false
487}
488
489// IsCollaborator returns true if the given public key is a collaborator on the
490// given repo.
491//
492// It implements backend.Backend.
493func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, repo string) bool {
494	repo = utils.SanitizeRepo(repo) + ".git"
495	_, err := os.Stat(fb.collabsPath(repo))
496	if err != nil {
497		return false
498	}
499
500	f, err := os.Open(fb.collabsPath(repo))
501	if err != nil {
502		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
503		return false
504	}
505
506	defer f.Close() //nolint:errcheck
507	s := bufio.NewScanner(f)
508	for s.Scan() {
509		apk, _, err := backend.ParseAuthorizedKey(s.Text())
510		if err != nil {
511			continue
512		}
513		if ssh.KeysEqual(apk, pk) {
514			return true
515		}
516	}
517
518	return false
519}
520
521// IsPrivate returns true if the given repo is private.
522//
523// It implements backend.Backend.
524func (fb *FileBackend) IsPrivate(repo string) bool {
525	repo = utils.SanitizeRepo(repo) + ".git"
526	r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
527	return r.IsPrivate()
528}
529
530// SetAllowKeyless sets whether or not to allow keyless access.
531//
532// It implements backend.Backend.
533func (fb *FileBackend) SetAllowKeyless(allow bool) error {
534	f, err := os.OpenFile(filepath.Join(fb.settingsPath(), allowKeyless), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
535	if err != nil {
536		return fmt.Errorf("failed to open allow-keyless file: %w", err)
537	}
538
539	defer f.Close() //nolint:errcheck
540	_, err = fmt.Fprintln(f, allow)
541	return err
542}
543
544// SetAnonAccess sets the anonymous access level.
545//
546// It implements backend.Backend.
547func (fb *FileBackend) SetAnonAccess(level backend.AccessLevel) error {
548	f, err := os.OpenFile(filepath.Join(fb.settingsPath(), anonAccess), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
549	if err != nil {
550		return fmt.Errorf("failed to open anon-access file: %w", err)
551	}
552
553	defer f.Close() //nolint:errcheck
554	_, err = fmt.Fprintln(f, level.String())
555	return err
556}
557
558// SetDescription sets the description of the given repo.
559//
560// It implements backend.Backend.
561func (fb *FileBackend) SetDescription(repo string, desc string) error {
562	repo = utils.SanitizeRepo(repo) + ".git"
563	f, err := os.OpenFile(filepath.Join(fb.reposPath(), repo, description), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
564	if err != nil {
565		return fmt.Errorf("failed to open description file: %w", err)
566	}
567
568	defer f.Close() //nolint:errcheck
569	_, err = fmt.Fprintln(f, desc)
570	return err
571}
572
573// SetPrivate sets the private status of the given repo.
574//
575// It implements backend.Backend.
576func (fb *FileBackend) SetPrivate(repo string, priv bool) error {
577	repo = utils.SanitizeRepo(repo) + ".git"
578	daemonExport := filepath.Join(fb.reposPath(), repo, exportOk)
579	if priv {
580		_ = os.Remove(daemonExport)
581		f, err := os.Create(filepath.Join(fb.reposPath(), repo, private))
582		if err != nil {
583			return fmt.Errorf("failed to create private file: %w", err)
584		}
585
586		_ = f.Close() //nolint:errcheck
587	} else {
588		// Create git-daemon-export-ok file if repo is public.
589		f, err := os.Create(daemonExport)
590		if err != nil {
591			logger.Warn("failed to create git-daemon-export-ok file", "err", err)
592		} else {
593			_ = f.Close() //nolint:errcheck
594		}
595	}
596	return nil
597}
598
599// CreateRepository creates a new repository.
600//
601// Created repositories are always bare.
602//
603// It implements backend.Backend.
604func (fb *FileBackend) CreateRepository(repo string, private bool) (backend.Repository, error) {
605	name := utils.SanitizeRepo(repo)
606	repo = name + ".git"
607	rp := filepath.Join(fb.reposPath(), repo)
608	if _, err := os.Stat(rp); err == nil {
609		return nil, os.ErrExist
610	}
611
612	if _, err := git.Init(rp, true); err != nil {
613		logger.Debug("failed to create repository", "err", err)
614		return nil, err
615	}
616
617	fb.SetPrivate(repo, private)
618	fb.SetDescription(repo, "")
619
620	r := &Repo{path: rp, root: fb.reposPath()}
621	// Add to cache.
622	fb.repos[name] = r
623	return r, nil
624}
625
626// DeleteRepository deletes the given repository.
627//
628// It implements backend.Backend.
629func (fb *FileBackend) DeleteRepository(repo string) error {
630	name := utils.SanitizeRepo(repo)
631	delete(fb.repos, name)
632	repo = name + ".git"
633	return os.RemoveAll(filepath.Join(fb.reposPath(), repo))
634}
635
636// RenameRepository renames the given repository.
637//
638// It implements backend.Backend.
639func (fb *FileBackend) RenameRepository(oldName string, newName string) error {
640	oldName = filepath.Join(fb.reposPath(), utils.SanitizeRepo(oldName)+".git")
641	newName = filepath.Join(fb.reposPath(), utils.SanitizeRepo(newName)+".git")
642	if _, err := os.Stat(oldName); errors.Is(err, os.ErrNotExist) {
643		return fmt.Errorf("repository %q does not exist", strings.TrimSuffix(filepath.Base(oldName), ".git"))
644	}
645	if _, err := os.Stat(newName); err == nil {
646		return fmt.Errorf("repository %q already exists", strings.TrimSuffix(filepath.Base(newName), ".git"))
647	}
648
649	return os.Rename(oldName, newName)
650}
651
652// Repository finds the given repository.
653//
654// It implements backend.Backend.
655func (fb *FileBackend) Repository(repo string) (backend.Repository, error) {
656	name := utils.SanitizeRepo(repo)
657	if r, ok := fb.repos[name]; ok {
658		return r, nil
659	}
660
661	repo = name + ".git"
662	rp := filepath.Join(fb.reposPath(), repo)
663	_, err := os.Stat(rp)
664	if err != nil {
665		if errors.Is(err, os.ErrNotExist) {
666			return nil, os.ErrNotExist
667		}
668		return nil, err
669	}
670
671	return &Repo{path: rp, root: fb.reposPath()}, nil
672}
673
674// Returns true if path is a directory containing an `objects` directory and a
675// `HEAD` file.
676func isGitDir(path string) bool {
677	stat, err := os.Stat(filepath.Join(path, "objects"))
678	if err != nil {
679		return false
680	}
681	if !stat.IsDir() {
682		return false
683	}
684
685	stat, err = os.Stat(filepath.Join(path, "HEAD"))
686	if err != nil {
687		return false
688	}
689	if stat.IsDir() {
690		return false
691	}
692
693	return true
694}
695
696// initRepos initializes the repository cache.
697func (fb *FileBackend) initRepos() error {
698	fb.repos = make(map[string]*Repo)
699	repos := make([]backend.Repository, 0)
700	err := filepath.WalkDir(fb.reposPath(), func(path string, d fs.DirEntry, _ error) error {
701		// Skip non-directories.
702		if !d.IsDir() {
703			return nil
704		}
705
706		// Skip non-repositories.
707		if !strings.HasSuffix(path, ".git") {
708			return nil
709		}
710
711		if isGitDir(path) {
712			r := &Repo{path: path, root: fb.reposPath()}
713			fb.repos[r.Name()] = r
714			repos = append(repos, r)
715		}
716
717		return nil
718	})
719	if err != nil {
720		return err
721	}
722
723	return nil
724}
725
726// Repositories returns a list of all repositories.
727//
728// It implements backend.Backend.
729func (fb *FileBackend) Repositories() ([]backend.Repository, error) {
730	repos := make([]backend.Repository, 0)
731	for _, r := range fb.repos {
732		repos = append(repos, r)
733	}
734
735	return repos, nil
736}