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