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