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