feat(backend): collabs & admins interface

Ayman Bagabas created

Change summary

server/backend/backend.go   |  54 --------
server/backend/file/file.go | 219 +++++++++++++++++++++++++++++++++++---
server/backend/noop/noop.go |  24 +++
server/backend/repo.go      |  51 ++++++++
server/backend/server.go    |  26 ++++
5 files changed, 300 insertions(+), 74 deletions(-)

Detailed changes

server/backend/backend.go 🔗

@@ -9,56 +9,10 @@ import (
 // Backend is an interface that handles repositories management and any
 // non-Git related operations.
 type Backend interface {
-	// ServerName returns the server's name.
-	ServerName() string
-	// SetServerName sets the server's name.
-	SetServerName(name string) error
-	// ServerHost returns the server's host.
-	ServerHost() string
-	// SetServerHost sets the server's host.
-	SetServerHost(host string) error
-	// ServerPort returns the server's port.
-	ServerPort() string
-	// SetServerPort sets the server's port.
-	SetServerPort(port string) error
-
-	// AnonAccess returns the access level for anonymous users.
-	AnonAccess() AccessLevel
-	// SetAnonAccess sets the access level for anonymous users.
-	SetAnonAccess(level AccessLevel) error
-	// AllowKeyless returns true if keyless access is allowed.
-	AllowKeyless() bool
-	// SetAllowKeyless sets whether or not keyless access is allowed.
-	SetAllowKeyless(allow bool) error
-
-	// Repository finds the given repository.
-	Repository(repo string) (Repository, error)
-	// Repositories returns a list of all repositories.
-	Repositories() ([]Repository, error)
-	// CreateRepository creates a new repository.
-	CreateRepository(name string, private bool) (Repository, error)
-	// DeleteRepository deletes a repository.
-	DeleteRepository(name string) error
-	// RenameRepository renames a repository.
-	RenameRepository(oldName, newName string) error
-
-	// Description returns the repo's description.
-	Description(repo string) string
-	// SetDescription sets the repo's description.
-	SetDescription(repo, desc string) error
-	// IsPrivate returns true if the repository is private.
-	IsPrivate(repo string) bool
-	// SetPrivate sets the repository's private status.
-	SetPrivate(repo string, priv bool) error
-
-	// IsCollaborator returns true if the authorized key is a collaborator on the repository.
-	IsCollaborator(pk ssh.PublicKey, repo string) bool
-	// AddCollaborator adds the authorized key as a collaborator on the repository.
-	AddCollaborator(pk ssh.PublicKey, repo string) error
-	// IsAdmin returns true if the authorized key is an admin.
-	IsAdmin(pk ssh.PublicKey) bool
-	// AddAdmin adds the authorized key as an admin.
-	AddAdmin(pk ssh.PublicKey) error
+	ServerBackend
+	RepositoryStore
+	RepositoryMetadata
+	RepositoryAccess
 }
 
 // ParseAuthorizedKey parses an authorized key string into a public key.

server/backend/file/file.go 🔗

@@ -89,7 +89,7 @@ func (fb *FileBackend) adminsPath() string {
 }
 
 func (fb *FileBackend) collabsPath(repo string) string {
-	return filepath.Join(fb.reposPath(), repo, collabs)
+	return filepath.Join(fb.path, collabs, repo)
 }
 
 func sanatizeRepo(repo string) string {
@@ -117,10 +117,16 @@ func readAll(path string) (string, error) {
 	return string(bts), err
 }
 
+// exists returns true if the given path exists.
+func exists(path string) bool {
+	_, err := os.Stat(path)
+	return err == nil
+}
+
 // NewFileBackend creates a new FileBackend.
 func NewFileBackend(path string) (*FileBackend, error) {
 	fb := &FileBackend{path: path}
-	for _, dir := range []string{repos, settings} {
+	for _, dir := range []string{repos, settings, collabs} {
 		if err := os.MkdirAll(filepath.Join(path, dir), 0755); err != nil {
 			return nil, err
 		}
@@ -181,10 +187,10 @@ func (fb *FileBackend) AccessLevel(repo string, pk gossh.PublicKey) backend.Acce
 // AddAdmin adds a public key to the list of server admins.
 //
 // It implements backend.Backend.
-func (fb *FileBackend) AddAdmin(pk gossh.PublicKey) error {
+func (fb *FileBackend) AddAdmin(pk gossh.PublicKey, memo string) error {
 	// Skip if the key already exists.
 	if fb.IsAdmin(pk) {
-		return nil
+		return fmt.Errorf("key already exists")
 	}
 
 	ak := backend.MarshalAuthorizedKey(pk)
@@ -195,32 +201,206 @@ func (fb *FileBackend) AddAdmin(pk gossh.PublicKey) error {
 	}
 
 	defer f.Close() //nolint:errcheck
-	_, err = fmt.Fprintln(f, ak)
+	if memo != "" {
+		memo = " " + memo
+	}
+	_, err = fmt.Fprintf(f, "%s%s\n", ak, memo)
 	return err
 }
 
 // AddCollaborator adds a public key to the list of collaborators for the given repo.
 //
 // It implements backend.Backend.
-func (fb *FileBackend) AddCollaborator(pk gossh.PublicKey, repo string) error {
+func (fb *FileBackend) AddCollaborator(pk gossh.PublicKey, memo string, name string) error {
+	// Check if repo exists
+	if !exists(filepath.Join(fb.reposPath(), sanatizeRepo(name)+".git")) {
+		return fmt.Errorf("repository %s does not exist", name)
+	}
+
 	// Skip if the key already exists.
-	if fb.IsCollaborator(pk, repo) {
-		return nil
+	if fb.IsCollaborator(pk, name) {
+		return fmt.Errorf("key already exists")
 	}
 
 	ak := backend.MarshalAuthorizedKey(pk)
-	repo = sanatizeRepo(repo) + ".git"
-	f, err := os.OpenFile(fb.collabsPath(repo), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
+	name = sanatizeRepo(name)
+	if err := os.MkdirAll(filepath.Dir(fb.collabsPath(name)), 0755); err != nil {
+		logger.Debug("failed to create collaborators directory",
+			"err", err, "path", filepath.Dir(fb.collabsPath(name)))
+		return err
+	}
+
+	f, err := os.OpenFile(fb.collabsPath(name), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
 	if err != nil {
-		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
+		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(name))
 		return err
 	}
 
 	defer f.Close() //nolint:errcheck
-	_, err = fmt.Fprintln(f, ak)
+	if memo != "" {
+		memo = " " + memo
+	}
+	_, err = fmt.Fprintf(f, "%s%s\n", ak, memo)
 	return err
 }
 
+// Admins returns a list of public keys that are admins.
+//
+// It implements backend.Backend.
+func (fb *FileBackend) Admins() ([]string, error) {
+	admins := make([]string, 0)
+	f, err := os.Open(fb.adminsPath())
+	if err != nil {
+		logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
+		return nil, err
+	}
+
+	defer f.Close() //nolint:errcheck
+	s := bufio.NewScanner(f)
+	for s.Scan() {
+		admins = append(admins, s.Text())
+	}
+
+	return admins, s.Err()
+}
+
+// Collaborators returns a list of public keys that are collaborators for the given repo.
+//
+// It implements backend.Backend.
+func (fb *FileBackend) Collaborators(repo string) ([]string, error) {
+	// Check if repo exists
+	if !exists(filepath.Join(fb.reposPath(), sanatizeRepo(repo)+".git")) {
+		return nil, fmt.Errorf("repository %s does not exist", repo)
+	}
+
+	collabs := make([]string, 0)
+	f, err := os.Open(fb.collabsPath(repo))
+	if err != nil && errors.Is(err, os.ErrNotExist) {
+		return collabs, nil
+	}
+	if err != nil {
+		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
+		return nil, err
+	}
+
+	defer f.Close() //nolint:errcheck
+	s := bufio.NewScanner(f)
+	for s.Scan() {
+		collabs = append(collabs, s.Text())
+	}
+
+	return collabs, s.Err()
+}
+
+// RemoveAdmin implements backend.Backend
+func (fb *FileBackend) RemoveAdmin(pk gossh.PublicKey) error {
+	f, err := os.OpenFile(fb.adminsPath(), os.O_RDWR, 0644)
+	if err != nil {
+		logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath())
+		return err
+	}
+
+	defer f.Close() //nolint:errcheck
+	s := bufio.NewScanner(f)
+	lines := make([]string, 0)
+	for s.Scan() {
+		apk, _, err := backend.ParseAuthorizedKey(s.Text())
+		if err != nil {
+			logger.Debug("failed to parse admin key", "err", err, "path", fb.adminsPath())
+			continue
+		}
+
+		if !ssh.KeysEqual(apk, pk) {
+			lines = append(lines, s.Text())
+		}
+	}
+
+	if err := s.Err(); err != nil {
+		logger.Debug("failed to scan admin keys file", "err", err, "path", fb.adminsPath())
+		return err
+	}
+
+	if err := f.Truncate(0); err != nil {
+		logger.Debug("failed to truncate admin keys file", "err", err, "path", fb.adminsPath())
+		return err
+	}
+
+	if _, err := f.Seek(0, 0); err != nil {
+		logger.Debug("failed to seek admin keys file", "err", err, "path", fb.adminsPath())
+		return err
+	}
+
+	w := bufio.NewWriter(f)
+	for _, line := range lines {
+		if _, err := fmt.Fprintln(w, line); err != nil {
+			logger.Debug("failed to write admin keys file", "err", err, "path", fb.adminsPath())
+			return err
+		}
+	}
+
+	return w.Flush()
+}
+
+// RemoveCollaborator removes a public key from the list of collaborators for the given repo.
+//
+// It implements backend.Backend.
+func (fb *FileBackend) RemoveCollaborator(pk gossh.PublicKey, repo string) error {
+	// Check if repo exists
+	if !exists(filepath.Join(fb.reposPath(), sanatizeRepo(repo)+".git")) {
+		return fmt.Errorf("repository %s does not exist", repo)
+	}
+
+	f, err := os.OpenFile(fb.collabsPath(repo), os.O_RDWR, 0644)
+	if err != nil && errors.Is(err, os.ErrNotExist) {
+		return nil
+	}
+
+	if err != nil {
+		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
+		return err
+	}
+
+	defer f.Close() //nolint:errcheck
+	s := bufio.NewScanner(f)
+	lines := make([]string, 0)
+	for s.Scan() {
+		apk, _, err := backend.ParseAuthorizedKey(s.Text())
+		if err != nil {
+			logger.Debug("failed to parse collaborator key", "err", err, "path", fb.collabsPath(repo))
+			continue
+		}
+
+		if !ssh.KeysEqual(apk, pk) {
+			lines = append(lines, s.Text())
+		}
+	}
+
+	if err := s.Err(); err != nil {
+		logger.Debug("failed to scan collaborators file", "err", err, "path", fb.collabsPath(repo))
+		return err
+	}
+
+	if err := f.Truncate(0); err != nil {
+		logger.Debug("failed to truncate collaborators file", "err", err, "path", fb.collabsPath(repo))
+		return err
+	}
+
+	if _, err := f.Seek(0, 0); err != nil {
+		logger.Debug("failed to seek collaborators file", "err", err, "path", fb.collabsPath(repo))
+		return err
+	}
+
+	w := bufio.NewWriter(f)
+	for _, line := range lines {
+		if _, err := fmt.Fprintln(w, line); err != nil {
+			logger.Debug("failed to write collaborators file", "err", err, "path", fb.collabsPath(repo))
+			return err
+		}
+	}
+
+	return w.Flush()
+}
+
 // AllowKeyless returns true if keyless access is allowed.
 //
 // It implements backend.Backend.
@@ -304,19 +484,16 @@ func (fb *FileBackend) IsAdmin(pk gossh.PublicKey) bool {
 // given repo.
 //
 // It implements backend.Backend.
-func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, repo string) bool {
-	repo = sanatizeRepo(repo) + ".git"
-	_, err := os.Stat(filepath.Join(fb.reposPath(), repo))
-	if errors.Is(err, os.ErrNotExist) {
+func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, name string) bool {
+	name = sanatizeRepo(name)
+	_, err := os.Stat(fb.collabsPath(name))
+	if err != nil {
 		return false
 	}
 
-	f, err := os.Open(fb.collabsPath(repo))
-	if err != nil && errors.Is(err, os.ErrNotExist) {
-		return false
-	}
+	f, err := os.Open(fb.collabsPath(name))
 	if err != nil {
-		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo))
+		logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(name))
 		return false
 	}
 

server/backend/noop/noop.go 🔗

@@ -21,18 +21,38 @@ type Noop struct {
 	Port string
 }
 
+// Admins implements backend.Backend
+func (*Noop) Admins() ([]string, error) {
+	return nil, nil
+}
+
+// Collaborators implements backend.Backend
+func (*Noop) Collaborators(repo string) ([]string, error) {
+	return nil, nil
+}
+
+// RemoveAdmin implements backend.Backend
+func (*Noop) RemoveAdmin(pk ssh.PublicKey) error {
+	return nil
+}
+
+// RemoveCollaborator implements backend.Backend
+func (*Noop) RemoveCollaborator(pk ssh.PublicKey, repo string) error {
+	return nil
+}
+
 // AccessLevel implements backend.AccessMethod
 func (*Noop) AccessLevel(repo string, pk ssh.PublicKey) backend.AccessLevel {
 	return backend.AdminAccess
 }
 
 // AddAdmin implements backend.Backend
-func (*Noop) AddAdmin(pk ssh.PublicKey) error {
+func (*Noop) AddAdmin(pk ssh.PublicKey, memo string) error {
 	return ErrNotImpl
 }
 
 // AddCollaborator implements backend.Backend
-func (*Noop) AddCollaborator(pk ssh.PublicKey, repo string) error {
+func (*Noop) AddCollaborator(pk ssh.PublicKey, memo string, repo string) error {
 	return ErrNotImpl
 }
 

server/backend/repo.go 🔗

@@ -1,6 +1,55 @@
 package backend
 
-import "github.com/charmbracelet/soft-serve/git"
+import (
+	"github.com/charmbracelet/soft-serve/git"
+	"golang.org/x/crypto/ssh"
+)
+
+// RepositoryStore is an interface for managing repositories.
+type RepositoryStore interface {
+	// Repository finds the given repository.
+	Repository(repo string) (Repository, error)
+	// Repositories returns a list of all repositories.
+	Repositories() ([]Repository, error)
+	// CreateRepository creates a new repository.
+	CreateRepository(name string, private bool) (Repository, error)
+	// DeleteRepository deletes a repository.
+	DeleteRepository(name string) error
+	// RenameRepository renames a repository.
+	RenameRepository(oldName, newName string) error
+}
+
+// RepositoryMetadata is an interface for managing repository metadata.
+type RepositoryMetadata interface {
+	// Description returns the repository's description.
+	Description(repo string) string
+	// SetDescription sets the repository's description.
+	SetDescription(repo, desc string) error
+	// IsPrivate returns whether the repository is private.
+	IsPrivate(repo string) bool
+	// SetPrivate sets whether the repository is private.
+	SetPrivate(repo string, private bool) error
+}
+
+// RepositoryAccess is an interface for managing repository access.
+type RepositoryAccess interface {
+	// IsCollaborator returns true if the authorized key is a collaborator on the repository.
+	IsCollaborator(pk ssh.PublicKey, repo string) bool
+	// AddCollaborator adds the authorized key as a collaborator on the repository.
+	AddCollaborator(pk ssh.PublicKey, memo string, repo string) error
+	// RemoveCollaborator removes the authorized key as a collaborator on the repository.
+	RemoveCollaborator(pk ssh.PublicKey, repo string) error
+	// Collaborators returns a list of all collaborators on the repository.
+	Collaborators(repo string) ([]string, error)
+	// IsAdmin returns true if the authorized key is an admin.
+	IsAdmin(pk ssh.PublicKey) bool
+	// AddAdmin adds the authorized key as an admin.
+	AddAdmin(pk ssh.PublicKey, memo string) error
+	// RemoveAdmin removes the authorized key as an admin.
+	RemoveAdmin(pk ssh.PublicKey) error
+	// Admins returns a list of all admins.
+	Admins() ([]string, error)
+}
 
 // Repository is a Git repository interface.
 type Repository interface {

server/backend/server.go 🔗

@@ -0,0 +1,26 @@
+package backend
+
+// ServerBackend is an interface that handles server configuration.
+type ServerBackend interface {
+	// ServerName returns the server's name.
+	ServerName() string
+	// SetServerName sets the server's name.
+	SetServerName(name string) error
+	// ServerHost returns the server's host.
+	ServerHost() string
+	// SetServerHost sets the server's host.
+	SetServerHost(host string) error
+	// ServerPort returns the server's port.
+	ServerPort() string
+	// SetServerPort sets the server's port.
+	SetServerPort(port string) error
+
+	// AnonAccess returns the access level for anonymous users.
+	AnonAccess() AccessLevel
+	// SetAnonAccess sets the access level for anonymous users.
+	SetAnonAccess(level AccessLevel) error
+	// AllowKeyless returns true if keyless access is allowed.
+	AllowKeyless() bool
+	// SetAllowKeyless sets whether or not keyless access is allowed.
+	SetAllowKeyless(allow bool) error
+}