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	"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/gliderlabs/ssh"
 36	gitm "github.com/gogs/git-module"
 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	serverHost   = "host"
 46	serverName   = "name"
 47	serverPort   = "port"
 48	repos        = "repos"
 49	collabs      = "collaborators"
 50	description  = "description"
 51	private      = "private"
 52)
 53
 54var (
 55	logger = log.WithPrefix("backend.file")
 56)
 57
 58var _ backend.Backend = &FileBackend{}
 59
 60var _ backend.AccessMethod = &FileBackend{}
 61
 62// FileBackend is a backend that uses the filesystem.
 63type FileBackend struct { // nolint:revive
 64	// path is the path to the directory containing the repositories and config
 65	// files.
 66	path string
 67	// AdditionalAdmins additional admins to the server.
 68	AdditionalAdmins []string
 69}
 70
 71func (fb *FileBackend) reposPath() string {
 72	return filepath.Join(fb.path, repos)
 73}
 74
 75func (fb *FileBackend) adminsPath() string {
 76	return filepath.Join(fb.path, admins)
 77}
 78
 79func (fb *FileBackend) collabsPath(repo string) string {
 80	return filepath.Join(fb.reposPath(), repo, collabs)
 81}
 82
 83func sanatizeRepo(repo string) string {
 84	return strings.TrimSuffix(repo, ".git")
 85}
 86
 87func readOneLine(path string) (string, error) {
 88	f, err := os.Open(path)
 89	if err != nil {
 90		return "", err
 91	}
 92	defer f.Close() // nolint:errcheck
 93	s := bufio.NewScanner(f)
 94	s.Scan()
 95	return s.Text(), s.Err()
 96}
 97
 98func readAll(path string) (string, error) {
 99	f, err := os.Open(path)
100	if err != nil {
101		return "", err
102	}
103
104	bts, err := io.ReadAll(f)
105	return string(bts), err
106}
107
108// NewFileBackend creates a new FileBackend.
109func NewFileBackend(path string) (*FileBackend, error) {
110	fb := &FileBackend{path: path}
111	for _, dir := range []string{repos} {
112		if err := os.MkdirAll(filepath.Join(path, dir), 0755); err != nil {
113			return nil, err
114		}
115	}
116	for _, file := range []string{admins, anonAccess, allowKeyless, serverHost, serverName, serverPort} {
117		if _, err := os.OpenFile(filepath.Join(path, file), os.O_RDONLY|os.O_CREATE, 0644); err != nil {
118			return nil, err
119		}
120	}
121	return fb, nil
122}
123
124// AccessLevel returns the access level for the given public key and repo.
125//
126// It implements backend.AccessMethod.
127func (fb *FileBackend) AccessLevel(repo string, pk gossh.PublicKey) backend.AccessLevel {
128	private := fb.IsPrivate(repo)
129	anon := fb.AnonAccess()
130	if pk != nil {
131		// Check if the key is an admin.
132		if fb.IsAdmin(pk) {
133			return backend.AdminAccess
134		}
135
136		// Check if the key is a collaborator.
137		if fb.IsCollaborator(pk, repo) {
138			if anon > backend.ReadWriteAccess {
139				return anon
140			}
141			return backend.ReadWriteAccess
142		}
143
144		// Check if repo is private.
145		if !private {
146			if anon > backend.ReadOnlyAccess {
147				return anon
148			}
149			return backend.ReadOnlyAccess
150		}
151	}
152
153	if private {
154		return backend.NoAccess
155	}
156
157	return anon
158}
159
160// AddAdmin adds a public key to the list of server admins.
161//
162// It implements backend.Backend.
163func (fb *FileBackend) AddAdmin(pk gossh.PublicKey) error {
164	// Skip if the key already exists.
165	if fb.IsAdmin(pk) {
166		return nil
167	}
168
169	ak := backend.MarshalAuthorizedKey(pk)
170	f, err := os.OpenFile(fb.adminsPath(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
171	if err != nil {
172		logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
173		return err
174	}
175
176	defer f.Close() //nolint:errcheck
177	_, err = fmt.Fprintln(f, ak)
178	return err
179}
180
181// AddCollaborator adds a public key to the list of collaborators for the given repo.
182//
183// It implements backend.Backend.
184func (fb *FileBackend) AddCollaborator(pk gossh.PublicKey, repo string) error {
185	// Skip if the key already exists.
186	if fb.IsCollaborator(pk, repo) {
187		return nil
188	}
189
190	ak := backend.MarshalAuthorizedKey(pk)
191	repo = sanatizeRepo(repo) + ".git"
192	f, err := os.OpenFile(fb.collabsPath(repo), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
193	if err != nil {
194		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
195		return err
196	}
197
198	defer f.Close() //nolint:errcheck
199	_, err = fmt.Fprintln(f, ak)
200	return err
201}
202
203// AllowKeyless returns true if keyless access is allowed.
204//
205// It implements backend.Backend.
206func (fb *FileBackend) AllowKeyless() bool {
207	line, err := readOneLine(filepath.Join(fb.path, allowKeyless))
208	if err != nil {
209		logger.Debug("failed to read allow-keyless file", "err", err)
210		return false
211	}
212
213	return line == "true"
214}
215
216// AnonAccess returns the level of anonymous access allowed.
217//
218// It implements backend.Backend.
219func (fb *FileBackend) AnonAccess() backend.AccessLevel {
220	line, err := readOneLine(filepath.Join(fb.path, anonAccess))
221	if err != nil {
222		logger.Debug("failed to read anon-access file", "err", err)
223		return backend.NoAccess
224	}
225
226	switch line {
227	case backend.NoAccess.String():
228		return backend.NoAccess
229	case backend.ReadOnlyAccess.String():
230		return backend.ReadOnlyAccess
231	case backend.ReadWriteAccess.String():
232		return backend.ReadWriteAccess
233	case backend.AdminAccess.String():
234		return backend.AdminAccess
235	default:
236		return backend.NoAccess
237	}
238}
239
240// Description returns the description of the given repo.
241//
242// It implements backend.Backend.
243func (fb *FileBackend) Description(repo string) string {
244	repo = sanatizeRepo(repo) + ".git"
245	r := &Repo{path: filepath.Join(fb.reposPath(), repo)}
246	return r.Description()
247}
248
249// IsAdmin checks if the given public key is a server admin.
250//
251// It implements backend.Backend.
252func (fb *FileBackend) IsAdmin(pk gossh.PublicKey) bool {
253	// Check if the key is an additional admin.
254	ak := backend.MarshalAuthorizedKey(pk)
255	for _, admin := range fb.AdditionalAdmins {
256		if ak == admin {
257			return true
258		}
259	}
260
261	f, err := os.Open(fb.adminsPath())
262	if err != nil {
263		logger.Debug("failed to open admins file", "err", err, "path", fb.adminsPath())
264		return false
265	}
266
267	defer f.Close() //nolint:errcheck
268	s := bufio.NewScanner(f)
269	for s.Scan() {
270		apk, _, err := backend.ParseAuthorizedKey(s.Text())
271		if err != nil {
272			continue
273		}
274		if ssh.KeysEqual(apk, pk) {
275			return true
276		}
277	}
278
279	return false
280}
281
282// IsCollaborator returns true if the given public key is a collaborator on the
283// given repo.
284//
285// It implements backend.Backend.
286func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, repo string) bool {
287	repo = sanatizeRepo(repo) + ".git"
288	f, err := os.Open(fb.collabsPath(repo))
289	if err != nil {
290		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
291		return false
292	}
293
294	defer f.Close() //nolint:errcheck
295	s := bufio.NewScanner(f)
296	for s.Scan() {
297		apk, _, err := backend.ParseAuthorizedKey(s.Text())
298		if err != nil {
299			continue
300		}
301		if ssh.KeysEqual(apk, pk) {
302			return true
303		}
304	}
305
306	return false
307}
308
309// IsPrivate returns true if the given repo is private.
310//
311// It implements backend.Backend.
312func (fb *FileBackend) IsPrivate(repo string) bool {
313	repo = sanatizeRepo(repo) + ".git"
314	r := &Repo{path: filepath.Join(fb.reposPath(), repo)}
315	return r.IsPrivate()
316}
317
318// ServerHost returns the server host.
319//
320// It implements backend.Backend.
321func (fb *FileBackend) ServerHost() string {
322	line, err := readOneLine(filepath.Join(fb.path, serverHost))
323	if err != nil {
324		logger.Debug("failed to read server-host file", "err", err)
325		return ""
326	}
327
328	return line
329}
330
331// ServerName returns the server name.
332//
333// It implements backend.Backend.
334func (fb *FileBackend) ServerName() string {
335	line, err := readOneLine(filepath.Join(fb.path, serverName))
336	if err != nil {
337		logger.Debug("failed to read server-name file", "err", err)
338		return ""
339	}
340
341	return line
342}
343
344// ServerPort returns the server port.
345//
346// It implements backend.Backend.
347func (fb *FileBackend) ServerPort() string {
348	line, err := readOneLine(filepath.Join(fb.path, serverPort))
349	if err != nil {
350		logger.Debug("failed to read server-port file", "err", err)
351		return ""
352	}
353
354	if _, err := strconv.Atoi(line); err != nil {
355		logger.Debug("failed to parse server-port file", "err", err)
356		return ""
357	}
358
359	return line
360}
361
362// SetAllowKeyless sets whether or not to allow keyless access.
363//
364// It implements backend.Backend.
365func (fb *FileBackend) SetAllowKeyless(allow bool) error {
366	f, err := os.OpenFile(filepath.Join(fb.path, allowKeyless), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
367	if err != nil {
368		return fmt.Errorf("failed to open allow-keyless file: %w", err)
369	}
370
371	defer f.Close() //nolint:errcheck
372	_, err = fmt.Fprintln(f, allow)
373	return err
374}
375
376// SetAnonAccess sets the anonymous access level.
377//
378// It implements backend.Backend.
379func (fb *FileBackend) SetAnonAccess(level backend.AccessLevel) error {
380	f, err := os.OpenFile(filepath.Join(fb.path, anonAccess), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
381	if err != nil {
382		return fmt.Errorf("failed to open anon-access file: %w", err)
383	}
384
385	defer f.Close() //nolint:errcheck
386	_, err = fmt.Fprintln(f, level.String())
387	return err
388}
389
390// SetDescription sets the description of the given repo.
391//
392// It implements backend.Backend.
393func (fb *FileBackend) SetDescription(repo string, desc string) error {
394	f, err := os.OpenFile(filepath.Join(fb.reposPath(), repo, description), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
395	if err != nil {
396		return fmt.Errorf("failed to open description file: %w", err)
397	}
398
399	defer f.Close() //nolint:errcheck
400	_, err = fmt.Fprintln(f, desc)
401	return err
402}
403
404// SetPrivate sets the private status of the given repo.
405//
406// It implements backend.Backend.
407func (fb *FileBackend) SetPrivate(repo string, priv bool) error {
408	repo = sanatizeRepo(repo) + ".git"
409	daemonExport := filepath.Join(fb.reposPath(), repo, "git-daemon-export-ok")
410	if priv {
411		_ = os.Remove(daemonExport)
412		f, err := os.Create(filepath.Join(fb.reposPath(), repo, private))
413		if err != nil {
414			return fmt.Errorf("failed to create private file: %w", err)
415		}
416
417		_ = f.Close() //nolint:errcheck
418	} else {
419		// Create git-daemon-export-ok file if repo is public.
420		f, err := os.Create(daemonExport)
421		if err != nil {
422			logger.Warn("failed to create git-daemon-export-ok file", "err", err)
423		} else {
424			_ = f.Close() //nolint:errcheck
425		}
426	}
427	return nil
428}
429
430// SetServerHost sets the server host.
431//
432// It implements backend.Backend.
433func (fb *FileBackend) SetServerHost(host string) error {
434	f, err := os.Create(filepath.Join(fb.path, serverHost))
435	if err != nil {
436		return fmt.Errorf("failed to create server-host file: %w", err)
437	}
438
439	defer f.Close() //nolint:errcheck
440	_, err = fmt.Fprintln(f, host)
441	return err
442}
443
444// SetServerName sets the server name.
445//
446// It implements backend.Backend.
447func (fb *FileBackend) SetServerName(name string) error {
448	f, err := os.Create(filepath.Join(fb.path, serverName))
449	if err != nil {
450		return fmt.Errorf("failed to create server-name file: %w", err)
451	}
452
453	defer f.Close() //nolint:errcheck
454	_, err = fmt.Fprintln(f, name)
455	return err
456}
457
458// SetServerPort sets the server port.
459//
460// It implements backend.Backend.
461func (fb *FileBackend) SetServerPort(port string) error {
462	f, err := os.Create(filepath.Join(fb.path, serverPort))
463	if err != nil {
464		return fmt.Errorf("failed to create server-port file: %w", err)
465	}
466
467	defer f.Close() //nolint:errcheck
468	_, err = fmt.Fprintln(f, port)
469	return err
470}
471
472// CreateRepository creates a new repository.
473//
474// Created repositories are always bare.
475//
476// It implements backend.Backend.
477func (fb *FileBackend) CreateRepository(name string, private bool) (backend.Repository, error) {
478	name = sanatizeRepo(name) + ".git"
479	rp := filepath.Join(fb.reposPath(), name)
480	if _, err := git.Init(rp, true); err != nil {
481		logger.Debug("failed to create repository", "err", err)
482		return nil, err
483	}
484
485	fb.SetPrivate(name, private)
486
487	return &Repo{path: rp}, nil
488}
489
490// DeleteRepository deletes the given repository.
491//
492// It implements backend.Backend.
493func (fb *FileBackend) DeleteRepository(name string) error {
494	name = sanatizeRepo(name) + ".git"
495	return os.RemoveAll(filepath.Join(fb.reposPath(), name))
496}
497
498// RenameRepository renames the given repository.
499//
500// It implements backend.Backend.
501func (fb *FileBackend) RenameRepository(oldName string, newName string) error {
502	oldName = sanatizeRepo(oldName) + ".git"
503	newName = sanatizeRepo(newName) + ".git"
504	return os.Rename(filepath.Join(fb.reposPath(), oldName), filepath.Join(fb.reposPath(), newName))
505}
506
507// Repository finds the given repository.
508//
509// It implements backend.Backend.
510func (fb *FileBackend) Repository(repo string) (backend.Repository, error) {
511	repo = sanatizeRepo(repo) + ".git"
512	rp := filepath.Join(fb.reposPath(), repo)
513	_, err := os.Stat(rp)
514	if !errors.Is(err, os.ErrExist) {
515		return nil, err
516	}
517
518	return &Repo{path: rp}, nil
519}
520
521// Repositories returns a list of all repositories.
522//
523// It implements backend.Backend.
524func (fb *FileBackend) Repositories() ([]backend.Repository, error) {
525	repos := make([]backend.Repository, 0)
526	err := filepath.WalkDir(fb.reposPath(), func(path string, d fs.DirEntry, _ error) error {
527		// Skip non-directories.
528		if !d.IsDir() {
529			return nil
530		}
531
532		// Skip non-repositories.
533		if !strings.HasSuffix(path, ".git") {
534			return nil
535		}
536
537		repos = append(repos, &Repo{path: path})
538
539		return nil
540	})
541	if err != nil {
542		return nil, err
543	}
544
545	return repos, nil
546}
547
548// DefaultBranch returns the default branch of the given repository.
549//
550// It implements backend.Backend.
551func (fb *FileBackend) DefaultBranch(repo string) (string, error) {
552	rr, err := fb.Repository(repo)
553	if err != nil {
554		logger.Debug("failed to get default branch", "err", err)
555		return "", err
556	}
557
558	r, err := rr.Repository()
559	if err != nil {
560		logger.Debug("failed to open repository for default branch", "err", err)
561		return "", err
562	}
563
564	head, err := r.HEAD()
565	if err != nil {
566		logger.Debug("failed to get HEAD for default branch", "err", err)
567		return "", err
568	}
569
570	return head.Name().Short(), nil
571}
572
573// SetDefaultBranch sets the default branch for the given repository.
574//
575// It implements backend.Backend.
576func (fb *FileBackend) SetDefaultBranch(repo string, branch string) error {
577	rr, err := fb.Repository(repo)
578	if err != nil {
579		logger.Debug("failed to get repository for default branch", "err", err)
580		return err
581	}
582
583	r, err := rr.Repository()
584	if err != nil {
585		logger.Debug("failed to open repository for default branch", "err", err)
586		return err
587	}
588
589	if _, err := r.SymbolicRef(gitm.SymbolicRefOptions{
590		Name: "HEAD",
591		Ref:  gitm.RefsHeads + branch,
592	}); err != nil {
593		logger.Debug("failed to set default branch", "err", err)
594		return err
595	}
596
597	return nil
598}