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