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