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/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	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)}
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	f, err := os.Open(fb.collabsPath(repo))
311	if err != nil {
312		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
313		return false
314	}
315
316	defer f.Close() //nolint:errcheck
317	s := bufio.NewScanner(f)
318	for s.Scan() {
319		apk, _, err := backend.ParseAuthorizedKey(s.Text())
320		if err != nil {
321			continue
322		}
323		if ssh.KeysEqual(apk, pk) {
324			return true
325		}
326	}
327
328	return false
329}
330
331// IsPrivate returns true if the given repo is private.
332//
333// It implements backend.Backend.
334func (fb *FileBackend) IsPrivate(repo string) bool {
335	repo = sanatizeRepo(repo) + ".git"
336	r := &Repo{path: filepath.Join(fb.reposPath(), repo)}
337	return r.IsPrivate()
338}
339
340// ServerHost returns the server host.
341//
342// It implements backend.Backend.
343func (fb *FileBackend) ServerHost() string {
344	line, err := readOneLine(filepath.Join(fb.settingsPath(), serverHost))
345	if err != nil {
346		logger.Debug("failed to read server-host file", "err", err)
347		return ""
348	}
349
350	return line
351}
352
353// ServerName returns the server name.
354//
355// It implements backend.Backend.
356func (fb *FileBackend) ServerName() string {
357	line, err := readOneLine(filepath.Join(fb.settingsPath(), serverName))
358	if err != nil {
359		logger.Debug("failed to read server-name file", "err", err)
360		return ""
361	}
362
363	return line
364}
365
366// ServerPort returns the server port.
367//
368// It implements backend.Backend.
369func (fb *FileBackend) ServerPort() string {
370	line, err := readOneLine(filepath.Join(fb.settingsPath(), serverPort))
371	if err != nil {
372		logger.Debug("failed to read server-port file", "err", err)
373		return ""
374	}
375
376	if _, err := strconv.Atoi(line); err != nil {
377		logger.Debug("failed to parse server-port file", "err", err)
378		return ""
379	}
380
381	return line
382}
383
384// SetAllowKeyless sets whether or not to allow keyless access.
385//
386// It implements backend.Backend.
387func (fb *FileBackend) SetAllowKeyless(allow bool) error {
388	f, err := os.OpenFile(filepath.Join(fb.settingsPath(), allowKeyless), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
389	if err != nil {
390		return fmt.Errorf("failed to open allow-keyless file: %w", err)
391	}
392
393	defer f.Close() //nolint:errcheck
394	_, err = fmt.Fprintln(f, allow)
395	return err
396}
397
398// SetAnonAccess sets the anonymous access level.
399//
400// It implements backend.Backend.
401func (fb *FileBackend) SetAnonAccess(level backend.AccessLevel) error {
402	f, err := os.OpenFile(filepath.Join(fb.settingsPath(), anonAccess), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
403	if err != nil {
404		return fmt.Errorf("failed to open anon-access file: %w", err)
405	}
406
407	defer f.Close() //nolint:errcheck
408	_, err = fmt.Fprintln(f, level.String())
409	return err
410}
411
412// SetDescription sets the description of the given repo.
413//
414// It implements backend.Backend.
415func (fb *FileBackend) SetDescription(repo string, desc string) error {
416	f, err := os.OpenFile(filepath.Join(fb.reposPath(), repo, description), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
417	if err != nil {
418		return fmt.Errorf("failed to open description file: %w", err)
419	}
420
421	defer f.Close() //nolint:errcheck
422	_, err = fmt.Fprintln(f, desc)
423	return err
424}
425
426// SetPrivate sets the private status of the given repo.
427//
428// It implements backend.Backend.
429func (fb *FileBackend) SetPrivate(repo string, priv bool) error {
430	repo = sanatizeRepo(repo) + ".git"
431	daemonExport := filepath.Join(fb.reposPath(), repo, "git-daemon-export-ok")
432	if priv {
433		_ = os.Remove(daemonExport)
434		f, err := os.Create(filepath.Join(fb.reposPath(), repo, private))
435		if err != nil {
436			return fmt.Errorf("failed to create private file: %w", err)
437		}
438
439		_ = f.Close() //nolint:errcheck
440	} else {
441		// Create git-daemon-export-ok file if repo is public.
442		f, err := os.Create(daemonExport)
443		if err != nil {
444			logger.Warn("failed to create git-daemon-export-ok file", "err", err)
445		} else {
446			_ = f.Close() //nolint:errcheck
447		}
448	}
449	return nil
450}
451
452// SetServerHost sets the server host.
453//
454// It implements backend.Backend.
455func (fb *FileBackend) SetServerHost(host string) error {
456	f, err := os.Create(filepath.Join(fb.settingsPath(), serverHost))
457	if err != nil {
458		return fmt.Errorf("failed to create server-host file: %w", err)
459	}
460
461	defer f.Close() //nolint:errcheck
462	_, err = fmt.Fprintln(f, host)
463	return err
464}
465
466// SetServerName sets the server name.
467//
468// It implements backend.Backend.
469func (fb *FileBackend) SetServerName(name string) error {
470	f, err := os.Create(filepath.Join(fb.settingsPath(), serverName))
471	if err != nil {
472		return fmt.Errorf("failed to create server-name file: %w", err)
473	}
474
475	defer f.Close() //nolint:errcheck
476	_, err = fmt.Fprintln(f, name)
477	return err
478}
479
480// SetServerPort sets the server port.
481//
482// It implements backend.Backend.
483func (fb *FileBackend) SetServerPort(port string) error {
484	f, err := os.Create(filepath.Join(fb.settingsPath(), serverPort))
485	if err != nil {
486		return fmt.Errorf("failed to create server-port file: %w", err)
487	}
488
489	defer f.Close() //nolint:errcheck
490	_, err = fmt.Fprintln(f, port)
491	return err
492}
493
494// CreateRepository creates a new repository.
495//
496// Created repositories are always bare.
497//
498// It implements backend.Backend.
499func (fb *FileBackend) CreateRepository(name string, private bool) (backend.Repository, error) {
500	name = sanatizeRepo(name) + ".git"
501	rp := filepath.Join(fb.reposPath(), name)
502	if _, err := git.Init(rp, true); err != nil {
503		logger.Debug("failed to create repository", "err", err)
504		return nil, err
505	}
506
507	fb.SetPrivate(name, private)
508
509	return &Repo{path: rp}, nil
510}
511
512// DeleteRepository deletes the given repository.
513//
514// It implements backend.Backend.
515func (fb *FileBackend) DeleteRepository(name string) error {
516	name = sanatizeRepo(name) + ".git"
517	return os.RemoveAll(filepath.Join(fb.reposPath(), name))
518}
519
520// RenameRepository renames the given repository.
521//
522// It implements backend.Backend.
523func (fb *FileBackend) RenameRepository(oldName string, newName string) error {
524	oldName = sanatizeRepo(oldName) + ".git"
525	newName = sanatizeRepo(newName) + ".git"
526	return os.Rename(filepath.Join(fb.reposPath(), oldName), filepath.Join(fb.reposPath(), newName))
527}
528
529// Repository finds the given repository.
530//
531// It implements backend.Backend.
532func (fb *FileBackend) Repository(repo string) (backend.Repository, error) {
533	repo = sanatizeRepo(repo) + ".git"
534	rp := filepath.Join(fb.reposPath(), repo)
535	_, err := os.Stat(rp)
536	if !errors.Is(err, os.ErrExist) {
537		return nil, err
538	}
539
540	return &Repo{path: rp}, nil
541}
542
543// Repositories returns a list of all repositories.
544//
545// It implements backend.Backend.
546func (fb *FileBackend) Repositories() ([]backend.Repository, error) {
547	repos := make([]backend.Repository, 0)
548	err := filepath.WalkDir(fb.reposPath(), func(path string, d fs.DirEntry, _ error) error {
549		// Skip non-directories.
550		if !d.IsDir() {
551			return nil
552		}
553
554		// Skip non-repositories.
555		if !strings.HasSuffix(path, ".git") {
556			return nil
557		}
558
559		repos = append(repos, &Repo{path: path})
560
561		return nil
562	})
563	if err != nil {
564		return nil, err
565	}
566
567	return repos, nil
568}
569
570// DefaultBranch returns the default branch of the given repository.
571//
572// It implements backend.Backend.
573func (fb *FileBackend) DefaultBranch(repo string) (string, error) {
574	rr, err := fb.Repository(repo)
575	if err != nil {
576		logger.Debug("failed to get default branch", "err", err)
577		return "", err
578	}
579
580	r, err := rr.Repository()
581	if err != nil {
582		logger.Debug("failed to open repository for default branch", "err", err)
583		return "", err
584	}
585
586	head, err := r.HEAD()
587	if err != nil {
588		logger.Debug("failed to get HEAD for default branch", "err", err)
589		return "", err
590	}
591
592	return head.Name().Short(), nil
593}
594
595// SetDefaultBranch sets the default branch for the given repository.
596//
597// It implements backend.Backend.
598func (fb *FileBackend) SetDefaultBranch(repo string, branch string) error {
599	rr, err := fb.Repository(repo)
600	if err != nil {
601		logger.Debug("failed to get repository for default branch", "err", err)
602		return err
603	}
604
605	r, err := rr.Repository()
606	if err != nil {
607		logger.Debug("failed to open repository for default branch", "err", err)
608		return err
609	}
610
611	if _, err := r.SymbolicRef("HEAD", gitm.RefsHeads+branch); err != nil {
612		logger.Debug("failed to set default branch", "err", err)
613		return err
614	}
615
616	return nil
617}