file.go

  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	// AdditionalAdmins additional admins to the server.
 70	AdditionalAdmins []string
 71}
 72
 73func (fb *FileBackend) reposPath() string {
 74	return filepath.Join(fb.path, repos)
 75}
 76
 77// RepositoryStorePath returns the path to the repository store.
 78func (fb *FileBackend) RepositoryStorePath() string {
 79	return fb.reposPath()
 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	return filepath.Join(fb.path, collabs, repo, collabs)
 92}
 93
 94func sanatizeRepo(repo string) string {
 95	return strings.TrimSuffix(repo, ".git")
 96}
 97
 98func readOneLine(path string) (string, error) {
 99	f, err := os.Open(path)
100	if err != nil {
101		return "", err
102	}
103	defer f.Close() // nolint:errcheck
104	s := bufio.NewScanner(f)
105	s.Scan()
106	return s.Text(), s.Err()
107}
108
109func readAll(path string) (string, error) {
110	f, err := os.Open(path)
111	if err != nil {
112		return "", err
113	}
114
115	bts, err := io.ReadAll(f)
116	return string(bts), err
117}
118
119// exists returns true if the given path exists.
120func exists(path string) bool {
121	_, err := os.Stat(path)
122	return err == nil
123}
124
125// NewFileBackend creates a new FileBackend.
126func NewFileBackend(path string) (*FileBackend, error) {
127	fb := &FileBackend{path: path}
128	for _, dir := range []string{repos, settings, collabs} {
129		if err := os.MkdirAll(filepath.Join(path, dir), 0755); err != nil {
130			return nil, err
131		}
132	}
133
134	for _, file := range []string{admins, anonAccess, allowKeyless} {
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, repo string) error {
215	name := sanatizeRepo(repo)
216	repo = name + ".git"
217	// Check if repo exists
218	if !exists(filepath.Join(fb.reposPath(), repo)) {
219		return fmt.Errorf("repository %s does not exist", repo)
220	}
221
222	// Skip if the key already exists.
223	if fb.IsCollaborator(pk, repo) {
224		return fmt.Errorf("key already exists")
225	}
226
227	ak := backend.MarshalAuthorizedKey(pk)
228	if err := os.MkdirAll(filepath.Dir(fb.collabsPath(repo)), 0755); err != nil {
229		logger.Debug("failed to create collaborators directory",
230			"err", err, "path", filepath.Dir(fb.collabsPath(repo)))
231		return err
232	}
233
234	f, err := os.OpenFile(fb.collabsPath(repo), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
235	if err != nil {
236		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
237		return err
238	}
239
240	defer f.Close() //nolint:errcheck
241	if memo != "" {
242		memo = " " + memo
243	}
244	_, err = fmt.Fprintf(f, "%s%s\n", ak, memo)
245	return err
246}
247
248// Admins returns a list of public keys that are admins.
249//
250// It implements backend.Backend.
251func (fb *FileBackend) Admins() ([]string, error) {
252	admins := make([]string, 0)
253	f, err := os.Open(fb.adminsPath())
254	if err != nil {
255		logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
256		return nil, err
257	}
258
259	defer f.Close() //nolint:errcheck
260	s := bufio.NewScanner(f)
261	for s.Scan() {
262		admins = append(admins, s.Text())
263	}
264
265	return admins, s.Err()
266}
267
268// Collaborators returns a list of public keys that are collaborators for the given repo.
269//
270// It implements backend.Backend.
271func (fb *FileBackend) Collaborators(repo string) ([]string, error) {
272	name := sanatizeRepo(repo)
273	repo = name + ".git"
274	// Check if repo exists
275	if !exists(filepath.Join(fb.reposPath(), repo)) {
276		return nil, fmt.Errorf("repository %s does not exist", repo)
277	}
278
279	collabs := make([]string, 0)
280	f, err := os.Open(fb.collabsPath(repo))
281	if err != nil && errors.Is(err, os.ErrNotExist) {
282		return collabs, nil
283	}
284	if err != nil {
285		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
286		return nil, err
287	}
288
289	defer f.Close() //nolint:errcheck
290	s := bufio.NewScanner(f)
291	for s.Scan() {
292		collabs = append(collabs, s.Text())
293	}
294
295	return collabs, s.Err()
296}
297
298// RemoveAdmin removes a public key from the list of server admins.
299//
300// It implements backend.Backend.
301func (fb *FileBackend) RemoveAdmin(pk gossh.PublicKey) error {
302	f, err := os.OpenFile(fb.adminsPath(), os.O_RDWR, 0644)
303	if err != nil {
304		logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
305		return err
306	}
307
308	defer f.Close() //nolint:errcheck
309	s := bufio.NewScanner(f)
310	lines := make([]string, 0)
311	for s.Scan() {
312		apk, _, err := backend.ParseAuthorizedKey(s.Text())
313		if err != nil {
314			logger.Debug("failed to parse admin key", "err", err, "path", fb.adminsPath())
315			continue
316		}
317
318		if !ssh.KeysEqual(apk, pk) {
319			lines = append(lines, s.Text())
320		}
321	}
322
323	if err := s.Err(); err != nil {
324		logger.Debug("failed to scan admin keys file", "err", err, "path", fb.adminsPath())
325		return err
326	}
327
328	if err := f.Truncate(0); err != nil {
329		logger.Debug("failed to truncate admin keys file", "err", err, "path", fb.adminsPath())
330		return err
331	}
332
333	if _, err := f.Seek(0, 0); err != nil {
334		logger.Debug("failed to seek admin keys file", "err", err, "path", fb.adminsPath())
335		return err
336	}
337
338	w := bufio.NewWriter(f)
339	for _, line := range lines {
340		if _, err := fmt.Fprintln(w, line); err != nil {
341			logger.Debug("failed to write admin keys file", "err", err, "path", fb.adminsPath())
342			return err
343		}
344	}
345
346	return w.Flush()
347}
348
349// RemoveCollaborator removes a public key from the list of collaborators for the given repo.
350//
351// It implements backend.Backend.
352func (fb *FileBackend) RemoveCollaborator(pk gossh.PublicKey, repo string) error {
353	name := sanatizeRepo(repo)
354	repo = name + ".git"
355	// Check if repo exists
356	if !exists(filepath.Join(fb.reposPath(), repo)) {
357		return fmt.Errorf("repository %s does not exist", repo)
358	}
359
360	f, err := os.OpenFile(fb.collabsPath(repo), os.O_RDWR, 0644)
361	if err != nil && errors.Is(err, os.ErrNotExist) {
362		return nil
363	}
364
365	if err != nil {
366		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
367		return err
368	}
369
370	defer f.Close() //nolint:errcheck
371	s := bufio.NewScanner(f)
372	lines := make([]string, 0)
373	for s.Scan() {
374		apk, _, err := backend.ParseAuthorizedKey(s.Text())
375		if err != nil {
376			logger.Debug("failed to parse collaborator key", "err", err, "path", fb.collabsPath(repo))
377			continue
378		}
379
380		if !ssh.KeysEqual(apk, pk) {
381			lines = append(lines, s.Text())
382		}
383	}
384
385	if err := s.Err(); err != nil {
386		logger.Debug("failed to scan collaborators file", "err", err, "path", fb.collabsPath(repo))
387		return err
388	}
389
390	if err := f.Truncate(0); err != nil {
391		logger.Debug("failed to truncate collaborators file", "err", err, "path", fb.collabsPath(repo))
392		return err
393	}
394
395	if _, err := f.Seek(0, 0); err != nil {
396		logger.Debug("failed to seek collaborators file", "err", err, "path", fb.collabsPath(repo))
397		return err
398	}
399
400	w := bufio.NewWriter(f)
401	for _, line := range lines {
402		if _, err := fmt.Fprintln(w, line); err != nil {
403			logger.Debug("failed to write collaborators file", "err", err, "path", fb.collabsPath(repo))
404			return err
405		}
406	}
407
408	return w.Flush()
409}
410
411// AllowKeyless returns true if keyless access is allowed.
412//
413// It implements backend.Backend.
414func (fb *FileBackend) AllowKeyless() bool {
415	line, err := readOneLine(filepath.Join(fb.settingsPath(), allowKeyless))
416	if err != nil {
417		logger.Debug("failed to read allow-keyless file", "err", err)
418		return false
419	}
420
421	return line == "true"
422}
423
424// AnonAccess returns the level of anonymous access allowed.
425//
426// It implements backend.Backend.
427func (fb *FileBackend) AnonAccess() backend.AccessLevel {
428	line, err := readOneLine(filepath.Join(fb.settingsPath(), anonAccess))
429	if err != nil {
430		logger.Debug("failed to read anon-access file", "err", err)
431		return backend.NoAccess
432	}
433
434	switch line {
435	case backend.NoAccess.String():
436		return backend.NoAccess
437	case backend.ReadOnlyAccess.String():
438		return backend.ReadOnlyAccess
439	case backend.ReadWriteAccess.String():
440		return backend.ReadWriteAccess
441	case backend.AdminAccess.String():
442		return backend.AdminAccess
443	default:
444		return backend.NoAccess
445	}
446}
447
448// Description returns the description of the given repo.
449//
450// It implements backend.Backend.
451func (fb *FileBackend) Description(repo string) string {
452	repo = sanatizeRepo(repo) + ".git"
453	r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
454	return r.Description()
455}
456
457// IsAdmin checks if the given public key is a server admin.
458//
459// It implements backend.Backend.
460func (fb *FileBackend) IsAdmin(pk gossh.PublicKey) bool {
461	// Check if the key is an additional admin.
462	ak := backend.MarshalAuthorizedKey(pk)
463	for _, admin := range fb.AdditionalAdmins {
464		if ak == admin {
465			return true
466		}
467	}
468
469	f, err := os.Open(fb.adminsPath())
470	if err != nil {
471		logger.Debug("failed to open admins file", "err", err, "path", fb.adminsPath())
472		return false
473	}
474
475	defer f.Close() //nolint:errcheck
476	s := bufio.NewScanner(f)
477	for s.Scan() {
478		apk, _, err := backend.ParseAuthorizedKey(s.Text())
479		if err != nil {
480			continue
481		}
482		if ssh.KeysEqual(apk, pk) {
483			return true
484		}
485	}
486
487	return false
488}
489
490// IsCollaborator returns true if the given public key is a collaborator on the
491// given repo.
492//
493// It implements backend.Backend.
494func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, repo string) bool {
495	repo = sanatizeRepo(repo) + ".git"
496	_, err := os.Stat(fb.collabsPath(repo))
497	if err != nil {
498		return false
499	}
500
501	f, err := os.Open(fb.collabsPath(repo))
502	if err != nil {
503		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
504		return false
505	}
506
507	defer f.Close() //nolint:errcheck
508	s := bufio.NewScanner(f)
509	for s.Scan() {
510		apk, _, err := backend.ParseAuthorizedKey(s.Text())
511		if err != nil {
512			continue
513		}
514		if ssh.KeysEqual(apk, pk) {
515			return true
516		}
517	}
518
519	return false
520}
521
522// IsPrivate returns true if the given repo is private.
523//
524// It implements backend.Backend.
525func (fb *FileBackend) IsPrivate(repo string) bool {
526	repo = sanatizeRepo(repo) + ".git"
527	r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
528	return r.IsPrivate()
529}
530
531// SetAllowKeyless sets whether or not to allow keyless access.
532//
533// It implements backend.Backend.
534func (fb *FileBackend) SetAllowKeyless(allow bool) error {
535	f, err := os.OpenFile(filepath.Join(fb.settingsPath(), allowKeyless), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
536	if err != nil {
537		return fmt.Errorf("failed to open allow-keyless file: %w", err)
538	}
539
540	defer f.Close() //nolint:errcheck
541	_, err = fmt.Fprintln(f, allow)
542	return err
543}
544
545// SetAnonAccess sets the anonymous access level.
546//
547// It implements backend.Backend.
548func (fb *FileBackend) SetAnonAccess(level backend.AccessLevel) error {
549	f, err := os.OpenFile(filepath.Join(fb.settingsPath(), anonAccess), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
550	if err != nil {
551		return fmt.Errorf("failed to open anon-access file: %w", err)
552	}
553
554	defer f.Close() //nolint:errcheck
555	_, err = fmt.Fprintln(f, level.String())
556	return err
557}
558
559// SetDescription sets the description of the given repo.
560//
561// It implements backend.Backend.
562func (fb *FileBackend) SetDescription(repo string, desc string) error {
563	repo = sanatizeRepo(repo) + ".git"
564	f, err := os.OpenFile(filepath.Join(fb.reposPath(), repo, description), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
565	if err != nil {
566		return fmt.Errorf("failed to open description file: %w", err)
567	}
568
569	defer f.Close() //nolint:errcheck
570	_, err = fmt.Fprintln(f, desc)
571	return err
572}
573
574// SetPrivate sets the private status of the given repo.
575//
576// It implements backend.Backend.
577func (fb *FileBackend) SetPrivate(repo string, priv bool) error {
578	repo = sanatizeRepo(repo) + ".git"
579	daemonExport := filepath.Join(fb.reposPath(), repo, exportOk)
580	if priv {
581		_ = os.Remove(daemonExport)
582		f, err := os.Create(filepath.Join(fb.reposPath(), repo, private))
583		if err != nil {
584			return fmt.Errorf("failed to create private file: %w", err)
585		}
586
587		_ = f.Close() //nolint:errcheck
588	} else {
589		// Create git-daemon-export-ok file if repo is public.
590		f, err := os.Create(daemonExport)
591		if err != nil {
592			logger.Warn("failed to create git-daemon-export-ok file", "err", err)
593		} else {
594			_ = f.Close() //nolint:errcheck
595		}
596	}
597	return nil
598}
599
600// CreateRepository creates a new repository.
601//
602// Created repositories are always bare.
603//
604// It implements backend.Backend.
605func (fb *FileBackend) CreateRepository(name string, private bool) (backend.Repository, error) {
606	name = sanatizeRepo(name) + ".git"
607	rp := filepath.Join(fb.reposPath(), name)
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(name, private)
618	fb.SetDescription(name, "")
619
620	return &Repo{path: rp, root: fb.reposPath()}, nil
621}
622
623// DeleteRepository deletes the given repository.
624//
625// It implements backend.Backend.
626func (fb *FileBackend) DeleteRepository(name string) error {
627	name = sanatizeRepo(name) + ".git"
628	return os.RemoveAll(filepath.Join(fb.reposPath(), name))
629}
630
631// RenameRepository renames the given repository.
632//
633// It implements backend.Backend.
634func (fb *FileBackend) RenameRepository(oldName string, newName string) error {
635	oldName = filepath.Join(fb.reposPath(), sanatizeRepo(oldName)+".git")
636	newName = filepath.Join(fb.reposPath(), sanatizeRepo(newName)+".git")
637	if _, err := os.Stat(oldName); errors.Is(err, os.ErrNotExist) {
638		return fmt.Errorf("repository %q does not exist", strings.TrimSuffix(filepath.Base(oldName), ".git"))
639	}
640	if _, err := os.Stat(newName); err == nil {
641		return fmt.Errorf("repository %q already exists", strings.TrimSuffix(filepath.Base(newName), ".git"))
642	}
643
644	return os.Rename(oldName, newName)
645}
646
647// Repository finds the given repository.
648//
649// It implements backend.Backend.
650func (fb *FileBackend) Repository(repo string) (backend.Repository, error) {
651	repo = sanatizeRepo(repo) + ".git"
652	rp := filepath.Join(fb.reposPath(), repo)
653	_, err := os.Stat(rp)
654	if err != nil {
655		if errors.Is(err, os.ErrNotExist) {
656			return nil, os.ErrNotExist
657		}
658		return nil, err
659	}
660
661	return &Repo{path: rp, root: fb.reposPath()}, nil
662}
663
664// Repositories returns a list of all repositories.
665//
666// It implements backend.Backend.
667func (fb *FileBackend) Repositories() ([]backend.Repository, error) {
668	repos := make([]backend.Repository, 0)
669	err := filepath.WalkDir(fb.reposPath(), func(path string, d fs.DirEntry, _ error) error {
670		// Skip non-directories.
671		if !d.IsDir() {
672			return nil
673		}
674
675		// Skip non-repositories.
676		if !strings.HasSuffix(path, ".git") {
677			return nil
678		}
679
680		repos = append(repos, &Repo{path: path, root: fb.reposPath()})
681
682		return nil
683	})
684	if err != nil {
685		return nil, err
686	}
687
688	return repos, nil
689}