feat(proto): update access and provider interfaces

Ayman Bagabas created

Add, delete update repositories and their metadata

Change summary

proto/access.go          |   2 
proto/provider.go        |  14 +++++
server/config/access.go  | 107 +++++++++++++++++++++++++++++++++++------
server/config/config.go  |  34 ++++++++++--
server/config/default.go |   2 
server/config/repo.go    |  78 ++++++++++++++++++++++++++++++
server/config/user.go    |  18 +++++++
7 files changed, 231 insertions(+), 24 deletions(-)

Detailed changes

proto/access.go 🔗

@@ -60,4 +60,6 @@ func (a *AccessLevel) UnmarshalText(text []byte) error {
 // Access is an interface that defines the access level for repositories.
 type Access interface {
 	AuthRepo(repo string, pk ssh.PublicKey) AccessLevel
+	IsCollab(repo string, pk ssh.PublicKey) bool
+	IsAdmin(pk ssh.PublicKey) bool
 }

proto/provider.go 🔗

@@ -6,6 +6,20 @@ type Provider interface {
 	Open(name string) (Repository, error)
 	// ListRepos lists all repositories.
 	ListRepos() ([]Metadata, error)
+	// Create creates a new repository.
+	Create(name string, projectName string, description string, isPrivate bool) error
+	// Delete deletes a repository.
+	Delete(name string) error
+	// Rename renames a repository.
+	Rename(name string, newName string) error
+	// SetProjectName sets a repository's project name.
+	SetProjectName(name string, projectName string) error
+	// SetDescription sets a repository's description.
+	SetDescription(name string, description string) error
+	// SetPrivate sets a repository's private flag.
+	SetPrivate(name string, isPrivate bool) error
+	// SetDefaultBranch sets a repository's default branch.
+	SetDefaultBranch(name string, branch string) error
 }
 
 // MetadataProvider is a Git repository metadata provider.

server/config/access.go 🔗

@@ -1,6 +1,9 @@
 package config
 
 import (
+	"log"
+	"strings"
+
 	"github.com/charmbracelet/soft-serve/proto"
 	"github.com/gliderlabs/ssh"
 	gossh "golang.org/x/crypto/ssh"
@@ -13,6 +16,56 @@ func (c *Config) AuthRepo(repo string, pk ssh.PublicKey) proto.AccessLevel {
 	return c.accessForKey(repo, pk)
 }
 
+// User returns the user for the given public key. Can return nil if no user is
+// found.
+func (c *Config) User(pk ssh.PublicKey) (proto.User, error) {
+	k := authorizedKey(pk)
+	u, err := c.db.GetUserByPublicKey(k)
+	if err != nil {
+		log.Printf("error getting user for key: %s", err)
+		return nil, err
+	}
+	if u == nil {
+		return nil, nil
+	}
+	return &user{
+		cfg:  c,
+		user: u,
+	}, nil
+}
+
+// IsCollab returns whether or not the given key is a collaborator on the given
+// repository.
+func (c *Config) IsCollab(repo string, pk ssh.PublicKey) bool {
+	if c.isInitialAdminKey(pk) {
+		return true
+	}
+
+	isCollab, err := c.db.IsRepoPublicKeyCollab(repo, authorizedKey(pk))
+	if err != nil {
+		log.Printf("error checking if key is repo collab: %v", err)
+		return false
+	}
+	if isCollab {
+		return true
+	}
+	return false
+}
+
+// IsAdmin returns whether or not the given key is an admin.
+func (c *Config) IsAdmin(pk ssh.PublicKey) bool {
+	if c.isInitialAdminKey(pk) {
+		return true
+	}
+
+	u, err := c.User(pk)
+	if err != nil {
+		log.Printf("error getting user for key: %s", err)
+		return false
+	}
+	return u.IsAdmin()
+}
+
 // PasswordHandler returns whether or not password access is allowed.
 func (c *Config) PasswordHandler(ctx ssh.Context, password string) bool {
 	return (c.AnonAccess != proto.NoAccess) && c.SSH.AllowKeyless &&
@@ -37,32 +90,34 @@ func (c *Config) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
 // If repo exists, and private, then admins and collabs are allowed access.
 // If repo exists, and not private, then access is based on config.AnonAccess.
 func (c *Config) accessForKey(repo string, pk ssh.PublicKey) proto.AccessLevel {
+	if c.isInitialAdminKey(pk) {
+		return proto.AdminAccess
+	}
+
 	anon := c.AnonAccess
 	info, err := c.Metadata(repo)
 	if err != nil || info == nil {
+		log.Printf("error getting repo info: %v", err)
 		return anon
 	}
 	private := info.IsPrivate()
-	collabs := info.Collabs()
 	if pk != nil {
-		for _, u := range collabs {
-			if u.IsAdmin() {
-				return proto.AdminAccess
-			}
-			for _, k := range u.PublicKeys() {
-				if ssh.KeysEqual(pk, k) {
-					if anon > proto.ReadWriteAccess {
-						return anon
-					}
-					return proto.ReadWriteAccess
-				}
+		isAdmin := c.IsAdmin(pk)
+		if isAdmin {
+			return proto.AdminAccess
+		}
+		isCollab := c.IsCollab(repo, pk)
+		if isCollab {
+			if anon > proto.ReadWriteAccess {
+				return anon
 			}
-			if !private {
-				if anon > proto.ReadOnlyAccess {
-					return anon
-				}
-				return proto.ReadOnlyAccess
+			return proto.ReadWriteAccess
+		}
+		if !private {
+			if anon > proto.ReadOnlyAccess {
+				return anon
 			}
+			return proto.ReadOnlyAccess
 		}
 	}
 	// Don't restrict access to private repos if no users are configured.
@@ -80,3 +135,21 @@ func (c *Config) countUsers() int {
 	}
 	return count
 }
+
+func (c *Config) isInitialAdminKey(key ssh.PublicKey) bool {
+	for _, k := range c.InitialAdminKeys {
+		pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k))
+		if err != nil {
+			log.Printf("error parsing initial admin key: %v", err)
+			continue
+		}
+		if ssh.KeysEqual(key, pk) {
+			return true
+		}
+	}
+	return false
+}
+
+func authorizedKey(key ssh.PublicKey) string {
+	return strings.TrimSpace(string(gossh.MarshalAuthorizedKey(key)))
+}

server/config/config.go 🔗

@@ -7,11 +7,13 @@ import (
 	"net/url"
 	"os"
 	"path/filepath"
+	"strings"
 
 	"github.com/caarlos0/env/v6"
 	"github.com/charmbracelet/soft-serve/proto"
 	"github.com/charmbracelet/soft-serve/server/db"
 	"github.com/charmbracelet/soft-serve/server/db/sqlite"
+	"github.com/gliderlabs/ssh"
 )
 
 // Callbacks provides an interface that can be used to run callbacks on different events.
@@ -33,7 +35,7 @@ type SSHConfig struct {
 	IdleTimeout   int    `env:"IDLE_TIMEOUT" envDefault:"300"`
 }
 
-// GitConfig is the Git protocol configuration for the server.
+// GitConfig is the Git daemon configuration for the server.
 type GitConfig struct {
 	Port           int `env:"PORT" envDefault:"9418"`
 	MaxTimeout     int `env:"MAX_TIMEOUT" envDefault:"0"`
@@ -83,10 +85,11 @@ func (d *DBConfig) URL() *url.URL {
 
 // Config is the configuration for Soft Serve.
 type Config struct {
-	Host string    `env:"HOST" envDefault:"localhost"`
-	SSH  SSHConfig `env:"SSH" envPrefix:"SSH_"`
-	Git  GitConfig `env:"GIT" envPrefix:"GIT_"`
-	Db   DBConfig  `env:"DB" envPrefix:"DB_"`
+	Host string `env:"HOST" envDefault:"localhost"`
+
+	SSH SSHConfig `env:"SSH" envPrefix:"SSH_"`
+	Git GitConfig `env:"GIT" envPrefix:"GIT_"`
+	Db  DBConfig  `env:"DB" envPrefix:"DB_"`
 
 	ServerName string            `env:"SERVER_NAME" envDefault:"Soft Serve"`
 	AnonAccess proto.AccessLevel `env:"ANON_ACCESS" envDefault:"read-only"`
@@ -151,11 +154,30 @@ func DefaultConfig() *Config {
 	if migrateWarn {
 		log.Printf("warning: please run `soft serve migrate` to migrate your server and configuration.")
 	}
+	// initialize admin keys
+	for i, k := range cfg.InitialAdminKeys {
+		if bts, err := os.ReadFile(k); err == nil {
+			// k is a file path, read the file
+			k = string(bts)
+		}
+		pk := strings.TrimSpace(k)
+		if pk == "" {
+			// ignore empty keys
+			continue
+		}
+		if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k)); err != nil {
+			// Fatal if the key is invalid
+			log.Fatalf("invalid initial admin key %q: %v", k, err)
+		}
+		// store the key in the config
+		cfg.InitialAdminKeys[i] = pk
+	}
+	log.Printf("initial admin keys are: %v", cfg.InitialAdminKeys)
 	// init data path and db
 	if err := os.MkdirAll(cfg.RepoPath(), 0755); err != nil {
 		log.Fatalln(err)
 	}
-	if err := cfg.createDefaultConfigRepo(); err != nil {
+	if err := cfg.createDefaultConfigRepoAndUsers(); err != nil {
 		log.Fatalln(err)
 	}
 	var db db.Store

server/config/default.go 🔗

@@ -17,7 +17,7 @@ const (
 	defaultReadme     = "# Soft Serve\n\n Welcome! You can configure your Soft Serve server by cloning this repo and pushing changes.\n\n```\ngit clone ssh://localhost:23231/config\n```"
 )
 
-func (cfg *Config) createDefaultConfigRepo() error {
+func (cfg *Config) createDefaultConfigRepoAndUsers() error {
 	rp := filepath.Join(cfg.RepoPath(), defaultConfigRepo) + ".git"
 	_, err := gogit.PlainOpen(rp)
 	if errors.Is(err, gogit.ErrRepositoryNotExists) {

server/config/repo.go 🔗

@@ -31,6 +31,7 @@ func (c *Config) Open(name string) (proto.Repository, error) {
 	if name == "" {
 		return nil, os.ErrNotExist
 	}
+	name = strings.TrimSuffix(name, ".git")
 	r, err := git.Open(filepath.Join(c.RepoPath(), name+".git"))
 	if err != nil {
 		log.Printf("error opening repository %q: %v", name, err)
@@ -67,6 +68,83 @@ func (c *Config) ListRepos() ([]proto.Metadata, error) {
 	return md, nil
 }
 
+// Create creates a new repository.
+func (c *Config) Create(name string, projectName string, description string, isPrivate bool) error {
+	name = strings.TrimSuffix(name, ".git")
+	name = strings.ToLower(name)
+	if _, err := git.Init(filepath.Join(c.RepoPath(), name+".git"), true); err != nil {
+		return err
+	}
+	if err := c.db.AddRepo(name, projectName, description, isPrivate); err != nil {
+		return err
+	}
+	return nil
+}
+
+// Delete deletes a repository.
+func (c *Config) Delete(name string) error {
+	name = strings.TrimSuffix(name, ".git")
+	if err := os.RemoveAll(filepath.Join(c.RepoPath(), name+".git")); err != nil {
+		return err
+	}
+	if err := c.db.DeleteRepo(name); err != nil {
+		return err
+	}
+	return nil
+}
+
+// Rename renames a repository.
+func (c *Config) Rename(name string, newName string) error {
+	name = strings.TrimSuffix(name, ".git")
+	newName = strings.TrimSuffix(newName, ".git")
+	if err := os.Rename(filepath.Join(c.RepoPath(), name+".git"), filepath.Join(c.RepoPath(), newName+".git")); err != nil {
+		return err
+	}
+	if err := c.db.SetRepoName(name, newName); err != nil {
+		return err
+	}
+	return nil
+}
+
+// SetProjectName sets the repository's project name.
+func (c *Config) SetProjectName(name string, projectName string) error {
+	name = strings.TrimSuffix(name, ".git")
+	if err := c.db.SetRepoProjectName(name, projectName); err != nil {
+		return err
+	}
+	return nil
+}
+
+// SetDescription sets the repository's description.
+func (c *Config) SetDescription(name string, description string) error {
+	name = strings.TrimSuffix(name, ".git")
+	if err := c.db.SetRepoDescription(name, description); err != nil {
+		return err
+	}
+	return nil
+}
+
+// SetPrivate sets the repository's privacy.
+func (c *Config) SetPrivate(name string, isPrivate bool) error {
+	name = strings.TrimSuffix(name, ".git")
+	if err := c.db.SetRepoPrivate(name, isPrivate); err != nil {
+		return err
+	}
+	return nil
+}
+
+// SetDefaultBranch sets the repository's default branch.
+func (c *Config) SetDefaultBranch(name string, branch string) error {
+	re, err := c.Open(name)
+	if err != nil {
+		return err
+	}
+	if _, err = re.Repository().SymbolicRef("HEAD", "refs/heads/"+branch); err != nil {
+		return err
+	}
+	return nil
+}
+
 var _ proto.Metadata = emptyMetadata{}
 
 type emptyMetadata struct {

server/config/user.go 🔗

@@ -18,26 +18,44 @@ type user struct {
 }
 
 func (u *user) Name() string {
+	if u.user == nil {
+		return ""
+	}
 	return u.user.Name
 }
 
 func (u *user) Email() *mail.Address {
+	if u.user == nil {
+		return nil
+	}
 	return u.user.Address()
 }
 
 func (u *user) Login() *string {
+	if u.user == nil {
+		return nil
+	}
 	return u.user.Login
 }
 
 func (u *user) Password() *string {
+	if u.user == nil {
+		return nil
+	}
 	return u.user.Password
 }
 
 func (u *user) IsAdmin() bool {
+	if u.user == nil {
+		return false
+	}
 	return u.user.Admin
 }
 
 func (u *user) PublicKeys() []ssh.PublicKey {
+	if u.user == nil {
+		return nil
+	}
 	keys := u.keys
 	if keys == nil || len(keys) == 0 {
 		ks, err := u.cfg.db.GetUserPublicKeys(u.user)