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