b6041efb6bceedfa89bc26d0671a32d01c268e6c

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
Ayman Bagabas <ayman.bagabas@gmail.com>
Date

Message

feat(backend): server backend

Add file backend that uses filesystem to manage non-git operations.

Diff

This diff is truncated to protect this page.

  1diff --git a/git/repo.go b/git/repo.go
  2index b6d95b56d75c1cd6f18e6224b12d8223ff79daae..9dd674032cdab567f48a64092386e78f5ac327c0 100644
  3--- a/git/repo.go
  4+++ b/git/repo.go
  5@@ -2,6 +2,7 @@ package git
  6 
  7 import (
  8 	"path/filepath"
  9+	"strings"
 10 
 11 	"github.com/gogs/git-module"
 12 )
 13@@ -29,6 +30,10 @@ func Clone(src, dst string, opts ...git.CloneOptions) error {
 14 
 15 // Init initializes and opens a new git repository.
 16 func Init(path string, bare bool) (*Repository, error) {
 17+	if bare {
 18+		path = strings.TrimSuffix(path, ".git") + ".git"
 19+	}
 20+
 21 	err := git.Init(path, git.InitOptions{Bare: bare})
 22 	if err != nil {
 23 		return nil, err
 24diff --git a/server/backend/access.go b/server/backend/access.go
 25new file mode 100644
 26index 0000000000000000000000000000000000000000..52cc16eff8357c46fa84d18bed37336ba81ded82
 27--- /dev/null
 28+++ b/server/backend/access.go
 29@@ -0,0 +1,42 @@
 30+package backend
 31+
 32+import "golang.org/x/crypto/ssh"
 33+
 34+// AccessLevel is the level of access allowed to a repo.
 35+type AccessLevel int
 36+
 37+const (
 38+	// NoAccess does not allow access to the repo.
 39+	NoAccess AccessLevel = iota
 40+
 41+	// ReadOnlyAccess allows read-only access to the repo.
 42+	ReadOnlyAccess
 43+
 44+	// ReadWriteAccess allows read and write access to the repo.
 45+	ReadWriteAccess
 46+
 47+	// AdminAccess allows read, write, and admin access to the repo.
 48+	AdminAccess
 49+)
 50+
 51+// String returns the string representation of the access level.
 52+func (a AccessLevel) String() string {
 53+	switch a {
 54+	case NoAccess:
 55+		return "no-access"
 56+	case ReadOnlyAccess:
 57+		return "read-only"
 58+	case ReadWriteAccess:
 59+		return "read-write"
 60+	case AdminAccess:
 61+		return "admin-access"
 62+	default:
 63+		return "unknown"
 64+	}
 65+}
 66+
 67+// AccessMethod is an interface that handles repository authorization.
 68+type AccessMethod interface {
 69+	// AccessLevel returns the access level for the given repository and key.
 70+	AccessLevel(repo string, pk ssh.PublicKey) AccessLevel
 71+}
 72diff --git a/server/backend/backend.go b/server/backend/backend.go
 73new file mode 100644
 74index 0000000000000000000000000000000000000000..0bc7d6f62fe26e5bfb9c38e3a024bed57cff0a25
 75--- /dev/null
 76+++ b/server/backend/backend.go
 77@@ -0,0 +1,84 @@
 78+package backend
 79+
 80+import (
 81+	"bytes"
 82+
 83+	"golang.org/x/crypto/ssh"
 84+)
 85+
 86+// Backend is an interface that handles repositories management and any
 87+// non-Git related operations.
 88+type Backend interface {
 89+	// ServerName returns the server's name.
 90+	ServerName() string
 91+	// SetServerName sets the server's name.
 92+	SetServerName(name string) error
 93+	// ServerHost returns the server's host.
 94+	ServerHost() string
 95+	// SetServerHost sets the server's host.
 96+	SetServerHost(host string) error
 97+	// ServerPort returns the server's port.
 98+	ServerPort() string
 99+	// SetServerPort sets the server's port.
100+	SetServerPort(port string) error
101+
102+	// AnonAccess returns the access level for anonymous users.
103+	AnonAccess() AccessLevel
104+	// SetAnonAccess sets the access level for anonymous users.
105+	SetAnonAccess(level AccessLevel) error
106+	// AllowKeyless returns true if keyless access is allowed.
107+	AllowKeyless() bool
108+	// SetAllowKeyless sets whether or not keyless access is allowed.
109+	SetAllowKeyless(allow bool) error
110+
111+	// Repository finds the given repository.
112+	Repository(repo string) (Repository, error)
113+	// Repositories returns a list of all repositories.
114+	Repositories() ([]Repository, error)
115+	// CreateRepository creates a new repository.
116+	CreateRepository(name string, private bool) (Repository, error)
117+	// DeleteRepository deletes a repository.
118+	DeleteRepository(name string) error
119+	// RenameRepository renames a repository.
120+	RenameRepository(oldName, newName string) error
121+	// DefaultBranch returns the repository's default branch.
122+	DefaultBranch(repo string) (string, error)
123+	// SetDefaultBranch sets the default branch for a repository.
124+	SetDefaultBranch(repo string, branch string) error
125+
126+	// Description returns the repo's description.
127+	Description(repo string) string
128+	// SetDescription sets the repo's description.
129+	SetDescription(repo, desc string) error
130+	// IsPrivate returns true if the repository is private.
131+	IsPrivate(repo string) bool
132+	// SetPrivate sets the repository's private status.
133+	SetPrivate(repo string, priv bool) error
134+
135+	// IsCollaborator returns true if the authorized key is a collaborator on the repository.
136+	IsCollaborator(pk ssh.PublicKey, repo string) bool
137+	// AddCollaborator adds the authorized key as a collaborator on the repository.
138+	AddCollaborator(pk ssh.PublicKey, repo string) error
139+	// IsAdmin returns true if the authorized key is an admin.
140+	IsAdmin(pk ssh.PublicKey) bool
141+	// AddAdmin adds the authorized key as an admin.
142+	AddAdmin(pk ssh.PublicKey) error
143+}
144+
145+// ParseAuthorizedKey parses an authorized key string into a public key.
146+func ParseAuthorizedKey(ak string) (ssh.PublicKey, string, error) {
147+	pk, c, _, _, err := ssh.ParseAuthorizedKey([]byte(ak))
148+	return pk, c, err
149+}
150+
151+// MarshalAuthorizedKey marshals a public key into an authorized key string.
152+//
153+// This is the inverse of ParseAuthorizedKey.
154+// This function is a copy of ssh.MarshalAuthorizedKey, but without the trailing newline.
155+// It returns an empty string if pk is nil.
156+func MarshalAuthorizedKey(pk ssh.PublicKey) string {
157+	if pk == nil {
158+		return ""
159+	}
160+	return string(bytes.TrimSuffix(ssh.MarshalAuthorizedKey(pk), []byte("\n")))
161+}
162diff --git a/server/backend/file/file.go b/server/backend/file/file.go
163new file mode 100644
164index 0000000000000000000000000000000000000000..9cdcd3e183e3214bb44ed90fc344c01f495f39ef
165--- /dev/null
166+++ b/server/backend/file/file.go
167@@ -0,0 +1,598 @@
168+// Package file implements a backend that uses the filesystem to store non-Git related data
169+//
170+// The following files and directories are used:
171+//
172+//   - anon-access: contains the access level for anonymous users
173+//   - allow-keyless: contains a boolean value indicating whether or not keyless access is allowed
174+//   - admins: contains a list of authorized keys for admin users
175+//   - host: contains the server's server hostname
176+//   - name: contains the server's name
177+//   - port: contains the server's port
178+//   - repos: is a the directory containing all Git repositories
179+//
180+// Each repository has the following files and directories:
181+//   - collaborators: contains a list of authorized keys for collaborators
182+//   - description: contains the repository's description
183+//   - private: when present, indicates that the repository is private
184+//   - git-daemon-export-ok: when present, indicates that the repository is public
185+//   - project-name: contains the repository's project name
186+package file
187+
188+import (
189+	"bufio"
190+	"errors"
191+	"fmt"
192+	"io"
193+	"io/fs"
194+	"os"
195+	"path/filepath"
196+	"strconv"
197+	"strings"
198+
199+	"github.com/charmbracelet/log"
200+	"github.com/charmbracelet/soft-serve/git"
201+	"github.com/charmbracelet/soft-serve/server/backend"
202+	"github.com/gliderlabs/ssh"
203+	gitm "github.com/gogs/git-module"
204+	gossh "golang.org/x/crypto/ssh"
205+)
206+
207+// sub file and directory names.
208+const (
209+	anonAccess   = "anon-access"
210+	allowKeyless = "allow-keyless"
211+	admins       = "admins"
212+	serverHost   = "host"
213+	serverName   = "name"
214+	serverPort   = "port"
215+	repos        = "repos"
216+	collabs      = "collaborators"
217+	description  = "description"
218+	private      = "private"
219+)
220+
221+var (
222+	logger = log.WithPrefix("backend.file")
223+)
224+
225+var _ backend.Backend = &FileBackend{}
226+
227+var _ backend.AccessMethod = &FileBackend{}
228+
229+// FileBackend is a backend that uses the filesystem.
230+type FileBackend struct { // nolint:revive
231+	// path is the path to the directory containing the repositories and config
232+	// files.
233+	path string
234+	// AdditionalAdmins additional admins to the server.
235+	AdditionalAdmins []string
236+}
237+
238+func (fb *FileBackend) reposPath() string {
239+	return filepath.Join(fb.path, repos)
240+}
241+
242+func (fb *FileBackend) adminsPath() string {
243+	return filepath.Join(fb.path, admins)
244+}
245+
246+func (fb *FileBackend) collabsPath(repo string) string {
247+	return filepath.Join(fb.reposPath(), repo, collabs)
248+}
249+
250+func sanatizeRepo(repo string) string {
251+	return strings.TrimSuffix(repo, ".git")
252+}
253+
254+func readOneLine(path string) (string, error) {
255+	f, err := os.Open(path)
256+	if err != nil {
257+		return "", err
258+	}
259+	defer f.Close() // nolint:errcheck
260+	s := bufio.NewScanner(f)
261+	s.Scan()
262+	return s.Text(), s.Err()
263+}
264+
265+func readAll(path string) (string, error) {
266+	f, err := os.Open(path)
267diff --git a/server/backend/file/repo.go b/server/backend/file/repo.go
268new file mode 100644
269index 0000000000000000000000000000000000000000..6764f5fe3164fa5518712159b5f140d895c6bfa3
270--- /dev/null
271+++ b/server/backend/file/repo.go
272@@ -0,0 +1,56 @@
273+package file
274+
275+import (
276+	"errors"
277+	"os"
278+	"path/filepath"
279+	"strings"
280+
281+	"github.com/charmbracelet/soft-serve/git"
282+	"github.com/charmbracelet/soft-serve/server/backend"
283+)
284+
285+var _ backend.Repository = (*Repo)(nil)
286+
287+// Repo is a filesystem Git repository.
288+//
289+// It implemenets backend.Repository.
290+type Repo struct {
291+	path string
292+}
293+
294+// Name returns the repository's name.
295+//
296+// It implements backend.Repository.
297+func (r *Repo) Name() string {
298+	return strings.TrimSuffix(filepath.Base(r.path), ".git")
299+}
300+
301+// Description returns the repository's description.
302+//
303+// It implements backend.Repository.
304+func (r *Repo) Description() string {
305+	desc, err := readAll(r.path)
306+	if err != nil {
307+		logger.Debug("failed to read description file", "err", err,
308+			"path", filepath.Join(r.path, description))
309+		return ""
310+	}
311+
312+	return desc
313+}
314+
315+// IsPrivate returns whether the repository is private.
316+//
317+// It implements backend.Repository.
318+func (r *Repo) IsPrivate() bool {
319+	_, err := os.Stat(filepath.Join(r.path, private))
320+	return errors.Is(err, os.ErrExist)
321+}
322+
323+// Repository returns the underlying git.Repository.
324+//
325+// It implements backend.Repository.
326+func (r *Repo) Repository() (*git.Repository, error) {
327+	return git.Open(r.path)
328+}
329diff --git a/server/backend/repo.go b/server/backend/repo.go
330new file mode 100644
331index 0000000000000000000000000000000000000000..846134b83e9873ae0874ad5e9f702ab7b801a78b
332--- /dev/null
333+++ b/server/backend/repo.go
334@@ -0,0 +1,15 @@
335+package backend
336+
337+import "github.com/charmbracelet/soft-serve/git"
338+
339+// Repository is a Git repository interface.
340+type Repository interface {
341+	// Name returns the repository's name.
342+	Name() string
343+	// Description returns the repository's description.
344+	Description() string
345+	// IsPrivate returns whether the repository is private.
346+	IsPrivate() bool
347+	// Repository returns the underlying git.Repository.
348+	Repository() (*git.Repository, error)
349+}
350diff --git a/server/backend/utils.go b/server/backend/utils.go
351new file mode 100644
352index 0000000000000000000000000000000000000000..6f7d87b3239c3889607467e67d1e981713c0f5ab
353--- /dev/null
354+++ b/server/backend/utils.go
355@@ -0,0 +1,51 @@
356+package backend
357+
358+import (
359+	"path/filepath"
360+
361+	"github.com/charmbracelet/soft-serve/git"
362+	"github.com/gobwas/glob"
363+)
364+
365+// LatestFile returns the contents of the latest file at the specified path in
366+// the repository and its file path.
367+func LatestFile(r Repository, pattern string) (string, string, error) {
368+	g := glob.MustCompile(pattern)
369+	dir := filepath.Dir(pattern)
370+	repo, err := r.Repository()
371+	if err != nil {
372+		return "", "", err
373+	}
374+	head, err := repo.HEAD()
375+	if err != nil {
376+		return "", "", err
377+	}
378+	t, err := repo.TreePath(head, dir)
379+	if err != nil {
380+		return "", "", err
381+	}
382+	ents, err := t.Entries()
383+	if err != nil {
384+		return "", "", err
385+	}
386+	for _, e := range ents {
387+		fp := filepath.Join(dir, e.Name())
388+		if e.IsTree() {
389+			continue
390+		}
391+		if g.Match(fp) {
392+			bts, err := e.Contents()
393+			if err != nil {
394+				return "", "", err
395+			}
396+			return string(bts), fp, nil
397+		}
398+	}
399+	return "", "", git.ErrFileNotFound
400+}
401+
402+// Readme returns the repository's README.
403+func Readme(r Repository) (readme string, path string, err error) {
404+	readme, path, err = LatestFile(r, "README*")
405+	return
406+}