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