feat(config): move app config to server

Ayman Bagabas created

Change summary

proto/access.go                  |  29 +++++++
proto/user.go                    |  17 ++++
server/cmd/middleware.go         |  10 +-
server/cmd/middleware_test.go    |  14 +--
server/config/access.go          | 119 ++++++++++++++++++++++++++++++
server/config/config.go          | 111 ++++++++++++++++++++++++---
server/config/user.go            |  41 ++++++++++
server/db/db.go                  |  13 ++-
server/db/sqlite/collabs.go      | 132 ----------------------------------
server/db/sqlite/sql.go          |  19 +++-
server/db/sqlite/sqlite.go       |  69 ++++++++++++++--
server/db/types/repo.go          |   4 
server/git/daemon/daemon.go      |   6 -
server/git/daemon/daemon_test.go |   7 -
server/git/ssh/ssh.go            |   7 -
server/server.go                 |  27 ++++--
16 files changed, 415 insertions(+), 210 deletions(-)

Detailed changes

proto/access.go 🔗

@@ -1,5 +1,12 @@
 package proto
 
+import (
+	"fmt"
+	"strings"
+
+	"github.com/gliderlabs/ssh"
+)
+
 // AccessLevel is the level of access allowed to a repo.
 type AccessLevel int
 
@@ -32,3 +39,25 @@ func (a AccessLevel) String() string {
 		return ""
 	}
 }
+
+// UnmarshalText implements the encoding.TextUnmarshaler interface.
+func (a *AccessLevel) UnmarshalText(text []byte) error {
+	switch strings.ToLower(string(text)) {
+	case "no-access":
+		*a = NoAccess
+	case "read-only":
+		*a = ReadOnlyAccess
+	case "read-write":
+		*a = ReadWriteAccess
+	case "admin-access":
+		*a = AdminAccess
+	default:
+		return fmt.Errorf("invalid access level: %s", text)
+	}
+	return nil
+}
+
+// Access is an interface that defines the access level for repositories.
+type Access interface {
+	AuthRepo(repo string, pk ssh.PublicKey) AccessLevel
+}

proto/user.go 🔗

@@ -0,0 +1,17 @@
+package proto
+
+import (
+	"net/mail"
+
+	"golang.org/x/crypto/ssh"
+)
+
+// User is a user.
+type User interface {
+	Name() string
+	PublicKeys() []ssh.PublicKey
+	Login() *string
+	Email() *mail.Address
+	Password() *string
+	IsAdmin() bool
+}

server/cmd/middleware.go 🔗

@@ -4,13 +4,13 @@ import (
 	"context"
 	"fmt"
 
-	appCfg "github.com/charmbracelet/soft-serve/config"
+	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/wish"
 	"github.com/gliderlabs/ssh"
 )
 
 // Middleware is the Soft Serve middleware that handles SSH commands.
-func Middleware(ac *appCfg.Config) wish.Middleware {
+func Middleware(cfg *config.Config) wish.Middleware {
 	return func(sh ssh.Handler) ssh.Handler {
 		return func(s ssh.Session) {
 			func() {
@@ -18,15 +18,15 @@ func Middleware(ac *appCfg.Config) wish.Middleware {
 				if active {
 					return
 				}
-				ctx := context.WithValue(s.Context(), ConfigCtxKey, ac)
+				ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg)
 				ctx = context.WithValue(ctx, SessionCtxKey, s)
 
 				use := "ssh"
-				port := ac.Port
+				port := cfg.Port
 				if port != 22 {
 					use += fmt.Sprintf(" -p%d", port)
 				}
-				use += fmt.Sprintf(" %s", ac.Host)
+				use += fmt.Sprintf(" %s", cfg.Host)
 				cmd := RootCommand()
 				cmd.Use = use
 				cmd.CompletionOptions.DisableDefaultCmd = true

server/cmd/middleware_test.go 🔗

@@ -4,11 +4,9 @@ import (
 	"os"
 	"testing"
 
-	"github.com/charmbracelet/soft-serve/config"
-	sconfig "github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/wish/testsession"
 	"github.com/gliderlabs/ssh"
-	"github.com/matryer/is"
 )
 
 var ()
@@ -17,17 +15,15 @@ func TestMiddleware(t *testing.T) {
 	t.Cleanup(func() {
 		os.RemoveAll("testmiddleware")
 	})
-	is := is.New(t)
-	appCfg, err := config.NewConfig(&sconfig.Config{
+	cfg := &config.Config{
 		Host: "localhost",
-		SSH: sconfig.SSHConfig{
+		SSH: config.SSHConfig{
 			Port: 22223,
 		},
 		DataPath: "testmiddleware",
-	})
-	is.NoErr(err)
+	}
 	_ = testsession.New(t, &ssh.Server{
-		Handler: Middleware(appCfg)(func(s ssh.Session) {
+		Handler: Middleware(cfg)(func(s ssh.Session) {
 			t.Run("TestCatConfig", func(t *testing.T) {
 				_, err := s.Write([]byte("cat config/config.json"))
 				if err == nil {

server/config/access.go 🔗

@@ -0,0 +1,119 @@
+package config
+
+import (
+	"strings"
+
+	"github.com/charmbracelet/soft-serve/proto"
+	"github.com/gliderlabs/ssh"
+	gossh "golang.org/x/crypto/ssh"
+)
+
+var _ proto.Access = &Config{}
+
+// AuthRepo grants repo authorization to the given key.
+func (c *Config) AuthRepo(repo string, pk ssh.PublicKey) proto.AccessLevel {
+	return c.accessForKey(repo, pk)
+}
+
+// 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 &&
+		c.SSH.AllowPassword && (c.SSH.Password == password)
+}
+
+// KeyboardInteractiveHandler returns whether or not keyboard interactive is allowed.
+func (c *Config) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
+	return (c.AnonAccess != proto.NoAccess) && c.SSH.AllowKeyless
+}
+
+// PublicKeyHandler returns whether or not the given public key may access the
+// repo.
+func (c *Config) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
+	return c.accessForKey("", pk) != proto.NoAccess
+}
+
+// accessForKey returns the access level for the given repo.
+//
+// If repo doesn't exist, then access is based on user's admin privileges, or
+// config.AnonAccess.
+// 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 {
+	anon := c.AnonAccess
+	private := c.isPrivate(repo)
+	// Find user
+	if pk != nil {
+		if u := c.findUser(pk); u != nil {
+			if u.IsAdmin() {
+				return proto.AdminAccess
+			}
+			if c.isCollab(repo, pk) {
+				if anon > proto.ReadWriteAccess {
+					return anon
+				}
+				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.
+	// Return anon access level.
+	if private && c.countUsers() > 0 {
+		return proto.NoAccess
+	}
+	return anon
+}
+
+func (c *Config) countUsers() int {
+	count, err := c.db.CountUsers()
+	if err != nil {
+		return 0
+	}
+	return count
+}
+
+func (c *Config) findUser(pk ssh.PublicKey) proto.User {
+	k := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pk)))
+	u, err := c.DB().GetUserByPublicKey(k)
+	if err != nil {
+		return nil
+	}
+	ks, err := c.DB().GetUserPublicKeys(u)
+	if err != nil {
+		return nil
+	}
+	return &user{user: u, keys: ks}
+}
+
+func (c *Config) findRepo(repo string) proto.Repository {
+	r, err := c.DB().Open(repo)
+	if err != nil {
+		return nil
+	}
+	return r
+}
+
+func (c *Config) isPrivate(repo string) bool {
+	if r := c.findRepo(repo); r != nil {
+		return r.IsPrivate()
+	}
+	return false
+}
+
+func (c *Config) isCollab(repo string, pk ssh.PublicKey) bool {
+	pks, err := c.DB().ListRepoPublicKeys(repo)
+	if err != nil {
+		return false
+	}
+	for _, k := range pks {
+		if ssh.KeysEqual(pk, k) {
+			return true
+		}
+	}
+	return false
+}

server/config/config.go 🔗

@@ -1,10 +1,17 @@
 package config
 
 import (
+	"fmt"
 	"log"
+	"net"
+	"net/url"
+	"os"
 	"path/filepath"
 
 	"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"
 )
 
 // Callbacks provides an interface that can be used to run callbacks on different events.
@@ -16,7 +23,10 @@ type Callbacks interface {
 
 // SSHConfig is the SSH configuration for the server.
 type SSHConfig struct {
-	Port int `env:"PORT" envDefault:"23231"`
+	Port          int    `env:"PORT" envDefault:"23231"`
+	AllowKeyless  bool   `env:"ALLOW_KEYLESS" envDefault:"true"`
+	AllowPassword bool   `env:"ALLOW_PASSWORD" envDefault:"false"`
+	Password      string `env:"PASSWORD"`
 }
 
 // GitConfig is the Git protocol configuration for the server.
@@ -28,13 +38,55 @@ type GitConfig struct {
 	MaxConnections int `env:"SOFT_SERVE_GIT_MAX_CONNECTIONS" envDefault:"32"`
 }
 
+// DBConfig is the database configuration for the server.
+type DBConfig struct {
+	Driver   string `env:"DRIVER" envDefault:"sqlite"`
+	User     string `env:"USER"`
+	Password string `env:"PASSWORD"`
+	Host     string `env:"HOST"`
+	Port     string `env:"PORT"`
+	Name     string `env:"NAME"`
+	SSLMode  bool   `env:"SSL_MODE" envDefault:"false"`
+}
+
+// URL returns a database URL for the configuration.
+func (d *DBConfig) URL() *url.URL {
+	switch d.Driver {
+	case "sqlite":
+		return &url.URL{
+			Scheme: "sqlite",
+			Path:   filepath.Join(d.Name),
+		}
+	default:
+		ssl := "disable"
+		if d.SSLMode {
+			ssl = "require"
+		}
+		var user *url.Userinfo
+		if d.User != "" && d.Password != "" {
+			user = url.UserPassword(d.User, d.Password)
+		} else if d.User != "" {
+			user = url.User(d.User)
+		}
+		return &url.URL{
+			Scheme:   d.Driver,
+			Host:     net.JoinHostPort(d.Host, d.Port),
+			User:     user,
+			Path:     d.Name,
+			RawQuery: fmt.Sprintf("sslmode=%s", ssl),
+		}
+	}
+}
+
 // 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_"`
 
-	DataPath string `env:"DATA_PATH" envDefault:"soft-serve"`
+	AnonAccess proto.AccessLevel `env:"ANON_ACCESS" envDefault:"read-only"`
+	DataPath   string            `env:"DATA_PATH" envDefault:"data"`
 
 	// Deprecated: use SOFT_SERVE_SSH_PORT instead.
 	Port int `env:"PORT"`
@@ -46,14 +98,12 @@ type Config struct {
 	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEY" envSeparator:"\n"`
 	Callbacks        Callbacks
 	ErrorLog         *log.Logger
+
+	db db.Store
 }
 
 // RepoPath returns the path to the repositories.
 func (c *Config) RepoPath() string {
-	if c.ReposPath != "" {
-		log.Printf("warning: SOFT_SERVE_REPO_PATH is deprecated, use SOFT_SERVE_DATA_PATH instead")
-		return c.ReposPath
-	}
 	return filepath.Join(c.DataPath, "repos")
 }
 
@@ -64,27 +114,52 @@ func (c *Config) SSHPath() string {
 
 // PrivateKeyPath returns the path to the SSH key.
 func (c *Config) PrivateKeyPath() string {
-	if c.KeyPath != "" {
-		log.Printf("warning: SOFT_SERVE_KEY_PATH is deprecated, use SOFT_SERVE_DATA_PATH instead")
-		return c.KeyPath
-	}
-	return filepath.Join(c.DataPath, "ssh", "soft_serve")
+	return filepath.Join(c.SSHPath(), "soft_serve")
 }
 
 // DefaultConfig returns a Config with the values populated with the defaults
 // or specified environment variables.
 func DefaultConfig() *Config {
+	var err error
+	var migrateWarn bool
 	cfg := &Config{ErrorLog: log.Default()}
-	if err := env.Parse(cfg, env.Options{
+	if err = env.Parse(cfg, env.Options{
 		Prefix: "SOFT_SERVE_",
 	}); err != nil {
 		log.Fatalln(err)
 	}
 	if cfg.Port != 0 {
-		log.Printf("warning: SOFT_SERVE_PORT is deprecated, use SOFT_SERVE_SSH_PORT instead")
-		cfg.SSH.Port = cfg.Port
+		log.Printf("warning: SOFT_SERVE_PORT is deprecated, use SOFT_SERVE_SSH_PORT instead.")
+		migrateWarn = true
+	}
+	if cfg.KeyPath != "" {
+		log.Printf("warning: SOFT_SERVE_KEY_PATH is deprecated, use SOFT_SERVE_DATA_PATH instead.")
+		migrateWarn = true
 	}
-	return cfg.WithCallbacks(nil)
+	if cfg.ReposPath != "" {
+		log.Printf("warning: SOFT_SERVE_REPO_PATH is deprecated, use SOFT_SERVE_DATA_PATH instead.")
+		migrateWarn = true
+	}
+	if migrateWarn {
+		log.Printf("warning: please run `soft serve --migrate` to migrate your server and configuration.")
+	}
+	var db db.Store
+	switch cfg.Db.Driver {
+	case "sqlite":
+		if err := os.MkdirAll(filepath.Join(cfg.DataPath, "db"), 0755); err != nil {
+			log.Fatalln(err)
+		}
+		db, err = sqlite.New(filepath.Join(cfg.DataPath, "db", "soft-serve.db"))
+		if err != nil {
+			log.Fatalln(err)
+		}
+	}
+	return cfg.WithDB(db)
+}
+
+// DB returns the database for the configuration.
+func (c *Config) DB() db.Store {
+	return c.db
 }
 
 // WithCallbacks applies the given Callbacks to the configuration.
@@ -98,3 +173,9 @@ func (c *Config) WithErrorLogger(logger *log.Logger) *Config {
 	c.ErrorLog = logger
 	return c
 }
+
+// WithDB sets the database for the configuration.
+func (c *Config) WithDB(db db.Store) *Config {
+	c.db = db
+	return c
+}

server/config/user.go 🔗

@@ -0,0 +1,41 @@
+package config
+
+import (
+	"net/mail"
+
+	"github.com/charmbracelet/soft-serve/server/db/types"
+	"golang.org/x/crypto/ssh"
+)
+
+type user struct {
+	user *types.User
+	keys []*types.PublicKey
+}
+
+func (u *user) Name() string {
+	return u.user.Name
+}
+
+func (u *user) Email() *mail.Address {
+	return u.user.Address()
+}
+
+func (u *user) Login() *string {
+	return u.user.Login
+}
+
+func (u *user) Password() *string {
+	return u.user.Password
+}
+
+func (u *user) IsAdmin() bool {
+	return u.user.Admin
+}
+
+func (u *user) PublicKeys() []ssh.PublicKey {
+	ks := make([]ssh.PublicKey, len(u.keys))
+	for i, k := range u.keys {
+		ks[i] = k
+	}
+	return ks
+}

server/db/db.go 🔗

@@ -1,6 +1,7 @@
 package db
 
 import (
+	"github.com/charmbracelet/soft-serve/proto"
 	"github.com/charmbracelet/soft-serve/server/db/types"
 )
 
@@ -29,6 +30,7 @@ type UserStore interface {
 	SetUserEmail(*types.User, string) error
 	SetUserPassword(*types.User, string) error
 	SetUserAdmin(*types.User, bool) error
+	CountUsers() (int, error)
 }
 
 // PublicKeyStore is a public key database storage.
@@ -53,13 +55,16 @@ type RepoStore interface {
 // CollabStore is a collaborator database storage.
 type CollabStore interface {
 	// Collaborators
-	AddRepoCollab(*types.Repo, *types.User) error
+	AddRepoCollab(string, *types.User) error
 	DeleteRepoCollab(int, int) error
-	ListRepoCollabs(*types.Repo) ([]*types.User, error)
+	ListRepoCollabs(string) ([]*types.User, error)
+	ListRepoPublicKeys(string) ([]*types.PublicKey, error)
 }
 
-// DB is a database.
-type DB interface {
+// Store is a database.
+type Store interface {
+	proto.Provider
+
 	ConfigStore
 	UserStore
 	PublicKeyStore

server/db/sqlite/collabs.go 🔗

@@ -1,132 +0,0 @@
-package sqlite
-
-import (
-	"net/mail"
-	"strconv"
-
-	"github.com/charmbracelet/soft-serve/proto"
-	"github.com/charmbracelet/soft-serve/server/db/types"
-	"golang.org/x/crypto/ssh"
-)
-
-var _ proto.CollaboratorService = &Sqlite{}
-
-// AddCollaborator adds a collaborator to a repository.
-func (d *Sqlite) AddCollaborator(repo string, collab proto.Collaborator) error {
-	r, err := d.GetRepo(repo)
-	if err != nil {
-		return err
-	}
-	switch c := collab.(type) {
-	}
-}
-
-// RemoveCollaborator removes a collaborator from a repository.
-func (d *Sqlite) RemoveCollaborator(repo string, collab proto.Collaborator) error {
-	return nil
-}
-
-// ListCollaborators lists the collaborators of a repository.
-func (d *Sqlite) ListCollaborators(repo string) ([]proto.Collaborator, error) {
-	return nil, nil
-}
-
-type publicKey struct {
-	key *types.PublicKey
-}
-
-// PublicKey returns the collaborator's public key.
-func (k publicKey) PublicKey() ssh.PublicKey {
-	pk, err := ssh.ParsePublicKey([]byte(k.key.PublicKey))
-	if err != nil {
-		return nil
-	}
-	return pk
-}
-
-var _ proto.PublicKeyCollaborator = &collaborator{}
-var _ proto.UserLoginCollaborator = &collaborator{}
-var _ proto.EmailCollaborator = &collaborator{}
-
-type collaborator struct {
-	user *types.User
-	keys []*types.PublicKey
-	db   *Sqlite
-}
-
-func (c *collaborator) init() {
-	if c.keys != nil || len(c.keys) > 0 {
-		return
-	}
-	ks, err := c.db.GetUserPublicKeys(c.user)
-	if err != nil {
-		return
-	}
-	c.keys = ks
-}
-
-// Identifier returns the collaborator's identifier.
-func (c *collaborator) Identifier() string {
-	return strconv.Itoa(c.user.ID)
-}
-
-// Name returns the collaborator's name.
-func (c *collaborator) Name() string {
-	return c.user.Name
-}
-
-// String returns the collaborator's username.
-func (c *collaborator) String() string {
-	return c.user.Name
-}
-
-// Marshal implements proto.PublicKeyCollaborator
-func (c *collaborator) Marshal() []byte {
-	c.init()
-	pk := c.keys[0]
-	return pk.Marshal()
-}
-
-// Type implements proto.PublicKeyCollaborator
-func (c *collaborator) Type() string {
-	c.init()
-	pk := c.keys[0]
-	return pk.Type()
-}
-
-// Verify implements proto.PublicKeyCollaborator
-func (c *collaborator) Verify(data []byte, sig *ssh.Signature) error {
-	c.init()
-	pk := c.keys[0]
-	return pk.Verify(data, sig)
-}
-
-// Login implements proto.UserLoginCollaborator
-func (c *collaborator) Login() string {
-	var login string
-	if c.user.Login != nil {
-		login = *c.user.Login
-	}
-	return login
-}
-
-// Address implements proto.EmailCollaborator
-func (c *collaborator) Address() mail.Address {
-	var addr string
-	if c.user.Email != nil {
-		addr = *c.user.Email
-	}
-	return mail.Address{
-		Name:    c.user.Name,
-		Address: addr,
-	}
-}
-
-// Password implements proto.UserLoginCollaborator
-func (c *collaborator) Password() string {
-	var pwd string
-	if c.user.Password != nil {
-		pwd = *c.user.Password
-	}
-	return pwd
-}

server/db/sqlite/sql.go 🔗

@@ -10,8 +10,7 @@ var (
 		allow_keyless BOOLEAN NOT NULL,
 		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 		updated_at DATETIME NOT NULL
-	);
-	`
+	);`
 
 	sqlCreateUserTable = `CREATE TABLE IF NOT EXISTS user (
 		id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -43,8 +42,8 @@ var (
 		project_name TEXT NOT NULL,
 		description TEXT NOT NULL,
 		private BOOLEAN NOT NULL,
-		create_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-		updated_at DATETIME NOT NULL,
+		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+		updated_at DATETIME NOT NULL
 	);`
 
 	sqlCreateCollabTable = `CREATE TABLE IF NOT EXISTS collab (
@@ -85,6 +84,7 @@ var (
 	sqlUpdateUserEmail       = `UPDATE user SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;`
 	sqlUpdateUserPassword    = `UPDATE user SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;`
 	sqlUpdateUserAdmin       = `UPDATE user SET admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;`
+	sqlCountUsers            = `SELECT COUNT(*) FROM user;`
 
 	// Public Key.
 	sqlInsertPublicKey      = `INSERT INTO public_key (user_id, public_key, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP);`
@@ -101,7 +101,12 @@ var (
 	sqlUpdateRepoPrivateByName     = `UPDATE repo SET private = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;`
 
 	// Collab.
-	sqlInsertCollab      = `INSERT INTO collab (user_id, repo_id, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP);`
-	sqlDeleteCollab      = `DELETE FROM collab WHERE user_id = ? AND repo_id;`
-	sqlSelectRepoCollabs = `SELECT user.id, user.name, user.login, user.email, user.admin, user.created_at, user.updated_at FROM user INNER JOIN collab ON user.id = collab.user_id WHERE collab.repo_id = ?;`
+	sqlInsertCollab               = `INSERT INTO collab (user_id, repo_id, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP);`
+	sqlInsertCollabByName         = `INSERT INTO collab (user_id, repo_id, updated_at) VALUES (?, (SELECT id FROM repo WHERE name = ?), CURRENT_TIMESTAMP);`
+	sqlDeleteCollab               = `DELETE FROM collab WHERE user_id = ? AND repo_id = ?;`
+	sqlDeleteCollabByName         = `DELETE FROM collab WHERE user_id = ? AND repo_id = (SELECT id FROM repo WHERE name = ?);`
+	sqlSelectRepoCollabs          = `SELECT user.id, user.name, user.login, user.email, user.admin, user.created_at, user.updated_at FROM user INNER JOIN collab ON user.id = collab.user_id WHERE collab.repo_id = ?;`
+	sqlSelectRepoCollabsByName    = `SELECT user.id, user.name, user.login, user.email, user.admin, user.created_at, user.updated_at FROM user INNER JOIN collab ON user.id = collab.user_id WHERE collab.repo_id = (SELECT id FROM repo WHERE name = ?);`
+	sqlSelectRepoPublicKeys       = `SELECT public_key.id, public_key.user_id, public_key.public_key, public_key.created_at, public_key.updated_at FROM public_key INNER JOIN collab ON public_key.user_id = collab.user_id WHERE collab.repo_id = ?;`
+	sqlSelectRepoPublicKeysByName = `SELECT public_key.id, public_key.user_id, public_key.public_key, public_key.created_at, public_key.updated_at FROM public_key INNER JOIN collab ON public_key.user_id = collab.user_id WHERE collab.repo_id = (SELECT id FROM repo WHERE name = ?);`
 )

server/db/sqlite/sqlite.go 🔗

@@ -3,6 +3,7 @@ package sqlite
 import (
 	"context"
 	"database/sql"
+	"errors"
 	"fmt"
 	"log"
 	"strings"
@@ -14,7 +15,7 @@ import (
 	sqlitelib "modernc.org/sqlite/lib"
 )
 
-var _ db.DB = &Sqlite{}
+var _ db.Store = &Sqlite{}
 
 // Sqlite is a SQLite database.
 type Sqlite struct {
@@ -36,7 +37,7 @@ func New(path string) (*Sqlite, error) {
 		path: path,
 	}
 	if err = d.CreateDB(); err != nil {
-		return nil, err
+		return nil, fmt.Errorf("failed to create db: %w", err)
 	}
 	return d, d.db.Ping()
 }
@@ -49,7 +50,7 @@ func (d *Sqlite) Close() error {
 // CreateDB creates the database and tables.
 func (d *Sqlite) CreateDB() error {
 	return d.wrapTransaction(func(tx *sql.Tx) error {
-		if _, err := tx.Exec(sqlInsertConfig); err != nil {
+		if _, err := tx.Exec(sqlCreateConfigTable); err != nil {
 			return err
 		}
 		if _, err := tx.Exec(sqlCreateUserTable); err != nil {
@@ -273,6 +274,21 @@ func (d *Sqlite) SetUserAdmin(user *types.User, admin bool) error {
 	})
 }
 
+// CountUsers returns the number of users.
+func (d *Sqlite) CountUsers() (int, error) {
+	var count int
+	if err := d.wrapTransaction(func(tx *sql.Tx) error {
+		r := tx.QueryRow(sqlCountUsers)
+		if err := r.Scan(&count); err != nil {
+			return err
+		}
+		return nil
+	}); err != nil {
+		return 0, err
+	}
+	return count, nil
+}
+
 // AddUserPublicKey adds a new user public key.
 func (d *Sqlite) AddUserPublicKey(user *types.User, key string) error {
 	return d.wrapTransaction(func(tx *sql.Tx) error {
@@ -381,9 +397,9 @@ func (d *Sqlite) SetRepoPrivate(name string, private bool) error {
 }
 
 // AddRepoCollab adds a new repo collaborator.
-func (d *Sqlite) AddRepoCollab(repo *types.Repo, user *types.User) error {
+func (d *Sqlite) AddRepoCollab(repo string, user *types.User) error {
 	return d.wrapTransaction(func(tx *sql.Tx) error {
-		_, err := tx.Exec(sqlInsertCollab, repo.ID, user.ID)
+		_, err := tx.Exec(sqlInsertCollabByName, repo, user.ID)
 		return err
 	})
 }
@@ -397,10 +413,10 @@ func (d *Sqlite) DeleteRepoCollab(userID int, repoID int) error {
 }
 
 // ListRepoCollabs returns a list of repo collaborators.
-func (d *Sqlite) ListRepoCollabs(repo *types.Repo) ([]*types.User, error) {
+func (d *Sqlite) ListRepoCollabs(repo string) ([]*types.User, error) {
 	collabs := make([]*types.User, 0)
 	if err := d.wrapTransaction(func(tx *sql.Tx) error {
-		rows, err := tx.Query(sqlSelectRepoCollabs, repo.ID)
+		rows, err := tx.Query(sqlSelectRepoCollabsByName, repo)
 		if err != nil {
 			return err
 		}
@@ -422,6 +438,32 @@ func (d *Sqlite) ListRepoCollabs(repo *types.Repo) ([]*types.User, error) {
 	return collabs, nil
 }
 
+// ListRepoPublicKeys returns a list of repo public keys.
+func (d *Sqlite) ListRepoPublicKeys(repo string) ([]*types.PublicKey, error) {
+	keys := make([]*types.PublicKey, 0)
+	if err := d.wrapTransaction(func(tx *sql.Tx) error {
+		rows, err := tx.Query(sqlSelectRepoPublicKeysByName, repo)
+		if err != nil {
+			return err
+		}
+		if err := rows.Err(); err != nil {
+			return err
+		}
+		defer rows.Close()
+		for rows.Next() {
+			var k types.PublicKey
+			if err := rows.Scan(&k.ID, &k.UserID, &k.PublicKey, &k.CreatedAt, &k.UpdatedAt); err != nil {
+				return err
+			}
+			keys = append(keys, &k)
+		}
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	return keys, nil
+}
+
 // WrapTransaction runs the given function within a transaction.
 func (d *Sqlite) wrapTransaction(f func(tx *sql.Tx) error) error {
 	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
@@ -433,12 +475,17 @@ func (d *Sqlite) wrapTransaction(f func(tx *sql.Tx) error) error {
 	}
 	for {
 		err = f(tx)
-		if err != nil {
+		if err != nil && !errors.Is(err, sql.ErrNoRows) {
 			serr, ok := err.(*sqlite.Error)
-			if ok && serr.Code() == sqlitelib.SQLITE_BUSY {
-				continue
+			if ok {
+				switch serr.Code() {
+				case sqlitelib.SQLITE_BUSY:
+					continue
+				}
+				log.Printf("error in transaction: %d: %s", serr.Code(), serr)
+			} else {
+				log.Printf("error in transaction: %s", err)
 			}
-			log.Printf("error in transaction: %s", err)
 			return err
 		}
 		err = tx.Commit()

server/db/types/repo.go 🔗

@@ -1,6 +1,8 @@
 package types
 
-import "time"
+import (
+	"time"
+)
 
 // Repo is a repository database model.
 type Repo struct {

server/git/daemon/daemon.go 🔗

@@ -23,7 +23,6 @@ var ErrServerClosed = errors.New("git: Server closed")
 
 // Daemon represents a Git daemon.
 type Daemon struct {
-	auth     git.Hooks
 	listener net.Listener
 	addr     string
 	exit     chan struct{}
@@ -34,11 +33,10 @@ type Daemon struct {
 }
 
 // NewDaemon returns a new Git daemon.
-func NewDaemon(cfg *config.Config, auth git.Hooks) (*Daemon, error) {
+func NewDaemon(cfg *config.Config) (*Daemon, error) {
 	addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Git.Port)
 	d := &Daemon{
 		addr:  addr,
-		auth:  auth,
 		exit:  make(chan struct{}),
 		cfg:   cfg,
 		conns: make(map[net.Conn]struct{}),
@@ -159,7 +157,7 @@ func (d *Daemon) handleClient(c net.Conn) {
 	log.Printf("git: connect %s %s %s", c.RemoteAddr(), cmd, repo)
 	defer log.Printf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, repo)
 	repo = strings.TrimPrefix(repo, "/")
-	auth := d.auth.AuthRepo(strings.TrimSuffix(repo, ".git"), nil)
+	auth := d.cfg.AuthRepo(strings.TrimSuffix(repo, ".git"), nil)
 	if auth < proto.ReadOnlyAccess {
 		fatal(c, git.ErrNotAuthed)
 		return

server/git/daemon/daemon_test.go 🔗

@@ -8,7 +8,6 @@ import (
 	"os"
 	"testing"
 
-	appCfg "github.com/charmbracelet/soft-serve/config"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/git"
 	"github.com/go-git/go-git/v5/plumbing/format/pktline"
@@ -35,11 +34,7 @@ func TestMain(m *testing.M) {
 			Port:           9418,
 		},
 	}
-	ac, err := appCfg.NewConfig(cfg)
-	if err != nil {
-		log.Fatal(err)
-	}
-	d, err := NewDaemon(cfg, ac)
+	d, err := NewDaemon(cfg)
 	if err != nil {
 		log.Fatal(err)
 	}

server/git/ssh/ssh.go 🔗

@@ -17,7 +17,7 @@ import (
 // checked for access on a per repo basis for a ssh.Session public key.
 // Hooks.Push and Hooks.Fetch will be called on successful completion of
 // their commands.
-func Middleware(repoDir string, gh git.Hooks) wish.Middleware {
+func Middleware(repoDir string, auth proto.Access) wish.Middleware {
 	return func(sh ssh.Handler) ssh.Handler {
 		return func(s ssh.Session) {
 			func() {
@@ -33,7 +33,7 @@ func Middleware(repoDir string, gh git.Hooks) wish.Middleware {
 						return
 					}
 					pk := s.PublicKey()
-					access := gh.AuthRepo(strings.TrimSuffix(repo, ".git"), pk)
+					access := auth.AuthRepo(strings.TrimSuffix(repo, ".git"), pk)
 					// git bare repositories should end in ".git"
 					// https://git-scm.com/docs/gitrepository-layout
 					if !strings.HasSuffix(repo, ".git") {
@@ -46,8 +46,6 @@ func Middleware(repoDir string, gh git.Hooks) wish.Middleware {
 							err := git.GitPack(s, s, s.Stderr(), gc, repoDir, repo)
 							if err != nil {
 								Fatal(s, git.ErrSystemMalfunction)
-							} else {
-								gh.Push(repo, pk)
 							}
 						default:
 							Fatal(s, git.ErrNotAuthed)
@@ -65,7 +63,6 @@ func Middleware(repoDir string, gh git.Hooks) wish.Middleware {
 							case git.ErrInvalidRepo:
 								Fatal(s, git.ErrInvalidRepo)
 							case nil:
-								gh.Fetch(repo, pk)
 							default:
 								log.Printf("unknown git error: %s", err)
 								Fatal(s, git.ErrSystemMalfunction)

server/server.go 🔗

@@ -33,6 +33,7 @@ type Server struct {
 // restricted to that key. If authKey is not provided, the server will be
 // publicly writable until configured otherwise by cloning the `config` repo.
 func NewServer(cfg *config.Config) *Server {
+	s := &Server{Config: cfg}
 	ac, err := appCfg.NewConfig(cfg)
 	if err != nil {
 		log.Fatal(err)
@@ -43,33 +44,37 @@ func NewServer(cfg *config.Config) *Server {
 			// BubbleTea middleware.
 			bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256),
 			// Command middleware must come after the git middleware.
-			cm.Middleware(ac),
+			cm.Middleware(cfg),
 			// Git middleware.
 			gm.Middleware(cfg.RepoPath(), ac),
 			// Logging middleware must be last to be executed first.
 			lm.Middleware(),
 		),
 	}
-	s, err := wish.NewServer(
-		ssh.PublicKeyAuth(ac.PublicKeyHandler),
-		ssh.KeyboardInteractiveAuth(ac.KeyboardInteractiveHandler),
+
+	opts := []ssh.Option{ssh.PublicKeyAuth(cfg.PublicKeyHandler)}
+	if cfg.SSH.AllowKeyless {
+		opts = append(opts, ssh.KeyboardInteractiveAuth(cfg.KeyboardInteractiveHandler))
+	}
+	if cfg.SSH.AllowPassword {
+		opts = append(opts, ssh.PasswordAuth(cfg.PasswordHandler))
+	}
+	opts = append(opts,
 		wish.WithAddress(fmt.Sprintf("%s:%d", cfg.Host, cfg.SSH.Port)),
 		wish.WithHostKeyPath(cfg.PrivateKeyPath()),
 		wish.WithMiddleware(mw...),
 	)
+	sh, err := wish.NewServer(opts...)
 	if err != nil {
 		log.Fatalln(err)
 	}
-	d, err := daemon.NewDaemon(cfg, ac)
+	s.SSHServer = sh
+	d, err := daemon.NewDaemon(cfg)
 	if err != nil {
 		log.Fatalln(err)
 	}
-	return &Server{
-		SSHServer: s,
-		GitServer: d,
-		Config:    cfg,
-		config:    ac,
-	}
+	s.GitServer = d
+	return s
 }
 
 // Reload reloads the server configuration.