From 33b8de76edf4f2730921fe01a4a1a5d3bab935eb Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 12 Jul 2023 13:12:54 -0400 Subject: [PATCH] refactor: use db module and abstract backend logic --- cmd/soft/hook.go | 30 +- cmd/soft/migrate_config.go | 40 +- cmd/soft/serve.go | 26 + server/backend/access.go | 50 -- server/backend/backend.go | 64 ++- server/backend/cache.go | 35 ++ server/backend/collab.go | 78 +++ server/backend/context.go | 6 +- server/backend/hooks.go | 82 +++- server/backend/repo.go | 553 ++++++++++++++++++--- server/backend/settings.go | 65 ++- server/backend/sqlite/db.go | 90 ---- server/backend/sqlite/error.go | 20 - server/backend/sqlite/hooks.go | 76 --- server/backend/sqlite/repo.go | 202 -------- server/backend/sqlite/sql.go | 61 --- server/backend/sqlite/sqlite.go | 649 ------------------------- server/backend/sqlite/user.go | 365 -------------- server/backend/user.go | 329 +++++++++++-- server/backend/utils.go | 5 +- server/cmd/blob.go | 5 +- server/cmd/branch.go | 17 +- server/cmd/cmd.go | 38 +- server/cmd/collab.go | 15 +- server/cmd/commit.go | 5 +- server/cmd/create.go | 7 +- server/cmd/delete.go | 5 +- server/cmd/description.go | 7 +- server/cmd/hidden.go | 7 +- server/cmd/import.go | 7 +- server/cmd/info.go | 9 +- server/cmd/list.go | 9 +- server/cmd/mirror.go | 5 +- server/cmd/private.go | 7 +- server/cmd/project_name.go | 7 +- server/cmd/pubkey.go | 27 +- server/cmd/rename.go | 5 +- server/cmd/repo.go | 5 +- server/cmd/set_username.go | 7 +- server/cmd/settings.go | 20 +- server/cmd/tag.go | 10 +- server/cmd/tree.go | 5 +- server/cmd/user.go | 53 +- server/config/config.go | 47 +- server/config/file.go | 9 + server/daemon/daemon.go | 13 +- server/daemon/daemon_test.go | 15 +- server/hooks/gen.go | 156 ++++++ server/hooks/hooks.go | 161 +----- server/jobs.go | 7 +- server/server.go | 34 +- server/ssh/session.go | 13 +- server/ssh/session_test.go | 24 +- server/ssh/ssh.go | 35 +- server/sshutils/utils.go | 31 ++ server/ui/common/common.go | 17 +- server/ui/pages/repo/files.go | 4 +- server/ui/pages/repo/log.go | 4 +- server/ui/pages/repo/readme.go | 3 +- server/ui/pages/repo/refs.go | 6 +- server/ui/pages/repo/repo.go | 6 +- server/ui/pages/selection/item.go | 6 +- server/ui/pages/selection/selection.go | 11 +- server/ui/ui.go | 9 +- server/web/git.go | 17 +- server/web/goget.go | 7 +- server/web/http.go | 2 +- 67 files changed, 1627 insertions(+), 2118 deletions(-) delete mode 100644 server/backend/access.go create mode 100644 server/backend/cache.go create mode 100644 server/backend/collab.go delete mode 100644 server/backend/sqlite/db.go delete mode 100644 server/backend/sqlite/error.go delete mode 100644 server/backend/sqlite/hooks.go delete mode 100644 server/backend/sqlite/repo.go delete mode 100644 server/backend/sqlite/sql.go delete mode 100644 server/backend/sqlite/sqlite.go delete mode 100644 server/backend/sqlite/user.go create mode 100644 server/hooks/gen.go create mode 100644 server/sshutils/utils.go diff --git a/cmd/soft/hook.go b/cmd/soft/hook.go index c1f9d4233..175601504 100644 --- a/cmd/soft/hook.go +++ b/cmd/soft/hook.go @@ -13,8 +13,8 @@ import ( "github.com/charmbracelet/log" "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/backend/sqlite" "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/soft-serve/server/db" "github.com/charmbracelet/soft-serve/server/hooks" "github.com/spf13/cobra" ) @@ -49,15 +49,16 @@ var ( logger.SetOutput(f) ctx = log.WithContext(ctx, logger) cmd.SetContext(ctx) - - // Set up the backend - // TODO: support other backends - sb, err := sqlite.NewSqliteBackend(ctx) + db, err := db.Open(cfg.DB.Driver, cfg.DB.DataSource) if err != nil { - return fmt.Errorf("failed to create sqlite backend: %w", err) + return fmt.Errorf("open database: %w", err) } - cfg = cfg.WithBackend(sb) + // Set up the backend + // TODO: support other backends + sb := backend.New(ctx, cfg, db) + ctx = backend.WithContext(ctx, sb) + cmd.SetContext(ctx) return nil }, @@ -69,8 +70,7 @@ var ( hooksRunE = func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - cfg := config.FromContext(ctx) - hks := cfg.Backend.(backend.Hooks) + hks := backend.FromContext(ctx) // This is set in the server before invoking git-receive-pack/git-upload-pack repoName := os.Getenv("SOFT_SERVE_REPO_NAME") @@ -83,7 +83,7 @@ var ( customHookPath := filepath.Join(filepath.Dir(configPath), "hooks", cmdName) var buf bytes.Buffer - opts := make([]backend.HookArg, 0) + opts := make([]hooks.HookArg, 0) switch cmdName { case hooks.PreReceiveHook, hooks.PostReceiveHook: @@ -94,7 +94,7 @@ var ( if len(fields) != 3 { return fmt.Errorf("invalid hook input: %s", scanner.Text()) } - opts = append(opts, backend.HookArg{ + opts = append(opts, hooks.HookArg{ OldSha: fields[0], NewSha: fields[1], RefName: fields[2], @@ -103,22 +103,22 @@ var ( switch cmdName { case hooks.PreReceiveHook: - hks.PreReceive(stdout, stderr, repoName, opts) + hks.PreReceive(ctx, stdout, stderr, repoName, opts) case hooks.PostReceiveHook: - hks.PostReceive(stdout, stderr, repoName, opts) + hks.PostReceive(ctx, stdout, stderr, repoName, opts) } case hooks.UpdateHook: if len(args) != 3 { return fmt.Errorf("invalid update hook input: %s", args) } - hks.Update(stdout, stderr, repoName, backend.HookArg{ + hks.Update(ctx, stdout, stderr, repoName, hooks.HookArg{ OldSha: args[0], NewSha: args[1], RefName: args[2], }) case hooks.PostUpdateHook: - hks.PostUpdate(stdout, stderr, repoName, args...) + hks.PostUpdate(ctx, stdout, stderr, repoName, args...) } // Custom hooks diff --git a/cmd/soft/migrate_config.go b/cmd/soft/migrate_config.go index 6624da972..1330cedf1 100644 --- a/cmd/soft/migrate_config.go +++ b/cmd/soft/migrate_config.go @@ -13,8 +13,10 @@ import ( "github.com/charmbracelet/log" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/backend/sqlite" "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/soft-serve/server/db" + "github.com/charmbracelet/soft-serve/server/sshutils" + "github.com/charmbracelet/soft-serve/server/store" "github.com/charmbracelet/soft-serve/server/utils" gitm "github.com/gogs/git-module" "github.com/spf13/cobra" @@ -39,15 +41,15 @@ var ( bindAddr := os.Getenv("SOFT_SERVE_BIND_ADDRESS") cfg := config.DefaultConfig() ctx = config.WithContext(ctx, cfg) - sb, err := sqlite.NewSqliteBackend(ctx) + db, err := db.Open(cfg.DB.Driver, cfg.DB.DataSource) if err != nil { - return fmt.Errorf("failed to create sqlite backend: %w", err) + return fmt.Errorf("open database: %w", err) } - // FIXME: Admin user gets created when the database is created. - sb.DeleteUser("admin") // nolint: errcheck + sb := backend.New(ctx, cfg, db) - cfg = cfg.WithBackend(sb) + // FIXME: Admin user gets created when the database is created. + sb.DeleteUser(ctx, "admin") // nolint: errcheck // Set SSH listen address logger.Info("Setting SSH listen address...") @@ -127,12 +129,12 @@ var ( // Set server settings logger.Info("Setting server settings...") - if cfg.Backend.SetAllowKeyless(ocfg.AllowKeyless) != nil { + if sb.SetAllowKeyless(ctx, ocfg.AllowKeyless) != nil { fmt.Fprintf(os.Stderr, "failed to set allow keyless\n") } - anon := backend.ParseAccessLevel(ocfg.AnonAccess) + anon := store.ParseAccessLevel(ocfg.AnonAccess) if anon >= 0 { - if err := sb.SetAnonAccess(anon); err != nil { + if err := sb.SetAnonAccess(ctx, anon); err != nil { fmt.Fprintf(os.Stderr, "failed to set anon access: %s\n", err) } } @@ -169,7 +171,7 @@ var ( return fmt.Errorf("failed to copy repo: %w", err) } - if _, err := sb.CreateRepository(dir.Name(), backend.RepositoryOptions{}); err != nil { + if _, err := sb.CreateRepository(ctx, dir.Name(), store.RepositoryOptions{}); err != nil { fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err) } } @@ -231,7 +233,7 @@ var ( } // Create `.soft-serve` repository and add readme - if _, err := sb.CreateRepository(".soft-serve", backend.RepositoryOptions{ + if _, err := sb.CreateRepository(ctx, ".soft-serve", store.RepositoryOptions{ ProjectName: "Home", Description: "Soft Serve home repository", Hidden: true, @@ -252,20 +254,20 @@ var ( r.Private = false } - if err := sb.SetProjectName(repo, name); err != nil { + if err := sb.SetProjectName(ctx, repo, name); err != nil { logger.Errorf("failed to set repo name to %s: %s", repo, err) } - if err := sb.SetDescription(repo, r.Note); err != nil { + if err := sb.SetDescription(ctx, repo, r.Note); err != nil { logger.Errorf("failed to set repo description to %s: %s", repo, err) } - if err := sb.SetPrivate(repo, r.Private); err != nil { + if err := sb.SetPrivate(ctx, repo, r.Private); err != nil { logger.Errorf("failed to set repo private to %s: %s", repo, err) } for _, collab := range r.Collabs { - if err := sb.AddCollaborator(repo, collab); err != nil { + if err := sb.AddCollaborator(ctx, repo, collab); err != nil { logger.Errorf("failed to add repo collab to %s: %s", repo, err) } } @@ -276,11 +278,11 @@ var ( for _, user := range ocfg.Users { keys := make(map[string]ssh.PublicKey) for _, key := range user.PublicKeys { - pk, _, err := backend.ParseAuthorizedKey(key) + pk, _, err := sshutils.ParseAuthorizedKey(key) if err != nil { continue } - ak := backend.MarshalAuthorizedKey(pk) + ak := sshutils.MarshalAuthorizedKey(pk) keys[ak] = pk } @@ -292,7 +294,7 @@ var ( username := strings.ToLower(user.Name) username = strings.ReplaceAll(username, " ", "-") logger.Infof("Creating user %q", username) - if _, err := sb.CreateUser(username, backend.UserOptions{ + if _, err := sb.CreateUser(ctx, username, store.UserOptions{ Admin: user.Admin, PublicKeys: pubkeys, }); err != nil { @@ -300,7 +302,7 @@ var ( } for _, repo := range user.CollabRepos { - if err := sb.AddCollaborator(repo, username); err != nil { + if err := sb.AddCollaborator(ctx, repo, username); err != nil { logger.Errorf("failed to add user collab to %s: %s\n", repo, err) } } diff --git a/cmd/soft/serve.go b/cmd/soft/serve.go index b9a9b2937..760346f80 100644 --- a/cmd/soft/serve.go +++ b/cmd/soft/serve.go @@ -11,10 +11,15 @@ import ( "github.com/charmbracelet/soft-serve/server" "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/soft-serve/server/db" + "github.com/charmbracelet/soft-serve/server/db/migrate" "github.com/spf13/cobra" ) var ( + autoMigrate bool + rollback bool + serveCmd = &cobra.Command{ Use: "serve", Short: "Start the server", @@ -44,6 +49,21 @@ var ( os.MkdirAll(logPath, os.ModePerm) // nolint: errcheck } + db, err := db.Open(cfg.DB.Driver, cfg.DB.DataSource) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + + if rollback { + if err := migrate.Rollback(ctx, db); err != nil { + return fmt.Errorf("rollback error: %w", err) + } + } else if autoMigrate { + if err := migrate.Migrate(ctx, db); err != nil { + return fmt.Errorf("migration error: %w", err) + } + } + s, err := server.NewServer(ctx) if err != nil { return fmt.Errorf("start server: %w", err) @@ -71,3 +91,9 @@ var ( }, } ) + +func init() { + serveCmd.Flags().BoolVarP(&autoMigrate, "auto-migrate", "", false, "automatically run database migrations") + serveCmd.Flags().BoolVarP(&rollback, "rollback", "", false, "rollback the last database migration") + rootCmd.AddCommand(serveCmd) +} diff --git a/server/backend/access.go b/server/backend/access.go deleted file mode 100644 index 8ed8dbfc3..000000000 --- a/server/backend/access.go +++ /dev/null @@ -1,50 +0,0 @@ -package backend - -// AccessLevel is the level of access allowed to a repo. -type AccessLevel int - -const ( - // NoAccess does not allow access to the repo. - NoAccess AccessLevel = iota - - // ReadOnlyAccess allows read-only access to the repo. - ReadOnlyAccess - - // ReadWriteAccess allows read and write access to the repo. - ReadWriteAccess - - // AdminAccess allows read, write, and admin access to the repo. - AdminAccess -) - -// String returns the string representation of the access level. -func (a AccessLevel) String() string { - switch a { - case NoAccess: - return "no-access" - case ReadOnlyAccess: - return "read-only" - case ReadWriteAccess: - return "read-write" - case AdminAccess: - return "admin-access" - default: - return "unknown" - } -} - -// ParseAccessLevel parses an access level string. -func ParseAccessLevel(s string) AccessLevel { - switch s { - case "no-access": - return NoAccess - case "read-only": - return ReadOnlyAccess - case "read-write": - return ReadWriteAccess - case "admin-access": - return AdminAccess - default: - return AccessLevel(-1) - } -} diff --git a/server/backend/backend.go b/server/backend/backend.go index 1aa408b46..2d6104d56 100644 --- a/server/backend/backend.go +++ b/server/backend/backend.go @@ -1,47 +1,41 @@ package backend import ( - "bytes" "context" - "github.com/charmbracelet/ssh" - gossh "golang.org/x/crypto/ssh" + "github.com/charmbracelet/log" + "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/soft-serve/server/db" + "github.com/charmbracelet/soft-serve/server/store" + "github.com/charmbracelet/soft-serve/server/store/database" ) -// Backend is an interface that handles repositories management and any -// non-Git related operations. -type Backend interface { - SettingsBackend - RepositoryStore - RepositoryMetadata - RepositoryAccess - UserStore - UserAccess - Hooks - - // WithContext returns a copy Backend with the given context. - WithContext(ctx context.Context) Backend -} - -// ParseAuthorizedKey parses an authorized key string into a public key. -func ParseAuthorizedKey(ak string) (gossh.PublicKey, string, error) { - pk, c, _, _, err := gossh.ParseAuthorizedKey([]byte(ak)) - return pk, c, err +// Backend is the Soft Serve backend that handles users, repositories, and +// server settings management and operations. +type Backend struct { + ctx context.Context + cfg *config.Config + db *db.DB + store store.Store + logger *log.Logger + cache *cache } -// MarshalAuthorizedKey marshals a public key into an authorized key string. -// -// This is the inverse of ParseAuthorizedKey. -// This function is a copy of ssh.MarshalAuthorizedKey, but without the trailing newline. -// It returns an empty string if pk is nil. -func MarshalAuthorizedKey(pk gossh.PublicKey) string { - if pk == nil { - return "" +// New returns a new Soft Serve backend. +func New(ctx context.Context, cfg *config.Config, db *db.DB) *Backend { + dbstore := database.New(ctx, db) + logger := log.FromContext(ctx).WithPrefix("backend") + b := &Backend{ + ctx: ctx, + cfg: cfg, + db: db, + store: dbstore, + logger: logger, } - return string(bytes.TrimSuffix(gossh.MarshalAuthorizedKey(pk), []byte("\n"))) -} -// KeysEqual returns whether the two public keys are equal. -func KeysEqual(a, b gossh.PublicKey) bool { - return ssh.KeysEqual(a, b) + // TODO: implement a proper caching interface + cache := newCache(b, 1000) + b.cache = cache + + return b } diff --git a/server/backend/cache.go b/server/backend/cache.go new file mode 100644 index 000000000..8d99401dd --- /dev/null +++ b/server/backend/cache.go @@ -0,0 +1,35 @@ +package backend + +import lru "github.com/hashicorp/golang-lru/v2" + +// TODO: implement a caching interface. +type cache struct { + b *Backend + repos *lru.Cache[string, *repo] +} + +func newCache(b *Backend, size int) *cache { + if size <= 0 { + size = 1 + } + c := &cache{b: b} + cache, _ := lru.New[string, *repo](size) + c.repos = cache + return c +} + +func (c *cache) Get(repo string) (*repo, bool) { + return c.repos.Get(repo) +} + +func (c *cache) Set(repo string, r *repo) { + c.repos.Add(repo, r) +} + +func (c *cache) Delete(repo string) { + c.repos.Remove(repo) +} + +func (c *cache) Len() int { + return c.repos.Len() +} diff --git a/server/backend/collab.go b/server/backend/collab.go new file mode 100644 index 000000000..92bfca821 --- /dev/null +++ b/server/backend/collab.go @@ -0,0 +1,78 @@ +package backend + +import ( + "context" + "strings" + + "github.com/charmbracelet/soft-serve/server/db" + "github.com/charmbracelet/soft-serve/server/db/models" + "github.com/charmbracelet/soft-serve/server/utils" +) + +// AddCollaborator adds a collaborator to a repository. +// +// It implements backend.Backend. +func (d *Backend) AddCollaborator(ctx context.Context, repo string, username string) error { + username = strings.ToLower(username) + if err := utils.ValidateUsername(username); err != nil { + return err + } + + repo = utils.SanitizeRepo(repo) + return db.WrapError( + d.db.TransactionContext(ctx, func(tx *db.Tx) error { + return d.store.AddCollabByUsernameAndRepo(ctx, tx, username, repo) + }), + ) +} + +// Collaborators returns a list of collaborators for a repository. +// +// It implements backend.Backend. +func (d *Backend) Collaborators(ctx context.Context, repo string) ([]string, error) { + repo = utils.SanitizeRepo(repo) + var users []models.User + if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + var err error + users, err = d.store.ListCollabsByRepoAsUsers(ctx, tx, repo) + return err + }); err != nil { + return nil, db.WrapError(err) + } + + var usernames []string + for _, u := range users { + usernames = append(usernames, u.Username) + } + + return usernames, nil +} + +// IsCollaborator returns true if the user is a collaborator of the repository. +// +// It implements backend.Backend. +func (d *Backend) IsCollaborator(ctx context.Context, repo string, username string) (bool, error) { + repo = utils.SanitizeRepo(repo) + var m models.Collab + if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + var err error + m, err = d.store.GetCollabByUsernameAndRepo(ctx, tx, username, repo) + return err + }); err != nil { + return false, db.WrapError(err) + } + + return m.ID > 0, nil +} + +// RemoveCollaborator removes a collaborator from a repository. +// +// It implements backend.Backend. +func (d *Backend) RemoveCollaborator(ctx context.Context, repo string, username string) error { + repo = utils.SanitizeRepo(repo) + return db.WrapError( + d.db.TransactionContext(ctx, func(tx *db.Tx) error { + return d.store.RemoveCollabByUsernameAndRepo(ctx, tx, username, repo) + }), + ) +} diff --git a/server/backend/context.go b/server/backend/context.go index 1af19057d..65eada57a 100644 --- a/server/backend/context.go +++ b/server/backend/context.go @@ -5,8 +5,8 @@ import "context" var contextKey = &struct{ string }{"backend"} // FromContext returns the backend from a context. -func FromContext(ctx context.Context) Backend { - if b, ok := ctx.Value(contextKey).(Backend); ok { +func FromContext(ctx context.Context) *Backend { + if b, ok := ctx.Value(contextKey).(*Backend); ok { return b } @@ -14,6 +14,6 @@ func FromContext(ctx context.Context) Backend { } // WithContext returns a new context with the backend attached. -func WithContext(ctx context.Context, b Backend) context.Context { +func WithContext(ctx context.Context, b *Backend) context.Context { return context.WithValue(ctx, contextKey, b) } diff --git a/server/backend/hooks.go b/server/backend/hooks.go index ba130a301..eecdd1873 100644 --- a/server/backend/hooks.go +++ b/server/backend/hooks.go @@ -1,20 +1,80 @@ package backend import ( + "context" "io" + "sync" + + "github.com/charmbracelet/soft-serve/server/hooks" + "github.com/charmbracelet/soft-serve/server/store" ) -// HookArg is an argument to a git hook. -type HookArg struct { - OldSha string - NewSha string - RefName string +var _ hooks.Hooks = (*Backend)(nil) + +// PostReceive is called by the git post-receive hook. +// +// It implements Hooks. +func (d *Backend) PostReceive(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) { + d.logger.Debug("post-receive hook called", "repo", repo, "args", args) +} + +// PreReceive is called by the git pre-receive hook. +// +// It implements Hooks. +func (d *Backend) PreReceive(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) { + d.logger.Debug("pre-receive hook called", "repo", repo, "args", args) } -// Hooks provides an interface for git server-side hooks. -type Hooks interface { - PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg) - Update(stdout io.Writer, stderr io.Writer, repo string, arg HookArg) - PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg) - PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string) +// Update is called by the git update hook. +// +// It implements Hooks. +func (d *Backend) Update(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, arg hooks.HookArg) { + d.logger.Debug("update hook called", "repo", repo, "arg", arg) +} + +// PostUpdate is called by the git post-update hook. +// +// It implements Hooks. +func (d *Backend) PostUpdate(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args ...string) { + d.logger.Debug("post-update hook called", "repo", repo, "args", args) + + var wg sync.WaitGroup + + // Populate last-modified file. + wg.Add(1) + go func() { + defer wg.Done() + if err := populateLastModified(ctx, d, repo); err != nil { + d.logger.Error("error populating last-modified", "repo", repo, "err", err) + return + } + }() + + wg.Wait() +} + +func populateLastModified(ctx context.Context, d *Backend, name string) error { + var rr *repo + _rr, err := d.Repository(ctx, name) + if err != nil { + return err + } + + if r, ok := _rr.(*repo); ok { + rr = r + } else { + return store.ErrRepoNotExist + } + + r, err := rr.Open() + if err != nil { + return err + } + + c, err := r.LatestCommitTime() + if err != nil { + return err + } + + return rr.writeLastModified(c) } diff --git a/server/backend/repo.go b/server/backend/repo.go index 8d7e9cdef..412a02119 100644 --- a/server/backend/repo.go +++ b/server/backend/repo.go @@ -1,86 +1,485 @@ package backend import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" "time" "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/db" + "github.com/charmbracelet/soft-serve/server/db/models" + "github.com/charmbracelet/soft-serve/server/hooks" + "github.com/charmbracelet/soft-serve/server/store" + "github.com/charmbracelet/soft-serve/server/utils" ) -// RepositoryOptions are options for creating a new repository. -type RepositoryOptions struct { - Private bool - Description string - ProjectName string - Mirror bool - Hidden bool -} - -// 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, opts RepositoryOptions) (Repository, error) - // ImportRepository creates a new repository from a Git repository. - ImportRepository(name string, remote string, opts RepositoryOptions) (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 { - // ProjectName returns the repository's project name. - ProjectName(repo string) (string, error) - // SetProjectName sets the repository's project name. - SetProjectName(repo, name string) error - // Description returns the repository's description. - Description(repo string) (string, error) - // SetDescription sets the repository's description. - SetDescription(repo, desc string) error - // IsPrivate returns whether the repository is private. - IsPrivate(repo string) (bool, error) - // SetPrivate sets whether the repository is private. - SetPrivate(repo string, private bool) error - // IsMirror returns whether the repository is a mirror. - IsMirror(repo string) (bool, error) - // IsHidden returns whether the repository is hidden. - IsHidden(repo string) (bool, error) - // SetHidden sets whether the repository is hidden. - SetHidden(repo string, hidden bool) error -} - -// RepositoryAccess is an interface for managing repository access. -type RepositoryAccess interface { - IsCollaborator(repo string, username string) (bool, error) - // AddCollaborator adds the authorized key as a collaborator on the repository. - AddCollaborator(repo string, username string) error - // RemoveCollaborator removes the authorized key as a collaborator on the repository. - RemoveCollaborator(repo string, username string) error - // Collaborators returns a list of all collaborators on the repository. - Collaborators(repo string) ([]string, error) -} - -// Repository is a Git repository interface. -type Repository interface { - // Name returns the repository's name. - Name() string - // ProjectName returns the repository's project name. - ProjectName() string - // Description returns the repository's description. - Description() string - // IsPrivate returns whether the repository is private. - IsPrivate() bool - // IsMirror returns whether the repository is a mirror. - IsMirror() bool - // IsHidden returns whether the repository is hidden. - IsHidden() bool - // UpdatedAt returns the time the repository was last updated. - // If the repository has never been updated, it returns the time it was created. - UpdatedAt() time.Time - // Open returns the underlying git.Repository. - Open() (*git.Repository, error) +func (d *Backend) reposPath() string { + return filepath.Join(d.cfg.DataPath, "repos") +} + +// CreateRepository creates a new repository. +// +// It implements backend.Backend. +func (d *Backend) CreateRepository(ctx context.Context, name string, opts store.RepositoryOptions) (store.Repository, error) { + name = utils.SanitizeRepo(name) + if err := utils.ValidateRepo(name); err != nil { + return nil, err + } + + repo := name + ".git" + rp := filepath.Join(d.reposPath(), repo) + + if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + if err := d.store.CreateRepo( + ctx, + tx, + name, + opts.ProjectName, + opts.Description, + opts.Private, + opts.Hidden, + opts.Mirror, + ); err != nil { + return err + } + + _, err := git.Init(rp, true) + if err != nil { + d.logger.Debug("failed to create repository", "err", err) + return err + } + + return hooks.GenerateHooks(ctx, d.cfg, repo) + }); err != nil { + d.logger.Debug("failed to create repository in database", "err", err) + return nil, db.WrapError(err) + } + + return d.Repository(ctx, name) +} + +// ImportRepository imports a repository from remote. +func (d *Backend) ImportRepository(ctx context.Context, name string, remote string, opts store.RepositoryOptions) (store.Repository, error) { + name = utils.SanitizeRepo(name) + if err := utils.ValidateRepo(name); err != nil { + return nil, err + } + + repo := name + ".git" + rp := filepath.Join(d.reposPath(), repo) + + if _, err := os.Stat(rp); err == nil || os.IsExist(err) { + return nil, store.ErrRepoExist + } + + copts := git.CloneOptions{ + Bare: true, + Mirror: opts.Mirror, + Quiet: true, + CommandOptions: git.CommandOptions{ + Timeout: -1, + Context: ctx, + Envs: []string{ + fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`, + filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"), + d.cfg.SSH.ClientKeyPath, + ), + }, + }, + // Timeout: time.Hour, + } + + if err := git.Clone(remote, rp, copts); err != nil { + d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp) + // Cleanup the mess! + if rerr := os.RemoveAll(rp); rerr != nil { + err = errors.Join(err, rerr) + } + return nil, err + } + + return d.CreateRepository(ctx, name, opts) +} + +// DeleteRepository deletes a repository. +// +// It implements backend.Backend. +func (d *Backend) DeleteRepository(ctx context.Context, name string) error { + name = utils.SanitizeRepo(name) + repo := name + ".git" + rp := filepath.Join(d.reposPath(), repo) + + return d.db.TransactionContext(ctx, func(tx *db.Tx) error { + // Delete repo from cache + defer d.cache.Delete(name) + + if err := d.store.DeleteRepoByName(ctx, tx, name); err != nil { + return err + } + + return os.RemoveAll(rp) + }) +} + +// RenameRepository renames a repository. +// +// It implements backend.Backend. +func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName string) error { + oldName = utils.SanitizeRepo(oldName) + if err := utils.ValidateRepo(oldName); err != nil { + return err + } + + newName = utils.SanitizeRepo(newName) + if err := utils.ValidateRepo(newName); err != nil { + return err + } + oldRepo := oldName + ".git" + newRepo := newName + ".git" + op := filepath.Join(d.reposPath(), oldRepo) + np := filepath.Join(d.reposPath(), newRepo) + if _, err := os.Stat(op); err != nil { + return store.ErrRepoNotExist + } + + if _, err := os.Stat(np); err == nil { + return store.ErrRepoExist + } + + if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + // Delete cache + defer d.cache.Delete(oldName) + + if err := d.store.SetRepoNameByName(ctx, tx, oldName, newName); err != nil { + return err + } + + // Make sure the new repository parent directory exists. + if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil { + return err + } + + return os.Rename(op, np) + }); err != nil { + return db.WrapError(err) + } + + return nil +} + +// Repositories returns a list of repositories per page. +// +// It implements backend.Backend. +func (d *Backend) Repositories(ctx context.Context) ([]store.Repository, error) { + repos := make([]store.Repository, 0) + + d.logger.Debugf("get all repositories %v %v %v", ctx, d.db, d.store) + if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + ms, err := d.store.GetAllRepos(ctx, tx) + if err != nil { + return err + } + + for _, m := range ms { + r := &repo{ + name: m.Name, + path: filepath.Join(d.reposPath(), m.Name+".git"), + repo: m, + } + + // Cache repositories + d.cache.Set(m.Name, r) + + repos = append(repos, r) + } + + return nil + }); err != nil { + return nil, db.WrapError(err) + } + + return repos, nil +} + +// Repository returns a repository by name. +// +// It implements backend.Backend. +func (d *Backend) Repository(ctx context.Context, name string) (store.Repository, error) { + var m models.Repo + name = utils.SanitizeRepo(name) + + if r, ok := d.cache.Get(name); ok && r != nil { + return r, nil + } + + rp := filepath.Join(d.reposPath(), name+".git") + if _, err := os.Stat(rp); err != nil { + return nil, os.ErrNotExist + } + + if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + var err error + m, err = d.store.GetRepoByName(ctx, tx, name) + return err + }); err != nil { + return nil, db.WrapError(err) + } + + r := &repo{ + name: name, + path: rp, + repo: m, + } + + // Add to cache + d.cache.Set(name, r) + + return r, nil +} + +// Description returns the description of a repository. +// +// It implements backend.Backend. +func (d *Backend) Description(ctx context.Context, name string) (string, error) { + name = utils.SanitizeRepo(name) + var desc string + if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + var err error + desc, err = d.store.GetRepoDescriptionByName(ctx, tx, name) + return err + }); err != nil { + return "", db.WrapError(err) + } + + return desc, nil +} + +// IsMirror returns true if the repository is a mirror. +// +// It implements backend.Backend. +func (d *Backend) IsMirror(ctx context.Context, name string) (bool, error) { + name = utils.SanitizeRepo(name) + var mirror bool + if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + var err error + mirror, err = d.store.GetRepoIsMirrorByName(ctx, tx, name) + return err + }); err != nil { + return false, db.WrapError(err) + } + return mirror, nil +} + +// IsPrivate returns true if the repository is private. +// +// It implements backend.Backend. +func (d *Backend) IsPrivate(ctx context.Context, name string) (bool, error) { + name = utils.SanitizeRepo(name) + var private bool + if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + var err error + private, err = d.store.GetRepoIsPrivateByName(ctx, tx, name) + return err + }); err != nil { + return false, db.WrapError(err) + } + + return private, nil +} + +// IsHidden returns true if the repository is hidden. +// +// It implements backend.Backend. +func (d *Backend) IsHidden(ctx context.Context, name string) (bool, error) { + name = utils.SanitizeRepo(name) + var hidden bool + if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + var err error + hidden, err = d.store.GetRepoIsHiddenByName(ctx, tx, name) + return err + }); err != nil { + return false, db.WrapError(err) + } + + return hidden, nil +} + +// ProjectName returns the project name of a repository. +// +// It implements backend.Backend. +func (d *Backend) ProjectName(ctx context.Context, name string) (string, error) { + name = utils.SanitizeRepo(name) + var pname string + if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + var err error + pname, err = d.store.GetRepoProjectNameByName(ctx, tx, name) + return err + }); err != nil { + return "", db.WrapError(err) + } + + return pname, nil +} + +// SetHidden sets the hidden flag of a repository. +// +// It implements backend.Backend. +func (d *Backend) SetHidden(ctx context.Context, name string, hidden bool) error { + name = utils.SanitizeRepo(name) + + // Delete cache + d.cache.Delete(name) + + return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error { + return d.store.SetRepoIsHiddenByName(ctx, tx, name, hidden) + })) +} + +// SetDescription sets the description of a repository. +// +// It implements backend.Backend. +func (d *Backend) SetDescription(ctx context.Context, repo string, desc string) error { + repo = utils.SanitizeRepo(repo) + + // Delete cache + d.cache.Delete(repo) + + return d.db.TransactionContext(ctx, func(tx *db.Tx) error { + return d.store.SetRepoDescriptionByName(ctx, tx, repo, desc) + }) +} + +// SetPrivate sets the private flag of a repository. +// +// It implements backend.Backend. +func (d *Backend) SetPrivate(ctx context.Context, repo string, private bool) error { + repo = utils.SanitizeRepo(repo) + + // Delete cache + d.cache.Delete(repo) + + return db.WrapError( + d.db.TransactionContext(ctx, func(tx *db.Tx) error { + return d.store.SetRepoIsPrivateByName(ctx, tx, repo, private) + }), + ) +} + +// SetProjectName sets the project name of a repository. +// +// It implements backend.Backend. +func (d *Backend) SetProjectName(ctx context.Context, repo string, name string) error { + repo = utils.SanitizeRepo(repo) + + // Delete cache + d.cache.Delete(repo) + + return db.WrapError( + d.db.TransactionContext(ctx, func(tx *db.Tx) error { + return d.store.SetRepoProjectNameByName(ctx, tx, repo, name) + }), + ) +} + +var _ store.Repository = (*repo)(nil) + +// repo is a Git repository with metadata stored in a SQLite database. +type repo struct { + name string + path string + repo models.Repo +} + +// Description returns the repository's description. +// +// It implements backend.Repository. +func (r *repo) Description() string { + return r.repo.Description +} + +// IsMirror returns whether the repository is a mirror. +// +// It implements backend.Repository. +func (r *repo) IsMirror() bool { + return r.repo.Mirror +} + +// IsPrivate returns whether the repository is private. +// +// It implements backend.Repository. +func (r *repo) IsPrivate() bool { + return r.repo.Private +} + +// Name returns the repository's name. +// +// It implements backend.Repository. +func (r *repo) Name() string { + return r.name +} + +// Open opens the repository. +// +// It implements backend.Repository. +func (r *repo) Open() (*git.Repository, error) { + return git.Open(r.path) +} + +// ProjectName returns the repository's project name. +// +// It implements backend.Repository. +func (r *repo) ProjectName() string { + return r.repo.ProjectName +} + +// IsHidden returns whether the repository is hidden. +// +// It implements backend.Repository. +func (r *repo) IsHidden() bool { + return r.repo.Hidden +} + +// UpdatedAt returns the repository's last update time. +func (r *repo) UpdatedAt() time.Time { + // Try to read the last modified time from the info directory. + if t, err := readOneline(filepath.Join(r.path, "info", "last-modified")); err == nil { + if t, err := time.Parse(time.RFC3339, t); err == nil { + return t + } + } + + rr, err := git.Open(r.path) + if err == nil { + t, err := rr.LatestCommitTime() + if err == nil { + return t + } + } + + return r.repo.UpdatedAt +} + +func (r *repo) writeLastModified(t time.Time) error { + fp := filepath.Join(r.path, "info", "last-modified") + if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil { + return err + } + + return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm) +} + +func readOneline(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + + defer f.Close() // nolint: errcheck + s := bufio.NewScanner(f) + s.Scan() + return s.Text(), s.Err() } diff --git a/server/backend/settings.go b/server/backend/settings.go index c3e8a7902..2322640b4 100644 --- a/server/backend/settings.go +++ b/server/backend/settings.go @@ -1,13 +1,58 @@ package backend -// SettingsBackend is an interface that handles server configuration. -type SettingsBackend interface { - // 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 +import ( + "context" + + "github.com/charmbracelet/soft-serve/server/db" + "github.com/charmbracelet/soft-serve/server/store" +) + +// AllowKeyless returns whether or not keyless access is allowed. +// +// It implements backend.Backend. +func (b *Backend) AllowKeyless(ctx context.Context) bool { + var allow bool + if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error { + var err error + allow, err = b.store.GetAllowKeylessAccess(ctx, tx) + return err + }); err != nil { + return false + } + + return allow +} + +// SetAllowKeyless sets whether or not keyless access is allowed. +// +// It implements backend.Backend. +func (b *Backend) SetAllowKeyless(ctx context.Context, allow bool) error { + return b.db.TransactionContext(ctx, func(tx *db.Tx) error { + return b.store.SetAllowKeylessAccess(ctx, tx, allow) + }) +} + +// AnonAccess returns the level of anonymous access. +// +// It implements backend.Backend. +func (b *Backend) AnonAccess(ctx context.Context) store.AccessLevel { + var level store.AccessLevel + if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error { + var err error + level, err = b.store.GetAnonAccess(ctx, tx) + return err + }); err != nil { + return store.NoAccess + } + + return level +} + +// SetAnonAccess sets the level of anonymous access. +// +// It implements backend.Backend. +func (b *Backend) SetAnonAccess(ctx context.Context, level store.AccessLevel) error { + return b.db.TransactionContext(ctx, func(tx *db.Tx) error { + return b.store.SetAnonAccess(ctx, tx, level) + }) } diff --git a/server/backend/sqlite/db.go b/server/backend/sqlite/db.go deleted file mode 100644 index 65ed6cbe3..000000000 --- a/server/backend/sqlite/db.go +++ /dev/null @@ -1,90 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "errors" - - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/db" -) - -// Close closes the database. -func (d *SqliteBackend) Close() error { - return d.db.Close() -} - -// init creates the database. -func (d *SqliteBackend) init() error { - return d.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - if _, err := tx.Exec(sqlCreateSettingsTable); err != nil { - return err - } - if _, err := tx.Exec(sqlCreateUserTable); err != nil { - return err - } - if _, err := tx.Exec(sqlCreatePublicKeyTable); err != nil { - return err - } - if _, err := tx.Exec(sqlCreateRepoTable); err != nil { - return err - } - if _, err := tx.Exec(sqlCreateCollabTable); err != nil { - return err - } - - // Set default settings. - if _, err := tx.Exec("INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", "allow_keyless", true); err != nil { - return err - } - if _, err := tx.Exec("INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", "anon_access", backend.ReadOnlyAccess.String()); err != nil { - return err - } - - var init bool - if err := tx.Get(&init, "SELECT value FROM settings WHERE key = 'init'"); err != nil && !errors.Is(err, sql.ErrNoRows) { - return err - } - - // Create default user. - if !init { - r, err := tx.Exec("INSERT OR IGNORE INTO user (username, admin, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP);", "admin", true) - if err != nil { - return err - } - userID, err := r.LastInsertId() - if err != nil { - return err - } - - // Add initial keys - // Don't use cfg.AdminKeys since it also includes the internal key - // used for internal api access. - for _, k := range d.cfg.InitialAdminKeys { - pk, _, err := backend.ParseAuthorizedKey(k) - if err != nil { - d.logger.Error("error parsing initial admin key, skipping", "key", k, "err", err) - continue - } - - stmt, err := tx.Prepare(`INSERT INTO public_key (user_id, public_key, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP);`) - if err != nil { - return err - } - - defer stmt.Close() // nolint: errcheck - if _, err := stmt.Exec(userID, backend.MarshalAuthorizedKey(pk)); err != nil { - return err - } - } - } - - // set init flag - if _, err := tx.Exec("INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", "init", true); err != nil { - return err - } - - return nil - }) -} diff --git a/server/backend/sqlite/error.go b/server/backend/sqlite/error.go deleted file mode 100644 index 8476f640c..000000000 --- a/server/backend/sqlite/error.go +++ /dev/null @@ -1,20 +0,0 @@ -package sqlite - -import ( - "errors" - "fmt" -) - -var ( - // ErrDuplicateKey is returned when a unique constraint is violated. - ErrDuplicateKey = errors.New("record already exists") - - // ErrNoRecord is returned when a record is not found. - ErrNoRecord = errors.New("record not found") - - // ErrRepoNotExist is returned when a repository does not exist. - ErrRepoNotExist = fmt.Errorf("repository does not exist") - - // ErrRepoExist is returned when a repository already exists. - ErrRepoExist = fmt.Errorf("repository already exists") -) diff --git a/server/backend/sqlite/hooks.go b/server/backend/sqlite/hooks.go deleted file mode 100644 index 972b3f31d..000000000 --- a/server/backend/sqlite/hooks.go +++ /dev/null @@ -1,76 +0,0 @@ -package sqlite - -import ( - "io" - "sync" - - "github.com/charmbracelet/soft-serve/server/backend" -) - -// PostReceive is called by the git post-receive hook. -// -// It implements Hooks. -func (d *SqliteBackend) PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []backend.HookArg) { - d.logger.Debug("post-receive hook called", "repo", repo, "args", args) -} - -// PreReceive is called by the git pre-receive hook. -// -// It implements Hooks. -func (d *SqliteBackend) PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []backend.HookArg) { - d.logger.Debug("pre-receive hook called", "repo", repo, "args", args) -} - -// Update is called by the git update hook. -// -// It implements Hooks. -func (d *SqliteBackend) Update(stdout io.Writer, stderr io.Writer, repo string, arg backend.HookArg) { - d.logger.Debug("update hook called", "repo", repo, "arg", arg) -} - -// PostUpdate is called by the git post-update hook. -// -// It implements Hooks. -func (d *SqliteBackend) PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string) { - d.logger.Debug("post-update hook called", "repo", repo, "args", args) - - var wg sync.WaitGroup - - // Populate last-modified file. - wg.Add(1) - go func() { - defer wg.Done() - if err := populateLastModified(d, repo); err != nil { - d.logger.Error("error populating last-modified", "repo", repo, "err", err) - return - } - }() - - wg.Wait() -} - -func populateLastModified(d *SqliteBackend, repo string) error { - var rr *Repo - _rr, err := d.Repository(repo) - if err != nil { - return err - } - - if r, ok := _rr.(*Repo); ok { - rr = r - } else { - return ErrRepoNotExist - } - - r, err := rr.Open() - if err != nil { - return err - } - - c, err := r.LatestCommitTime() - if err != nil { - return err - } - - return rr.writeLastModified(c) -} diff --git a/server/backend/sqlite/repo.go b/server/backend/sqlite/repo.go deleted file mode 100644 index 0a8080163..000000000 --- a/server/backend/sqlite/repo.go +++ /dev/null @@ -1,202 +0,0 @@ -package sqlite - -import ( - "bufio" - "context" - "os" - "path/filepath" - "sync" - "time" - - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/db" -) - -var _ backend.Repository = (*Repo)(nil) - -// Repo is a Git repository with metadata stored in a SQLite database. -type Repo struct { - name string - path string - db *db.DB - - // cache - // updatedAt is cached in "last-modified" file. - mu sync.Mutex - desc *string - projectName *string - isMirror *bool - isPrivate *bool - isHidden *bool -} - -// Description returns the repository's description. -// -// It implements backend.Repository. -func (r *Repo) Description() string { - r.mu.Lock() - defer r.mu.Unlock() - if r.desc != nil { - return *r.desc - } - - var desc string - if err := r.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - return tx.Get(&desc, "SELECT description FROM repo WHERE name = ?", r.name) - }); err != nil { - return "" - } - - r.desc = &desc - return desc -} - -// IsMirror returns whether the repository is a mirror. -// -// It implements backend.Repository. -func (r *Repo) IsMirror() bool { - r.mu.Lock() - defer r.mu.Unlock() - if r.isMirror != nil { - return *r.isMirror - } - - var mirror bool - if err := r.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - return tx.Get(&mirror, "SELECT mirror FROM repo WHERE name = ?", r.name) - }); err != nil { - return false - } - - r.isMirror = &mirror - return mirror -} - -// IsPrivate returns whether the repository is private. -// -// It implements backend.Repository. -func (r *Repo) IsPrivate() bool { - r.mu.Lock() - defer r.mu.Unlock() - if r.isPrivate != nil { - return *r.isPrivate - } - - var private bool - if err := r.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - return tx.Get(&private, "SELECT private FROM repo WHERE name = ?", r.name) - }); err != nil { - return false - } - - r.isPrivate = &private - return private -} - -// Name returns the repository's name. -// -// It implements backend.Repository. -func (r *Repo) Name() string { - return r.name -} - -// Open opens the repository. -// -// It implements backend.Repository. -func (r *Repo) Open() (*git.Repository, error) { - return git.Open(r.path) -} - -// ProjectName returns the repository's project name. -// -// It implements backend.Repository. -func (r *Repo) ProjectName() string { - r.mu.Lock() - defer r.mu.Unlock() - if r.projectName != nil { - return *r.projectName - } - - var name string - if err := r.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - return tx.Get(&name, "SELECT project_name FROM repo WHERE name = ?", r.name) - }); err != nil { - return "" - } - - r.projectName = &name - return name -} - -// IsHidden returns whether the repository is hidden. -// -// It implements backend.Repository. -func (r *Repo) IsHidden() bool { - r.mu.Lock() - defer r.mu.Unlock() - if r.isHidden != nil { - return *r.isHidden - } - - var hidden bool - if err := r.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - return tx.Get(&hidden, "SELECT hidden FROM repo WHERE name = ?", r.name) - }); err != nil { - return false - } - - r.isHidden = &hidden - return hidden -} - -// UpdatedAt returns the repository's last update time. -func (r *Repo) UpdatedAt() time.Time { - var updatedAt time.Time - - // Try to read the last modified time from the info directory. - if t, err := readOneline(filepath.Join(r.path, "info", "last-modified")); err == nil { - if t, err := time.Parse(time.RFC3339, t); err == nil { - return t - } - } - - rr, err := git.Open(r.path) - if err == nil { - t, err := rr.LatestCommitTime() - if err == nil { - updatedAt = t - } - } - - if updatedAt.IsZero() { - if err := r.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - return tx.Get(&updatedAt, "SELECT updated_at FROM repo WHERE name = ?", r.name) - }); err != nil { - return time.Time{} - } - } - - return updatedAt -} - -func (r *Repo) writeLastModified(t time.Time) error { - fp := filepath.Join(r.path, "info", "last-modified") - if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil { - return err - } - - return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm) -} - -func readOneline(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - - defer f.Close() // nolint: errcheck - s := bufio.NewScanner(f) - s.Scan() - return s.Text(), s.Err() -} diff --git a/server/backend/sqlite/sql.go b/server/backend/sqlite/sql.go deleted file mode 100644 index 34edd17f7..000000000 --- a/server/backend/sqlite/sql.go +++ /dev/null @@ -1,61 +0,0 @@ -package sqlite - -var ( - sqlCreateSettingsTable = `CREATE TABLE IF NOT EXISTS settings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - key TEXT NOT NULL UNIQUE, - value TEXT 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, - username TEXT UNIQUE, - admin BOOLEAN NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL - );` - - sqlCreatePublicKeyTable = `CREATE TABLE IF NOT EXISTS public_key ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - public_key TEXT NOT NULL UNIQUE, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL, - UNIQUE (user_id, public_key), - CONSTRAINT user_id_fk - FOREIGN KEY(user_id) REFERENCES user(id) - ON DELETE CASCADE - ON UPDATE CASCADE - );` - - sqlCreateRepoTable = `CREATE TABLE IF NOT EXISTS repo ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - project_name TEXT NOT NULL, - description TEXT NOT NULL, - private BOOLEAN NOT NULL, - mirror BOOLEAN NOT NULL, - hidden BOOLEAN NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL - );` - - sqlCreateCollabTable = `CREATE TABLE IF NOT EXISTS collab ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - repo_id INTEGER NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL, - UNIQUE (user_id, repo_id), - CONSTRAINT user_id_fk - FOREIGN KEY(user_id) REFERENCES user(id) - ON DELETE CASCADE - ON UPDATE CASCADE, - CONSTRAINT repo_id_fk - FOREIGN KEY(repo_id) REFERENCES repo(id) - ON DELETE CASCADE - ON UPDATE CASCADE - );` -) diff --git a/server/backend/sqlite/sqlite.go b/server/backend/sqlite/sqlite.go deleted file mode 100644 index 292b6d89e..000000000 --- a/server/backend/sqlite/sqlite.go +++ /dev/null @@ -1,649 +0,0 @@ -package sqlite - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/hooks" - "github.com/charmbracelet/soft-serve/server/utils" - lru "github.com/hashicorp/golang-lru/v2" - _ "modernc.org/sqlite" // sqlite driver -) - -// SqliteBackend is a backend that uses a SQLite database as a Soft Serve -// backend. -type SqliteBackend struct { //nolint: revive - cfg *config.Config - ctx context.Context - dp string - db *db.DB - logger *log.Logger - - // Repositories cache - cache *cache -} - -var _ backend.Backend = (*SqliteBackend)(nil) - -func (d *SqliteBackend) reposPath() string { - return filepath.Join(d.dp, "repos") -} - -// NewSqliteBackend creates a new SqliteBackend. -func NewSqliteBackend(ctx context.Context) (*SqliteBackend, error) { - cfg := config.FromContext(ctx) - dataPath := cfg.DataPath - if err := os.MkdirAll(dataPath, os.ModePerm); err != nil { - return nil, err - } - - db, err := db.Open("sqlite", filepath.Join(dataPath, "soft-serve.db"+ - "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")) - if err != nil { - return nil, err - } - - d := &SqliteBackend{ - cfg: cfg, - ctx: ctx, - dp: dataPath, - db: db, - logger: log.FromContext(ctx).WithPrefix("sqlite"), - } - - // Set up LRU cache with size 1000 - d.cache = newCache(d, 1000) - - if err := d.init(); err != nil { - return nil, err - } - - if err := d.db.Ping(); err != nil { - return nil, err - } - - return d, d.initRepos() -} - -// WithContext returns a copy of SqliteBackend with the given context. -func (d SqliteBackend) WithContext(ctx context.Context) backend.Backend { - d.ctx = ctx - return &d -} - -// AllowKeyless returns whether or not keyless access is allowed. -// -// It implements backend.Backend. -func (d *SqliteBackend) AllowKeyless() bool { - var allow bool - if err := d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - return tx.Get(&allow, "SELECT value FROM settings WHERE key = ?;", "allow_keyless") - }); err != nil { - return false - } - - return allow -} - -// AnonAccess returns the level of anonymous access. -// -// It implements backend.Backend. -func (d *SqliteBackend) AnonAccess() backend.AccessLevel { - var level string - if err := d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - return tx.Get(&level, "SELECT value FROM settings WHERE key = ?;", "anon_access") - }); err != nil { - return backend.NoAccess - } - - return backend.ParseAccessLevel(level) -} - -// SetAllowKeyless sets whether or not keyless access is allowed. -// -// It implements backend.Backend. -func (d *SqliteBackend) SetAllowKeyless(allow bool) error { - return db.WrapError( - d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - _, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", allow, "allow_keyless") - return err - }), - ) -} - -// SetAnonAccess sets the level of anonymous access. -// -// It implements backend.Backend. -func (d *SqliteBackend) SetAnonAccess(level backend.AccessLevel) error { - return db.WrapError( - d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - _, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", level.String(), "anon_access") - return err - }), - ) -} - -// CreateRepository creates a new repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) CreateRepository(name string, opts backend.RepositoryOptions) (backend.Repository, error) { - name = utils.SanitizeRepo(name) - if err := utils.ValidateRepo(name); err != nil { - return nil, err - } - - repo := name + ".git" - rp := filepath.Join(d.reposPath(), repo) - - if err := d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - if _, err := tx.Exec(`INSERT INTO repo (name, project_name, description, private, mirror, hidden, updated_at) - VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`, - name, opts.ProjectName, opts.Description, opts.Private, opts.Mirror, opts.Hidden); err != nil { - return err - } - - _, err := git.Init(rp, true) - if err != nil { - d.logger.Debug("failed to create repository", "err", err) - return err - } - - return nil - }); err != nil { - d.logger.Debug("failed to create repository in database", "err", err) - return nil, db.WrapError(err) - } - - r := &Repo{ - name: name, - path: rp, - db: d.db, - } - - // Set cache - d.cache.Set(name, r) - - return r, d.initRepo(name) -} - -// ImportRepository imports a repository from remote. -func (d *SqliteBackend) ImportRepository(name string, remote string, opts backend.RepositoryOptions) (backend.Repository, error) { - name = utils.SanitizeRepo(name) - if err := utils.ValidateRepo(name); err != nil { - return nil, err - } - - repo := name + ".git" - rp := filepath.Join(d.reposPath(), repo) - - if _, err := os.Stat(rp); err == nil || os.IsExist(err) { - return nil, ErrRepoExist - } - - copts := git.CloneOptions{ - Bare: true, - Mirror: opts.Mirror, - Quiet: true, - CommandOptions: git.CommandOptions{ - Timeout: -1, - Context: d.ctx, - Envs: []string{ - fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`, - filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"), - d.cfg.SSH.ClientKeyPath, - ), - }, - }, - // Timeout: time.Hour, - } - - if err := git.Clone(remote, rp, copts); err != nil { - d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp) - // Cleanup the mess! - if rerr := os.RemoveAll(rp); rerr != nil { - err = errors.Join(err, rerr) - } - return nil, err - } - - return d.CreateRepository(name, opts) -} - -// DeleteRepository deletes a repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) DeleteRepository(name string) error { - name = utils.SanitizeRepo(name) - repo := name + ".git" - rp := filepath.Join(d.reposPath(), repo) - - return d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - // Delete repo from cache - defer d.cache.Delete(name) - - if _, err := tx.Exec("DELETE FROM repo WHERE name = ?;", name); err != nil { - return err - } - - return os.RemoveAll(rp) - }) -} - -// RenameRepository renames a repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) RenameRepository(oldName string, newName string) error { - oldName = utils.SanitizeRepo(oldName) - if err := utils.ValidateRepo(oldName); err != nil { - return err - } - - newName = utils.SanitizeRepo(newName) - if err := utils.ValidateRepo(newName); err != nil { - return err - } - oldRepo := oldName + ".git" - newRepo := newName + ".git" - op := filepath.Join(d.reposPath(), oldRepo) - np := filepath.Join(d.reposPath(), newRepo) - if _, err := os.Stat(op); err != nil { - return ErrRepoNotExist - } - - if _, err := os.Stat(np); err == nil { - return ErrRepoExist - } - - if err := d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - // Delete cache - defer d.cache.Delete(oldName) - - _, err := tx.Exec("UPDATE repo SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", newName, oldName) - if err != nil { - return err - } - - // Make sure the new repository parent directory exists. - if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil { - return err - } - - if err := os.Rename(op, np); err != nil { - return err - } - - return nil - }); err != nil { - return db.WrapError(err) - } - - return nil -} - -// Repositories returns a list of all repositories. -// -// It implements backend.Backend. -func (d *SqliteBackend) Repositories() ([]backend.Repository, error) { - repos := make([]backend.Repository, 0) - - if err := d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - rows, err := tx.Query("SELECT name FROM repo") - if err != nil { - return err - } - - defer rows.Close() // nolint: errcheck - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - return err - } - - if r, ok := d.cache.Get(name); ok && r != nil { - repos = append(repos, r) - continue - } - - r := &Repo{ - name: name, - path: filepath.Join(d.reposPath(), name+".git"), - db: d.db, - } - - // Cache repositories - d.cache.Set(name, r) - - repos = append(repos, r) - } - - return nil - }); err != nil { - return nil, db.WrapError(err) - } - - return repos, nil -} - -// Repository returns a repository by name. -// -// It implements backend.Backend. -func (d *SqliteBackend) Repository(repo string) (backend.Repository, error) { - repo = utils.SanitizeRepo(repo) - - if r, ok := d.cache.Get(repo); ok && r != nil { - return r, nil - } - - rp := filepath.Join(d.reposPath(), repo+".git") - if _, err := os.Stat(rp); err != nil { - return nil, os.ErrNotExist - } - - var count int - if err := d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - return tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo) - }); err != nil { - return nil, db.WrapError(err) - } - - if count == 0 { - d.logger.Warn("repository exists but not found in database", "repo", repo) - return nil, ErrRepoNotExist - } - - r := &Repo{ - name: repo, - path: rp, - db: d.db, - } - - // Add to cache - d.cache.Set(repo, r) - - return r, nil -} - -// Description returns the description of a repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) Description(repo string) (string, error) { - r, err := d.Repository(repo) - if err != nil { - return "", err - } - - return r.Description(), nil -} - -// IsMirror returns true if the repository is a mirror. -// -// It implements backend.Backend. -func (d *SqliteBackend) IsMirror(repo string) (bool, error) { - r, err := d.Repository(repo) - if err != nil { - return false, err - } - - return r.IsMirror(), nil -} - -// IsPrivate returns true if the repository is private. -// -// It implements backend.Backend. -func (d *SqliteBackend) IsPrivate(repo string) (bool, error) { - r, err := d.Repository(repo) - if err != nil { - return false, err - } - - return r.IsPrivate(), nil -} - -// IsHidden returns true if the repository is hidden. -// -// It implements backend.Backend. -func (d *SqliteBackend) IsHidden(repo string) (bool, error) { - r, err := d.Repository(repo) - if err != nil { - return false, err - } - - return r.IsHidden(), nil -} - -// SetHidden sets the hidden flag of a repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) SetHidden(repo string, hidden bool) error { - repo = utils.SanitizeRepo(repo) - - // Delete cache - d.cache.Delete(repo) - - return db.WrapError(d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - var count int - if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil { - return err - } - if count == 0 { - return ErrRepoNotExist - } - _, err := tx.Exec("UPDATE repo SET hidden = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", hidden, repo) - return err - })) -} - -// ProjectName returns the project name of a repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) ProjectName(repo string) (string, error) { - r, err := d.Repository(repo) - if err != nil { - return "", err - } - - return r.ProjectName(), nil -} - -// SetDescription sets the description of a repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) SetDescription(repo string, desc string) error { - repo = utils.SanitizeRepo(repo) - - // Delete cache - d.cache.Delete(repo) - - return d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - var count int - if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil { - return err - } - if count == 0 { - return ErrRepoNotExist - } - _, err := tx.Exec("UPDATE repo SET description = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?", desc, repo) - return err - }) -} - -// SetPrivate sets the private flag of a repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) SetPrivate(repo string, private bool) error { - repo = utils.SanitizeRepo(repo) - - // Delete cache - d.cache.Delete(repo) - - return db.WrapError( - d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - var count int - if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil { - return err - } - if count == 0 { - return ErrRepoNotExist - } - _, err := tx.Exec("UPDATE repo SET private = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?", private, repo) - return err - }), - ) -} - -// SetProjectName sets the project name of a repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) SetProjectName(repo string, name string) error { - repo = utils.SanitizeRepo(repo) - - // Delete cache - d.cache.Delete(repo) - - return db.WrapError( - d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - var count int - if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil { - return err - } - if count == 0 { - return ErrRepoNotExist - } - _, err := tx.Exec("UPDATE repo SET project_name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?", name, repo) - return err - }), - ) -} - -// AddCollaborator adds a collaborator to a repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) AddCollaborator(repo string, username string) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - repo = utils.SanitizeRepo(repo) - return db.WrapError(d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - _, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at) - VALUES ( - (SELECT id FROM user WHERE username = ?), - (SELECT id FROM repo WHERE name = ?), - CURRENT_TIMESTAMP - );`, username, repo) - return err - }), - ) -} - -// Collaborators returns a list of collaborators for a repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) Collaborators(repo string) ([]string, error) { - repo = utils.SanitizeRepo(repo) - var users []string - if err := d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - return tx.Select(&users, `SELECT user.username FROM user - INNER JOIN collab ON user.id = collab.user_id - INNER JOIN repo ON repo.id = collab.repo_id - WHERE repo.name = ?`, repo) - }); err != nil { - return nil, db.WrapError(err) - } - - return users, nil -} - -// IsCollaborator returns true if the user is a collaborator of the repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) IsCollaborator(repo string, username string) (bool, error) { - repo = utils.SanitizeRepo(repo) - var count int - if err := d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - return tx.Get(&count, `SELECT COUNT(*) FROM user - INNER JOIN collab ON user.id = collab.user_id - INNER JOIN repo ON repo.id = collab.repo_id - WHERE repo.name = ? AND user.username = ?`, repo, username) - }); err != nil { - return false, db.WrapError(err) - } - - return count > 0, nil -} - -// RemoveCollaborator removes a collaborator from a repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error { - repo = utils.SanitizeRepo(repo) - return db.WrapError( - d.db.TransactionContext(d.ctx, func(tx *db.Tx) error { - _, err := tx.Exec(`DELETE FROM collab - WHERE user_id = (SELECT id FROM user WHERE username = ?) - AND repo_id = (SELECT id FROM repo WHERE name = ?)`, username, repo) - return err - }), - ) -} - -func (d *SqliteBackend) initRepo(repo string) error { - return hooks.GenerateHooks(d.ctx, d.cfg, repo) -} - -func (d *SqliteBackend) initRepos() error { - repos, err := d.Repositories() - if err != nil { - return err - } - - for _, repo := range repos { - if err := d.initRepo(repo.Name()); err != nil { - return err - } - } - - return nil -} - -// TODO: implement a caching interface. -type cache struct { - b *SqliteBackend - repos *lru.Cache[string, *Repo] -} - -func newCache(b *SqliteBackend, size int) *cache { - if size <= 0 { - size = 1 - } - c := &cache{b: b} - cache, _ := lru.New[string, *Repo](size) - c.repos = cache - return c -} - -func (c *cache) Get(repo string) (*Repo, bool) { - return c.repos.Get(repo) -} - -func (c *cache) Set(repo string, r *Repo) { - c.repos.Add(repo, r) -} - -func (c *cache) Delete(repo string) { - c.repos.Remove(repo) -} - -func (c *cache) Len() int { - return c.repos.Len() -} diff --git a/server/backend/sqlite/user.go b/server/backend/sqlite/user.go deleted file mode 100644 index 51d6e6fc1..000000000 --- a/server/backend/sqlite/user.go +++ /dev/null @@ -1,365 +0,0 @@ -package sqlite - -import ( - "context" - "strings" - - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/utils" - "golang.org/x/crypto/ssh" -) - -// User represents a user. -type User struct { - username string - db *db.DB -} - -var _ backend.User = (*User)(nil) - -// IsAdmin returns whether the user is an admin. -// -// It implements backend.User. -func (u *User) IsAdmin() bool { - var admin bool - if err := u.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - return tx.Get(&admin, "SELECT admin FROM user WHERE username = ?", u.username) - }); err != nil { - return false - } - - return admin -} - -// PublicKeys returns the user's public keys. -// -// It implements backend.User. -func (u *User) PublicKeys() []ssh.PublicKey { - var keys []ssh.PublicKey - if err := u.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - var keyStrings []string - if err := tx.Select(&keyStrings, `SELECT public_key - FROM public_key - INNER JOIN user ON user.id = public_key.user_id - WHERE user.username = ? - ORDER BY public_key.id asc;`, u.username); err != nil { - return err - } - - for _, keyString := range keyStrings { - key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyString)) - if err != nil { - return err - } - keys = append(keys, key) - } - - return nil - }); err != nil { - return nil - } - - return keys -} - -// Username returns the user's username. -// -// It implements backend.User. -func (u *User) Username() string { - return u.username -} - -// AccessLevel returns the access level of a user for a repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) AccessLevel(repo string, username string) backend.AccessLevel { - anon := d.AnonAccess() - user, _ := d.User(username) - // If the user is an admin, they have admin access. - if user != nil && user.IsAdmin() { - return backend.AdminAccess - } - - // If the repository exists, check if the user is a collaborator. - r, _ := d.Repository(repo) - if r != nil { - // If the user is a collaborator, they have read/write access. - isCollab, _ := d.IsCollaborator(repo, username) - if isCollab { - if anon > backend.ReadWriteAccess { - return anon - } - return backend.ReadWriteAccess - } - - // If the repository is private, the user has no access. - if r.IsPrivate() { - return backend.NoAccess - } - - // Otherwise, the user has read-only access. - return backend.ReadOnlyAccess - } - - if user != nil { - // If the repository doesn't exist, the user has read/write access. - if anon > backend.ReadWriteAccess { - return anon - } - - return backend.ReadWriteAccess - } - - // If the user doesn't exist, give them the anonymous access level. - return anon -} - -// AccessLevelByPublicKey returns the access level of a user's public key for a repository. -// -// It implements backend.Backend. -func (d *SqliteBackend) AccessLevelByPublicKey(repo string, pk ssh.PublicKey) backend.AccessLevel { - for _, k := range d.cfg.AdminKeys() { - if backend.KeysEqual(pk, k) { - return backend.AdminAccess - } - } - - user, _ := d.UserByPublicKey(pk) - if user != nil { - return d.AccessLevel(repo, user.Username()) - } - - return d.AccessLevel(repo, "") -} - -// AddPublicKey adds a public key to a user. -// -// It implements backend.Backend. -func (d *SqliteBackend) AddPublicKey(username string, pk ssh.PublicKey) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - return db.WrapError( - d.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - var userID int - if err := tx.Get(&userID, "SELECT id FROM user WHERE username = ?", username); err != nil { - return err - } - - _, err := tx.Exec(`INSERT INTO public_key (user_id, public_key, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP);`, userID, backend.MarshalAuthorizedKey(pk)) - return err - }), - ) -} - -// CreateUser creates a new user. -// -// It implements backend.Backend. -func (d *SqliteBackend) CreateUser(username string, opts backend.UserOptions) (backend.User, error) { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return nil, err - } - - var user *User - if err := d.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - stmt, err := tx.Prepare("INSERT INTO user (username, admin, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP);") - if err != nil { - return err - } - - defer stmt.Close() // nolint: errcheck - r, err := stmt.Exec(username, opts.Admin) - if err != nil { - return err - } - - if len(opts.PublicKeys) > 0 { - userID, err := r.LastInsertId() - if err != nil { - d.logger.Error("error getting last insert id") - return err - } - - for _, pk := range opts.PublicKeys { - stmt, err := tx.Prepare(`INSERT INTO public_key (user_id, public_key, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP);`) - if err != nil { - return err - } - - defer stmt.Close() // nolint: errcheck - if _, err := stmt.Exec(userID, backend.MarshalAuthorizedKey(pk)); err != nil { - return err - } - } - } - - user = &User{ - db: d.db, - username: username, - } - return nil - }); err != nil { - return nil, db.WrapError(err) - } - - return user, nil -} - -// DeleteUser deletes a user. -// -// It implements backend.Backend. -func (d *SqliteBackend) DeleteUser(username string) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - return db.WrapError( - d.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - _, err := tx.Exec("DELETE FROM user WHERE username = ?", username) - return err - }), - ) -} - -// RemovePublicKey removes a public key from a user. -// -// It implements backend.Backend. -func (d *SqliteBackend) RemovePublicKey(username string, pk ssh.PublicKey) error { - return db.WrapError( - d.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - _, err := tx.Exec(`DELETE FROM public_key - WHERE user_id = (SELECT id FROM user WHERE username = ?) - AND public_key = ?;`, username, backend.MarshalAuthorizedKey(pk)) - return err - }), - ) -} - -// ListPublicKeys lists the public keys of a user. -func (d *SqliteBackend) ListPublicKeys(username string) ([]ssh.PublicKey, error) { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return nil, err - } - - keys := make([]ssh.PublicKey, 0) - if err := d.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - var keyStrings []string - if err := tx.Select(&keyStrings, `SELECT public_key - FROM public_key - INNER JOIN user ON user.id = public_key.user_id - WHERE user.username = ?;`, username); err != nil { - return err - } - - for _, keyString := range keyStrings { - key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyString)) - if err != nil { - return err - } - keys = append(keys, key) - } - - return nil - }); err != nil { - return nil, db.WrapError(err) - } - - return keys, nil -} - -// SetUsername sets the username of a user. -// -// It implements backend.Backend. -func (d *SqliteBackend) SetUsername(username string, newUsername string) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - return db.WrapError( - d.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - _, err := tx.Exec("UPDATE user SET username = ? WHERE username = ?", newUsername, username) - return err - }), - ) -} - -// SetAdmin sets the admin flag of a user. -// -// It implements backend.Backend. -func (d *SqliteBackend) SetAdmin(username string, admin bool) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - return db.WrapError( - d.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - _, err := tx.Exec("UPDATE user SET admin = ? WHERE username = ?", admin, username) - return err - }), - ) -} - -// User finds a user by username. -// -// It implements backend.Backend. -func (d *SqliteBackend) User(username string) (backend.User, error) { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return nil, err - } - - if err := d.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - return tx.Get(&username, "SELECT username FROM user WHERE username = ?", username) - }); err != nil { - return nil, db.WrapError(err) - } - - return &User{ - db: d.db, - username: username, - }, nil -} - -// UserByPublicKey finds a user by public key. -// -// It implements backend.Backend. -func (d *SqliteBackend) UserByPublicKey(pk ssh.PublicKey) (backend.User, error) { - var username string - if err := d.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - return tx.Get(&username, `SELECT user.username - FROM public_key - INNER JOIN user ON user.id = public_key.user_id - WHERE public_key.public_key = ?;`, backend.MarshalAuthorizedKey(pk)) - }); err != nil { - return nil, db.WrapError(err) - } - - return &User{ - db: d.db, - username: username, - }, nil -} - -// Users returns all users. -// -// It implements backend.Backend. -func (d *SqliteBackend) Users() ([]string, error) { - var users []string - if err := d.db.TransactionContext(context.Background(), func(tx *db.Tx) error { - return tx.Select(&users, "SELECT username FROM user") - }); err != nil { - return nil, db.WrapError(err) - } - - return users, nil -} diff --git a/server/backend/user.go b/server/backend/user.go index b0b5f1115..df54640a8 100644 --- a/server/backend/user.go +++ b/server/backend/user.go @@ -1,55 +1,288 @@ package backend import ( + "context" + "strings" + + "github.com/charmbracelet/soft-serve/server/db" + "github.com/charmbracelet/soft-serve/server/db/models" + "github.com/charmbracelet/soft-serve/server/sshutils" + "github.com/charmbracelet/soft-serve/server/store" + "github.com/charmbracelet/soft-serve/server/utils" "golang.org/x/crypto/ssh" ) -// User is an interface representing a user. -type User interface { - // Username returns the user's username. - Username() string - // IsAdmin returns whether the user is an admin. - IsAdmin() bool - // PublicKeys returns the user's public keys. - PublicKeys() []ssh.PublicKey -} - -// UserAccess is an interface that handles user access to repositories. -type UserAccess interface { - // AccessLevel returns the access level of the username to the repository. - AccessLevel(repo string, username string) AccessLevel - // AccessLevelByPublicKey returns the access level of the public key to the repository. - AccessLevelByPublicKey(repo string, pk ssh.PublicKey) AccessLevel -} - -// UserStore is an interface for managing users. -type UserStore interface { - // User finds the given user. - User(username string) (User, error) - // UserByPublicKey finds the user with the given public key. - UserByPublicKey(pk ssh.PublicKey) (User, error) - // Users returns a list of all users. - Users() ([]string, error) - // CreateUser creates a new user. - CreateUser(username string, opts UserOptions) (User, error) - // DeleteUser deletes a user. - DeleteUser(username string) error - // SetUsername sets the username of the user. - SetUsername(oldUsername string, newUsername string) error - // SetAdmin sets whether the user is an admin. - SetAdmin(username string, admin bool) error - // AddPublicKey adds a public key to the user. - AddPublicKey(username string, pk ssh.PublicKey) error - // RemovePublicKey removes a public key from the user. - RemovePublicKey(username string, pk ssh.PublicKey) error - // ListPublicKeys lists the public keys of the user. - ListPublicKeys(username string) ([]ssh.PublicKey, error) -} - -// UserOptions are options for creating a user. -type UserOptions struct { - // Admin is whether the user is an admin. - Admin bool - // PublicKeys are the user's public keys. - PublicKeys []ssh.PublicKey +// AccessLevel returns the access level of a user for a repository. +// +// It implements backend.Backend. +func (d *Backend) AccessLevel(ctx context.Context, repo string, username string) store.AccessLevel { + anon := d.AnonAccess(ctx) + user, _ := d.User(ctx, username) + // If the user is an admin, they have admin access. + if user != nil && user.IsAdmin() { + return store.AdminAccess + } + + // If the repository exists, check if the user is a collaborator. + r, _ := d.Repository(ctx, repo) + if r != nil { + // If the user is a collaborator, they have read/write access. + isCollab, _ := d.IsCollaborator(ctx, repo, username) + if isCollab { + if anon > store.ReadWriteAccess { + return anon + } + return store.ReadWriteAccess + } + + // If the repository is private, the user has no access. + if r.IsPrivate() { + return store.NoAccess + } + + // Otherwise, the user has read-only access. + return store.ReadOnlyAccess + } + + if user != nil { + // If the repository doesn't exist, the user has read/write access. + if anon > store.ReadWriteAccess { + return anon + } + + return store.ReadWriteAccess + } + + // If the user doesn't exist, give them the anonymous access level. + return anon +} + +// AccessLevelByPublicKey returns the access level of a user's public key for a repository. +// +// It implements backend.Backend. +func (d *Backend) AccessLevelByPublicKey(ctx context.Context, repo string, pk ssh.PublicKey) store.AccessLevel { + for _, k := range d.cfg.AdminKeys() { + if sshutils.KeysEqual(pk, k) { + return store.AdminAccess + } + } + + user, _ := d.UserByPublicKey(ctx, pk) + if user != nil { + return d.AccessLevel(ctx, repo, user.Username()) + } + + return d.AccessLevel(ctx, repo, "") +} + +// User finds a user by username. +// +// It implements backend.Backend. +func (d *Backend) User(ctx context.Context, username string) (store.User, error) { + username = strings.ToLower(username) + if err := utils.ValidateUsername(username); err != nil { + return nil, err + } + + var m models.User + var pks []ssh.PublicKey + if err := d.db.TransactionContext(context.Background(), func(tx *db.Tx) error { + var err error + m, err = d.store.FindUserByUsername(ctx, tx, username) + if err != nil { + return err + } + + pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID) + return err + }); err != nil { + return nil, db.WrapError(err) + } + + return &user{ + user: m, + publicKeys: pks, + }, nil +} + +// UserByPublicKey finds a user by public key. +// +// It implements backend.Backend. +func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (store.User, error) { + var m models.User + var pks []ssh.PublicKey + if err := d.db.TransactionContext(context.Background(), func(tx *db.Tx) error { + var err error + m, err = d.store.FindUserByPublicKey(ctx, tx, pk) + if err != nil { + return err + } + + pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID) + return err + }); err != nil { + return nil, db.WrapError(err) + } + + return &user{ + user: m, + publicKeys: pks, + }, nil +} + +// Users returns all users. +// +// It implements backend.Backend. +func (d *Backend) Users(ctx context.Context) ([]string, error) { + var users []string + if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + ms, err := d.store.GetAllUsers(ctx, tx) + if err != nil { + return err + } + + for _, m := range ms { + users = append(users, m.Username) + } + + return nil + }); err != nil { + return nil, db.WrapError(err) + } + + return users, nil +} + +// AddPublicKey adds a public key to a user. +// +// It implements backend.Backend. +func (d *Backend) AddPublicKey(ctx context.Context, username string, pk ssh.PublicKey) error { + username = strings.ToLower(username) + if err := utils.ValidateUsername(username); err != nil { + return err + } + + return db.WrapError( + d.db.TransactionContext(ctx, func(tx *db.Tx) error { + return d.store.AddPublicKeyByUsername(ctx, tx, username, pk) + }), + ) +} + +// CreateUser creates a new user. +// +// It implements backend.Backend. +func (d *Backend) CreateUser(ctx context.Context, username string, opts store.UserOptions) (store.User, error) { + username = strings.ToLower(username) + if err := utils.ValidateUsername(username); err != nil { + return nil, err + } + + if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + return d.store.CreateUser(ctx, tx, username, opts.Admin, opts.PublicKeys) + }); err != nil { + return nil, db.WrapError(err) + } + + return d.User(ctx, username) +} + +// DeleteUser deletes a user. +// +// It implements backend.Backend. +func (d *Backend) DeleteUser(ctx context.Context, username string) error { + username = strings.ToLower(username) + if err := utils.ValidateUsername(username); err != nil { + return err + } + + return db.WrapError( + d.db.TransactionContext(ctx, func(tx *db.Tx) error { + return d.store.DeleteUserByUsername(ctx, tx, username) + }), + ) +} + +// RemovePublicKey removes a public key from a user. +// +// It implements backend.Backend. +func (d *Backend) RemovePublicKey(ctx context.Context, username string, pk ssh.PublicKey) error { + return db.WrapError( + d.db.TransactionContext(ctx, func(tx *db.Tx) error { + return d.store.RemovePublicKeyByUsername(ctx, tx, username, pk) + }), + ) +} + +// ListPublicKeys lists the public keys of a user. +func (d *Backend) ListPublicKeys(ctx context.Context, username string) ([]ssh.PublicKey, error) { + username = strings.ToLower(username) + if err := utils.ValidateUsername(username); err != nil { + return nil, err + } + + var keys []ssh.PublicKey + if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + var err error + keys, err = d.store.ListPublicKeysByUsername(ctx, tx, username) + return err + }); err != nil { + return nil, db.WrapError(err) + } + + return keys, nil +} + +// SetUsername sets the username of a user. +// +// It implements backend.Backend. +func (d *Backend) SetUsername(ctx context.Context, username string, newUsername string) error { + username = strings.ToLower(username) + if err := utils.ValidateUsername(username); err != nil { + return err + } + + return db.WrapError( + d.db.TransactionContext(ctx, func(tx *db.Tx) error { + return d.store.SetUsernameByUsername(ctx, tx, username, newUsername) + }), + ) +} + +// SetAdmin sets the admin flag of a user. +// +// It implements backend.Backend. +func (d *Backend) SetAdmin(ctx context.Context, username string, admin bool) error { + username = strings.ToLower(username) + if err := utils.ValidateUsername(username); err != nil { + return err + } + + return db.WrapError( + d.db.TransactionContext(ctx, func(tx *db.Tx) error { + return d.store.SetAdminByUsername(ctx, tx, username, admin) + }), + ) +} + +type user struct { + user models.User + publicKeys []ssh.PublicKey +} + +var _ store.User = (*user)(nil) + +// IsAdmin implements store.User +func (u *user) IsAdmin() bool { + return u.user.Admin +} + +// PublicKeys implements store.User +func (u *user) PublicKeys() []ssh.PublicKey { + return u.publicKeys +} + +// Username implements store.User +func (u *user) Username() string { + return u.user.Username } diff --git a/server/backend/utils.go b/server/backend/utils.go index 03d3cccda..0693f86fb 100644 --- a/server/backend/utils.go +++ b/server/backend/utils.go @@ -2,11 +2,12 @@ package backend import ( "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/store" ) // LatestFile returns the contents of the latest file at the specified path in // the repository and its file path. -func LatestFile(r Repository, pattern string) (string, string, error) { +func LatestFile(r store.Repository, pattern string) (string, string, error) { repo, err := r.Open() if err != nil { return "", "", err @@ -15,7 +16,7 @@ func LatestFile(r Repository, pattern string) (string, string, error) { } // Readme returns the repository's README. -func Readme(r Repository) (readme string, path string, err error) { +func Readme(r store.Repository) (readme string, path string, err error) { pattern := "[rR][eE][aA][dD][mM][eE]*" readme, path, err = LatestFile(r, pattern) return diff --git a/server/cmd/blob.go b/server/cmd/blob.go index d1388fb5f..aad08959f 100644 --- a/server/cmd/blob.go +++ b/server/cmd/blob.go @@ -34,7 +34,8 @@ func blobCommand() *cobra.Command { Args: cobra.RangeArgs(1, 3), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) rn := args[0] ref := "" fp := "" @@ -46,7 +47,7 @@ func blobCommand() *cobra.Command { fp = args[2] } - repo, err := cfg.Backend.Repository(rn) + repo, err := be.Repository(ctx, rn) if err != nil { return err } diff --git a/server/cmd/branch.go b/server/cmd/branch.go index 0b79eccee..fc3e87175 100644 --- a/server/cmd/branch.go +++ b/server/cmd/branch.go @@ -31,9 +31,10 @@ func branchListCommand() *cobra.Command { Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) rn := strings.TrimSuffix(args[0], ".git") - rr, err := cfg.Backend.Repository(rn) + rr, err := be.Repository(ctx, rn) if err != nil { return err } @@ -61,14 +62,15 @@ func branchDefaultCommand() *cobra.Command { Short: "Set or get the default branch", Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) rn := strings.TrimSuffix(args[0], ".git") switch len(args) { case 1: if err := checkIfReadable(cmd, args); err != nil { return err } - rr, err := cfg.Backend.Repository(rn) + rr, err := be.Repository(ctx, rn) if err != nil { return err } @@ -89,7 +91,7 @@ func branchDefaultCommand() *cobra.Command { return err } - rr, err := cfg.Backend.Repository(rn) + rr, err := be.Repository(ctx, rn) if err != nil { return err } @@ -132,9 +134,10 @@ func branchDeleteCommand() *cobra.Command { Short: "Delete a branch", PersistentPreRunE: checkIfCollab, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) rn := strings.TrimSuffix(args[0], ".git") - rr, err := cfg.Backend.Repository(rn) + rr, err := be.Repository(ctx, rn) if err != nil { return err } diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go index 16d89d163..d263e0b09 100644 --- a/server/cmd/cmd.go +++ b/server/cmd/cmd.go @@ -12,6 +12,8 @@ import ( "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/soft-serve/server/errors" + "github.com/charmbracelet/soft-serve/server/sshutils" + "github.com/charmbracelet/soft-serve/server/store" "github.com/charmbracelet/soft-serve/server/utils" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" @@ -90,7 +92,7 @@ func cmdName(args []string) string { } // rootCommand is the root command for the server. -func rootCommand(cfg *config.Config, s ssh.Session) *cobra.Command { +func rootCommand(be *backend.Backend, cfg *config.Config, s ssh.Session) *cobra.Command { cliCommandCounter.WithLabelValues(cmdName(s.Command())).Inc() rootCmd := &cobra.Command{ Short: "Soft Serve is a self-hostable Git server for the command line.", @@ -129,7 +131,7 @@ func rootCommand(cfg *config.Config, s ssh.Session) *cobra.Command { repoCommand(), ) - user, _ := cfg.Backend.UserByPublicKey(s.PublicKey()) + user, _ := be.UserByPublicKey(s.Context(), s.PublicKey()) isAdmin := isPublicKeyAdmin(cfg, s.PublicKey()) || (user != nil && user.IsAdmin()) if user != nil || isAdmin { if isAdmin { @@ -149,11 +151,12 @@ func rootCommand(cfg *config.Config, s ssh.Session) *cobra.Command { return rootCmd } -func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) { +func fromContext(cmd *cobra.Command) (*config.Config, *backend.Backend, ssh.Session) { ctx := cmd.Context() cfg := config.FromContext(ctx) + be := backend.FromContext(ctx) s := ctx.Value(sessionCtxKey).(ssh.Session) - return cfg, s + return cfg, be, s } func checkIfReadable(cmd *cobra.Command, args []string) error { @@ -161,10 +164,10 @@ func checkIfReadable(cmd *cobra.Command, args []string) error { if len(args) > 0 { repo = args[0] } - cfg, s := fromContext(cmd) + _, be, s := fromContext(cmd) rn := utils.SanitizeRepo(repo) - auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey()) - if auth < backend.ReadOnlyAccess { + auth := be.AccessLevelByPublicKey(cmd.Context(), rn, s.PublicKey()) + if auth < store.ReadOnlyAccess { return errors.ErrUnauthorized } return nil @@ -172,7 +175,7 @@ func checkIfReadable(cmd *cobra.Command, args []string) error { func isPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool { for _, k := range cfg.AdminKeys() { - if backend.KeysEqual(pk, k) { + if sshutils.KeysEqual(pk, k) { return true } } @@ -180,12 +183,13 @@ func isPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool { } func checkIfAdmin(cmd *cobra.Command, _ []string) error { - cfg, s := fromContext(cmd) + ctx := cmd.Context() + cfg, be, s := fromContext(cmd) if isPublicKeyAdmin(cfg, s.PublicKey()) { return nil } - user, _ := cfg.Backend.UserByPublicKey(s.PublicKey()) + user, _ := be.UserByPublicKey(ctx, s.PublicKey()) if user == nil { return errors.ErrUnauthorized } @@ -202,17 +206,19 @@ func checkIfCollab(cmd *cobra.Command, args []string) error { if len(args) > 0 { repo = args[0] } - cfg, s := fromContext(cmd) + + ctx := cmd.Context() + _, be, s := fromContext(cmd) rn := utils.SanitizeRepo(repo) - auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey()) - if auth < backend.ReadWriteAccess { + auth := be.AccessLevelByPublicKey(ctx, rn, s.PublicKey()) + if auth < store.ReadWriteAccess { return errors.ErrUnauthorized } return nil } // Middleware is the Soft Serve middleware that handles SSH commands. -func Middleware(cfg *config.Config, logger *log.Logger) wish.Middleware { +func Middleware(be *backend.Backend, cfg *config.Config, logger *log.Logger) wish.Middleware { return func(sh ssh.Handler) ssh.Handler { return func(s ssh.Session) { func() { @@ -236,13 +242,11 @@ func Middleware(cfg *config.Config, logger *log.Logger) wish.Middleware { var ctx context.Context = s.Context() scfg := *cfg cfg = &scfg - be := cfg.Backend.WithContext(ctx) - cfg.Backend = be ctx = config.WithContext(ctx, cfg) ctx = backend.WithContext(ctx, be) ctx = context.WithValue(ctx, sessionCtxKey, s) - rootCmd := rootCommand(cfg, s) + rootCmd := rootCommand(be, cfg, s) rootCmd.SetArgs(args) if len(args) == 0 { // otherwise it'll default to os.Args, which is not what we want. diff --git a/server/cmd/collab.go b/server/cmd/collab.go index 08baf5a06..0cadf87a8 100644 --- a/server/cmd/collab.go +++ b/server/cmd/collab.go @@ -27,11 +27,12 @@ func collabAddCommand() *cobra.Command { Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfCollab, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) repo := args[0] username := args[1] - return cfg.Backend.AddCollaborator(repo, username) + return be.AddCollaborator(ctx, repo, username) }, } @@ -45,11 +46,12 @@ func collabRemoveCommand() *cobra.Command { Short: "Remove a collaborator from a repo", PersistentPreRunE: checkIfCollab, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) repo := args[0] username := args[1] - return cfg.Backend.RemoveCollaborator(repo, username) + return be.RemoveCollaborator(ctx, repo, username) }, } @@ -63,9 +65,10 @@ func collabListCommand() *cobra.Command { Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfCollab, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) repo := args[0] - collabs, err := cfg.Backend.Collaborators(repo) + collabs, err := be.Collaborators(ctx, repo) if err != nil { return err } diff --git a/server/cmd/commit.go b/server/cmd/commit.go index 9b96f9103..1701e592a 100644 --- a/server/cmd/commit.go +++ b/server/cmd/commit.go @@ -24,11 +24,12 @@ func commitCommand() *cobra.Command { Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) repoName := args[0] commitSHA := args[1] - rr, err := cfg.Backend.Repository(repoName) + rr, err := be.Repository(ctx, repoName) if err != nil { return err } diff --git a/server/cmd/create.go b/server/cmd/create.go index 0407ce6d9..53d81fe13 100644 --- a/server/cmd/create.go +++ b/server/cmd/create.go @@ -1,7 +1,7 @@ package cmd import ( - "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/store" "github.com/spf13/cobra" ) @@ -18,9 +18,10 @@ func createCommand() *cobra.Command { Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfCollab, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) name := args[0] - if _, err := cfg.Backend.CreateRepository(name, backend.RepositoryOptions{ + if _, err := be.CreateRepository(ctx, name, store.RepositoryOptions{ Private: private, Description: description, ProjectName: projectName, diff --git a/server/cmd/delete.go b/server/cmd/delete.go index fb3f1dbdf..85ede10c3 100644 --- a/server/cmd/delete.go +++ b/server/cmd/delete.go @@ -10,9 +10,10 @@ func deleteCommand() *cobra.Command { Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfCollab, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) name := args[0] - if err := cfg.Backend.DeleteRepository(name); err != nil { + if err := be.DeleteRepository(ctx, name); err != nil { return err } return nil diff --git a/server/cmd/description.go b/server/cmd/description.go index 270800272..315aed7a5 100644 --- a/server/cmd/description.go +++ b/server/cmd/description.go @@ -13,7 +13,8 @@ func descriptionCommand() *cobra.Command { Short: "Set or get the description for a repository", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) rn := strings.TrimSuffix(args[0], ".git") switch len(args) { case 1: @@ -21,7 +22,7 @@ func descriptionCommand() *cobra.Command { return err } - desc, err := cfg.Backend.Description(rn) + desc, err := be.Description(ctx, rn) if err != nil { return err } @@ -31,7 +32,7 @@ func descriptionCommand() *cobra.Command { if err := checkIfCollab(cmd, args); err != nil { return err } - if err := cfg.Backend.SetDescription(rn, strings.Join(args[1:], " ")); err != nil { + if err := be.SetDescription(ctx, rn, strings.Join(args[1:], " ")); err != nil { return err } } diff --git a/server/cmd/hidden.go b/server/cmd/hidden.go index 6c10d1a6d..e65f8e7ce 100644 --- a/server/cmd/hidden.go +++ b/server/cmd/hidden.go @@ -9,7 +9,8 @@ func hiddenCommand() *cobra.Command { Aliases: []string{"hide"}, Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) repo := args[0] switch len(args) { case 1: @@ -17,7 +18,7 @@ func hiddenCommand() *cobra.Command { return err } - hidden, err := cfg.Backend.IsHidden(repo) + hidden, err := be.IsHidden(ctx, repo) if err != nil { return err } @@ -29,7 +30,7 @@ func hiddenCommand() *cobra.Command { } hidden := args[1] == "true" - if err := cfg.Backend.SetHidden(repo, hidden); err != nil { + if err := be.SetHidden(ctx, repo, hidden); err != nil { return err } } diff --git a/server/cmd/import.go b/server/cmd/import.go index e14875a2c..d35361823 100644 --- a/server/cmd/import.go +++ b/server/cmd/import.go @@ -1,7 +1,7 @@ package cmd import ( - "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/store" "github.com/spf13/cobra" ) @@ -19,10 +19,11 @@ func importCommand() *cobra.Command { Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfCollab, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) name := args[0] remote := args[1] - if _, err := cfg.Backend.ImportRepository(name, remote, backend.RepositoryOptions{ + if _, err := be.ImportRepository(ctx, name, remote, store.RepositoryOptions{ Private: private, Description: description, ProjectName: projectName, diff --git a/server/cmd/info.go b/server/cmd/info.go index 60d716d72..9747d0be6 100644 --- a/server/cmd/info.go +++ b/server/cmd/info.go @@ -1,7 +1,7 @@ package cmd import ( - "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/sshutils" "github.com/spf13/cobra" ) @@ -11,8 +11,9 @@ func infoCommand() *cobra.Command { Short: "Show your info", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - cfg, s := fromContext(cmd) - user, err := cfg.Backend.UserByPublicKey(s.PublicKey()) + ctx := cmd.Context() + _, be, s := fromContext(cmd) + user, err := be.UserByPublicKey(ctx, s.PublicKey()) if err != nil { return err } @@ -21,7 +22,7 @@ func infoCommand() *cobra.Command { cmd.Printf("Admin: %t\n", user.IsAdmin()) cmd.Printf("Public keys:\n") for _, pk := range user.PublicKeys() { - cmd.Printf(" %s\n", backend.MarshalAuthorizedKey(pk)) + cmd.Printf(" %s\n", sshutils.MarshalAuthorizedKey(pk)) } return nil }, diff --git a/server/cmd/list.go b/server/cmd/list.go index 9cfb936a5..befb6dfcc 100644 --- a/server/cmd/list.go +++ b/server/cmd/list.go @@ -1,7 +1,7 @@ package cmd import ( - "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/store" "github.com/spf13/cobra" ) @@ -15,13 +15,14 @@ func listCommand() *cobra.Command { Short: "List repositories", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - cfg, s := fromContext(cmd) - repos, err := cfg.Backend.Repositories() + ctx := cmd.Context() + _, be, s := fromContext(cmd) + repos, err := be.Repositories(ctx) if err != nil { return err } for _, r := range repos { - if cfg.Backend.AccessLevelByPublicKey(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess { + if be.AccessLevelByPublicKey(ctx, r.Name(), s.PublicKey()) >= store.ReadOnlyAccess { if !r.IsHidden() || all { cmd.Println(r.Name()) } diff --git a/server/cmd/mirror.go b/server/cmd/mirror.go index 34e785ab7..45dc8e6a9 100644 --- a/server/cmd/mirror.go +++ b/server/cmd/mirror.go @@ -11,9 +11,10 @@ func mirrorCommand() *cobra.Command { Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) rn := args[0] - rr, err := cfg.Backend.Repository(rn) + rr, err := be.Repository(ctx, rn) if err != nil { return err } diff --git a/server/cmd/private.go b/server/cmd/private.go index 3b5181d48..5d0ac2b9d 100644 --- a/server/cmd/private.go +++ b/server/cmd/private.go @@ -13,7 +13,8 @@ func privateCommand() *cobra.Command { Short: "Set or get a repository private property", Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) rn := strings.TrimSuffix(args[0], ".git") switch len(args) { @@ -22,7 +23,7 @@ func privateCommand() *cobra.Command { return err } - isPrivate, err := cfg.Backend.IsPrivate(rn) + isPrivate, err := be.IsPrivate(ctx, rn) if err != nil { return err } @@ -36,7 +37,7 @@ func privateCommand() *cobra.Command { if err := checkIfCollab(cmd, args); err != nil { return err } - if err := cfg.Backend.SetPrivate(rn, isPrivate); err != nil { + if err := be.SetPrivate(ctx, rn, isPrivate); err != nil { return err } } diff --git a/server/cmd/project_name.go b/server/cmd/project_name.go index 62e7f82b1..b7e112818 100644 --- a/server/cmd/project_name.go +++ b/server/cmd/project_name.go @@ -13,7 +13,8 @@ func projectName() *cobra.Command { Short: "Set or get the project name for a repository", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) rn := strings.TrimSuffix(args[0], ".git") switch len(args) { case 1: @@ -21,7 +22,7 @@ func projectName() *cobra.Command { return err } - pn, err := cfg.Backend.ProjectName(rn) + pn, err := be.ProjectName(ctx, rn) if err != nil { return err } @@ -31,7 +32,7 @@ func projectName() *cobra.Command { if err := checkIfCollab(cmd, args); err != nil { return err } - if err := cfg.Backend.SetProjectName(rn, strings.Join(args[1:], " ")); err != nil { + if err := be.SetProjectName(ctx, rn, strings.Join(args[1:], " ")); err != nil { return err } } diff --git a/server/cmd/pubkey.go b/server/cmd/pubkey.go index 7c1bea9b3..725c87a7d 100644 --- a/server/cmd/pubkey.go +++ b/server/cmd/pubkey.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/sshutils" "github.com/spf13/cobra" ) @@ -19,18 +19,19 @@ func pubkeyCommand() *cobra.Command { Short: "Add a public key", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - cfg, s := fromContext(cmd) - user, err := cfg.Backend.UserByPublicKey(s.PublicKey()) + ctx := cmd.Context() + _, be, s := fromContext(cmd) + user, err := be.UserByPublicKey(ctx, s.PublicKey()) if err != nil { return err } - pk, _, err := backend.ParseAuthorizedKey(strings.Join(args, " ")) + pk, _, err := sshutils.ParseAuthorizedKey(strings.Join(args, " ")) if err != nil { return err } - return cfg.Backend.AddPublicKey(user.Username(), pk) + return be.AddPublicKey(ctx, user.Username(), pk) }, } @@ -39,18 +40,19 @@ func pubkeyCommand() *cobra.Command { Args: cobra.MinimumNArgs(1), Short: "Remove a public key", RunE: func(cmd *cobra.Command, args []string) error { - cfg, s := fromContext(cmd) - user, err := cfg.Backend.UserByPublicKey(s.PublicKey()) + ctx := cmd.Context() + _, be, s := fromContext(cmd) + user, err := be.UserByPublicKey(ctx, s.PublicKey()) if err != nil { return err } - pk, _, err := backend.ParseAuthorizedKey(strings.Join(args, " ")) + pk, _, err := sshutils.ParseAuthorizedKey(strings.Join(args, " ")) if err != nil { return err } - return cfg.Backend.RemovePublicKey(user.Username(), pk) + return be.RemovePublicKey(ctx, user.Username(), pk) }, } @@ -60,15 +62,16 @@ func pubkeyCommand() *cobra.Command { Short: "List public keys", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - cfg, s := fromContext(cmd) - user, err := cfg.Backend.UserByPublicKey(s.PublicKey()) + ctx := cmd.Context() + _, be, s := fromContext(cmd) + user, err := be.UserByPublicKey(ctx, s.PublicKey()) if err != nil { return err } pks := user.PublicKeys() for _, pk := range pks { - cmd.Println(backend.MarshalAuthorizedKey(pk)) + cmd.Println(sshutils.MarshalAuthorizedKey(pk)) } return nil diff --git a/server/cmd/rename.go b/server/cmd/rename.go index d3ab7b0ac..254ece164 100644 --- a/server/cmd/rename.go +++ b/server/cmd/rename.go @@ -10,10 +10,11 @@ func renameCommand() *cobra.Command { Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfCollab, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) oldName := args[0] newName := args[1] - if err := cfg.Backend.RenameRepository(oldName, newName); err != nil { + if err := be.RenameRepository(ctx, oldName, newName); err != nil { return err } return nil diff --git a/server/cmd/repo.go b/server/cmd/repo.go index 6371f167d..fec9a88ea 100644 --- a/server/cmd/repo.go +++ b/server/cmd/repo.go @@ -40,9 +40,10 @@ func repoCommand() *cobra.Command { Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) rn := args[0] - rr, err := cfg.Backend.Repository(rn) + rr, err := be.Repository(ctx, rn) if err != nil { return err } diff --git a/server/cmd/set_username.go b/server/cmd/set_username.go index 152403a57..f3c0c3286 100644 --- a/server/cmd/set_username.go +++ b/server/cmd/set_username.go @@ -8,13 +8,14 @@ func setUsernameCommand() *cobra.Command { Short: "Set your username", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - cfg, s := fromContext(cmd) - user, err := cfg.Backend.UserByPublicKey(s.PublicKey()) + ctx := cmd.Context() + _, be, s := fromContext(cmd) + user, err := be.UserByPublicKey(ctx, s.PublicKey()) if err != nil { return err } - return cfg.Backend.SetUsername(user.Username(), args[0]) + return be.SetUsername(ctx, user.Username(), args[0]) }, } diff --git a/server/cmd/settings.go b/server/cmd/settings.go index 95ef01a24..373324001 100644 --- a/server/cmd/settings.go +++ b/server/cmd/settings.go @@ -4,7 +4,7 @@ import ( "fmt" "strconv" - "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/store" "github.com/spf13/cobra" ) @@ -21,13 +21,14 @@ func settingsCommand() *cobra.Command { Args: cobra.RangeArgs(0, 1), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) switch len(args) { case 0: - cmd.Println(cfg.Backend.AllowKeyless()) + cmd.Println(be.AllowKeyless(ctx)) case 1: v, _ := strconv.ParseBool(args[0]) - if err := cfg.Backend.SetAllowKeyless(v); err != nil { + if err := be.SetAllowKeyless(ctx, v); err != nil { return err } } @@ -37,7 +38,7 @@ func settingsCommand() *cobra.Command { }, ) - als := []string{backend.NoAccess.String(), backend.ReadOnlyAccess.String(), backend.ReadWriteAccess.String(), backend.AdminAccess.String()} + als := []string{store.NoAccess.String(), store.ReadOnlyAccess.String(), store.ReadWriteAccess.String(), store.AdminAccess.String()} cmd.AddCommand( &cobra.Command{ Use: "anon-access [ACCESS_LEVEL]", @@ -46,16 +47,17 @@ func settingsCommand() *cobra.Command { ValidArgs: als, PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) switch len(args) { case 0: - cmd.Println(cfg.Backend.AnonAccess()) + cmd.Println(be.AnonAccess(ctx)) case 1: - al := backend.ParseAccessLevel(args[0]) + al := store.ParseAccessLevel(args[0]) if al < 0 { return fmt.Errorf("invalid access level: %s. Please choose one of the following: %s", args[0], als) } - if err := cfg.Backend.SetAnonAccess(al); err != nil { + if err := be.SetAnonAccess(ctx, al); err != nil { return err } } diff --git a/server/cmd/tag.go b/server/cmd/tag.go index 84e6a907d..c22a0bba0 100644 --- a/server/cmd/tag.go +++ b/server/cmd/tag.go @@ -28,9 +28,10 @@ func tagListCommand() *cobra.Command { Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) rn := strings.TrimSuffix(args[0], ".git") - rr, err := cfg.Backend.Repository(rn) + rr, err := be.Repository(ctx, rn) if err != nil { return err } @@ -60,9 +61,10 @@ func tagDeleteCommand() *cobra.Command { Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfCollab, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) rn := strings.TrimSuffix(args[0], ".git") - rr, err := cfg.Backend.Repository(rn) + rr, err := be.Repository(ctx, rn) if err != nil { return err } diff --git a/server/cmd/tree.go b/server/cmd/tree.go index 19ea3720d..5a0fadda5 100644 --- a/server/cmd/tree.go +++ b/server/cmd/tree.go @@ -17,7 +17,8 @@ func treeCommand() *cobra.Command { Args: cobra.RangeArgs(1, 3), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) rn := args[0] path := "" ref := "" @@ -28,7 +29,7 @@ func treeCommand() *cobra.Command { ref = args[1] path = args[2] } - rr, err := cfg.Backend.Repository(rn) + rr, err := be.Repository(ctx, rn) if err != nil { return err } diff --git a/server/cmd/user.go b/server/cmd/user.go index 6518b52d5..79923169d 100644 --- a/server/cmd/user.go +++ b/server/cmd/user.go @@ -5,7 +5,8 @@ import ( "strings" "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/sshutils" + "github.com/charmbracelet/soft-serve/server/store" "github.com/spf13/cobra" "golang.org/x/crypto/ssh" ) @@ -26,10 +27,11 @@ func userCommand() *cobra.Command { PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { var pubkeys []ssh.PublicKey - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) username := args[0] if key != "" { - pk, _, err := backend.ParseAuthorizedKey(key) + pk, _, err := sshutils.ParseAuthorizedKey(key) if err != nil { return err } @@ -37,12 +39,12 @@ func userCommand() *cobra.Command { pubkeys = []ssh.PublicKey{pk} } - opts := backend.UserOptions{ + opts := store.UserOptions{ Admin: admin, PublicKeys: pubkeys, } - _, err := cfg.Backend.CreateUser(username, opts) + _, err := be.CreateUser(ctx, username, opts) return err }, } @@ -56,10 +58,11 @@ func userCommand() *cobra.Command { Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) username := args[0] - return cfg.Backend.DeleteUser(username) + return be.DeleteUser(ctx, username) }, } @@ -70,8 +73,9 @@ func userCommand() *cobra.Command { Args: cobra.NoArgs, PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, _ []string) error { - cfg, _ := fromContext(cmd) - users, err := cfg.Backend.Users() + ctx := cmd.Context() + _, be, _ := fromContext(cmd) + users, err := be.Users(ctx) if err != nil { return err } @@ -91,15 +95,16 @@ func userCommand() *cobra.Command { Args: cobra.MinimumNArgs(2), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) username := args[0] pubkey := strings.Join(args[1:], " ") - pk, _, err := backend.ParseAuthorizedKey(pubkey) + pk, _, err := sshutils.ParseAuthorizedKey(pubkey) if err != nil { return err } - return cfg.Backend.AddPublicKey(username, pk) + return be.AddPublicKey(ctx, username, pk) }, } @@ -109,16 +114,17 @@ func userCommand() *cobra.Command { Args: cobra.MinimumNArgs(2), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) username := args[0] pubkey := strings.Join(args[1:], " ") log.Debugf("key is %q", pubkey) - pk, _, err := backend.ParseAuthorizedKey(pubkey) + pk, _, err := sshutils.ParseAuthorizedKey(pubkey) if err != nil { return err } - return cfg.Backend.RemovePublicKey(username, pk) + return be.RemovePublicKey(ctx, username, pk) }, } @@ -128,10 +134,11 @@ func userCommand() *cobra.Command { Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) username := args[0] - return cfg.Backend.SetAdmin(username, args[1] == "true") + return be.SetAdmin(ctx, username, args[1] == "true") }, } @@ -141,10 +148,11 @@ func userCommand() *cobra.Command { Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) username := args[0] - user, err := cfg.Backend.User(username) + user, err := be.User(ctx, username) if err != nil { return err } @@ -155,7 +163,7 @@ func userCommand() *cobra.Command { cmd.Printf("Admin: %t\n", isAdmin) cmd.Printf("Public keys:\n") for _, pk := range user.PublicKeys() { - cmd.Printf(" %s\n", backend.MarshalAuthorizedKey(pk)) + cmd.Printf(" %s\n", sshutils.MarshalAuthorizedKey(pk)) } return nil @@ -168,11 +176,12 @@ func userCommand() *cobra.Command { Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) + ctx := cmd.Context() + _, be, _ := fromContext(cmd) username := args[0] newUsername := args[1] - return cfg.Backend.SetUsername(username, newUsername) + return be.SetUsername(ctx, username, newUsername) }, } diff --git a/server/config/config.go b/server/config/config.go index c8b9fa9bf..33d76e147 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -11,7 +11,7 @@ import ( "github.com/caarlos0/env/v8" "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/sshutils" "golang.org/x/crypto/ssh" "gopkg.in/yaml.v3" ) @@ -84,6 +84,15 @@ type LogConfig struct { TimeFormat string `env:"TIME_FORMAT" yaml:"time_format"` } +// DBConfig is the database connection configuration. +type DBConfig struct { + // Driver is the driver for the database. + Driver string `env:"DRIVER" yaml:"driver"` + + // DataSource is the database data source name. + DataSource string `env:"DATA_SOURCE" yaml:"data_source"` +} + // Config is the configuration for Soft Serve. type Config struct { // Name is the name of the server. @@ -104,14 +113,14 @@ type Config struct { // Log is the logger configuration. Log LogConfig `envPrefix:"LOG_" yaml:"log"` + // DB is the database configuration. + DB DBConfig `envPrefix:"DB_" yaml:"db"` + // InitialAdminKeys is a list of public keys that will be added to the list of admins. InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"` // DataPath is the path to the directory where Soft Serve will store its data. DataPath string `env:"DATA_PATH" yaml:"-"` - - // Backend is the Git backend to use. - Backend backend.Backend `yaml:"-"` } // Environ returns the config as a list of environment variables. @@ -143,6 +152,8 @@ func (c *Config) Environ() []string { fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr), fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format), fmt.Sprintf("SOFT_SERVE_LOG_TIME_FORMAT=%s", c.Log.TimeFormat), + fmt.Sprintf("SOFT_SERVE_DB_DRIVER=%s", c.DB.Driver), + fmt.Sprintf("SOFT_SERVE_DB_DATA_SOURCE=%s", c.DB.DataSource), }...) return envs @@ -178,6 +189,11 @@ func parseConfig(path string) (*Config, error) { Format: "text", TimeFormat: time.DateTime, }, + DB: DBConfig{ + Driver: "sqlite", + DataSource: filepath.Join(dataPath, "soft-serve.db"+ + "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"), + }, } f, err := os.Open(path) @@ -206,7 +222,7 @@ func parseConfig(path string) (*Config, error) { // Validate keys pks := make([]string, 0) for _, key := range parseAuthKeys(cfg.InitialAdminKeys) { - ak := backend.MarshalAuthorizedKey(key) + ak := sshutils.MarshalAuthorizedKey(key) pks = append(pks, ak) } @@ -270,12 +286,6 @@ func DefaultConfig() *Config { return cfg } -// WithBackend sets the backend for the configuration. -func (c *Config) WithBackend(backend backend.Backend) *Config { - c.Backend = backend - return c -} - func (c *Config) validate() error { // Use absolute paths if !filepath.IsAbs(c.DataPath) { @@ -310,17 +320,24 @@ func (c *Config) validate() error { // parseAuthKeys parses authorized keys from either file paths or string authorized_keys. func parseAuthKeys(aks []string) []ssh.PublicKey { - pks := make([]ssh.PublicKey, 0) + pks := make(map[string]ssh.PublicKey, 0) for _, key := range aks { if bts, err := os.ReadFile(key); err == nil { // key is a file key = strings.TrimSpace(string(bts)) } - if pk, _, err := backend.ParseAuthorizedKey(key); err == nil { - pks = append(pks, pk) + + if pk, _, err := sshutils.ParseAuthorizedKey(key); err == nil { + pks[key] = pk } } - return pks + + keys := make([]ssh.PublicKey, 0, len(pks)) + for _, p := range pks { + keys = append(keys, p) + } + + return keys } // AdminKeys returns the server admin keys. diff --git a/server/config/file.go b/server/config/file.go index 09e5ce2e0..cbdf7daf2 100644 --- a/server/config/file.go +++ b/server/config/file.go @@ -79,6 +79,15 @@ stats: # The address on which the stats server will listen. listen_addr: "{{ .Stats.ListenAddr }}" +# The database configuration. +db: + # The database driver to use. + # Valid values are "sqlite3", "sqlite", "postgres", and "mysql". + driver: "{{ .DB.Driver }}" + # The database data source name. + # This is driver specific and can be a file path or connection string. + data_source: "{{ .DB.DataSource }}" + # Additional admin keys. #initial_admin_keys: # - "ssh-rsa AAAAB3NzaC1yc2..." diff --git a/server/daemon/daemon.go b/server/daemon/daemon.go index 1820c0e3e..fa0f4db9a 100644 --- a/server/daemon/daemon.go +++ b/server/daemon/daemon.go @@ -14,6 +14,7 @@ import ( "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/soft-serve/server/git" + "github.com/charmbracelet/soft-serve/server/store" "github.com/charmbracelet/soft-serve/server/utils" "github.com/go-git/go-git/v5/plumbing/format/pktline" "github.com/prometheus/client_golang/prometheus" @@ -50,7 +51,7 @@ type GitDaemon struct { finished chan struct{} conns connections cfg *config.Config - be backend.Backend + be *backend.Backend wg sync.WaitGroup once sync.Once logger *log.Logger @@ -227,8 +228,8 @@ func (d *GitDaemon) handleClient(conn net.Conn) { } } - be := d.be.WithContext(ctx) - if !be.AllowKeyless() { + be := d.be + if !be.AllowKeyless(ctx) { d.fatal(c, git.ErrNotAuthed) return } @@ -247,13 +248,13 @@ func (d *GitDaemon) handleClient(conn net.Conn) { return } - if _, err := d.be.Repository(repo); err != nil { + if _, err := d.be.Repository(ctx, repo); err != nil { d.fatal(c, git.ErrInvalidRepo) return } - auth := be.AccessLevel(name, "") - if auth < backend.ReadOnlyAccess { + auth := be.AccessLevel(ctx, name, "") + if auth < store.ReadOnlyAccess { d.fatal(c, git.ErrNotAuthed) return } diff --git a/server/daemon/daemon_test.go b/server/daemon/daemon_test.go index a28fb68d4..64e809c59 100644 --- a/server/daemon/daemon_test.go +++ b/server/daemon/daemon_test.go @@ -13,11 +13,13 @@ import ( "testing" "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/backend/sqlite" "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/soft-serve/server/db" + "github.com/charmbracelet/soft-serve/server/db/migrate" "github.com/charmbracelet/soft-serve/server/git" "github.com/charmbracelet/soft-serve/server/test" "github.com/go-git/go-git/v5/plumbing/format/pktline" + _ "modernc.org/sqlite" // sqlite driver ) var testDaemon *GitDaemon @@ -36,12 +38,15 @@ func TestMain(m *testing.M) { ctx := context.TODO() cfg := config.DefaultConfig() ctx = config.WithContext(ctx, cfg) - fb, err := sqlite.NewSqliteBackend(ctx) + db, err := db.Open(cfg.DB.Driver, cfg.DB.DataSource) if err != nil { log.Fatal(err) } - cfg = cfg.WithBackend(fb) - ctx = backend.WithContext(ctx, fb) + if err := migrate.Migrate(ctx, db); err != nil { + log.Fatal(err) + } + be := backend.New(ctx, cfg, db) + ctx = backend.WithContext(ctx, be) d, err := NewGitDaemon(ctx) if err != nil { log.Fatal(err) @@ -59,7 +64,7 @@ func TestMain(m *testing.M) { os.Unsetenv("SOFT_SERVE_GIT_IDLE_TIMEOUT") os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR") _ = d.Close() - _ = fb.Close() + _ = db.Close() os.Exit(code) } diff --git a/server/hooks/gen.go b/server/hooks/gen.go new file mode 100644 index 000000000..34eac2756 --- /dev/null +++ b/server/hooks/gen.go @@ -0,0 +1,156 @@ +package hooks + +import ( + "bytes" + "context" + "flag" + "fmt" + "os" + "path/filepath" + "text/template" + + "github.com/charmbracelet/log" + "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/soft-serve/server/utils" +) + +// The names of git server-side hooks. +const ( + PreReceiveHook = "pre-receive" + UpdateHook = "update" + PostReceiveHook = "post-receive" + PostUpdateHook = "post-update" +) + +// GenerateHooks generates git server-side hooks for a repository. Currently, it supports the following hooks: +// - pre-receive +// - update +// - post-receive +// - post-update +// +// This function should be called by the backend when a repository is created. +// TODO: support context. +func GenerateHooks(_ context.Context, cfg *config.Config, repo string) error { + // TODO: support git hook tests. + if flag.Lookup("test.v") != nil { + log.WithPrefix("backend.hooks").Warn("refusing to set up hooks when in test") + return nil + } + repo = utils.SanitizeRepo(repo) + ".git" + hooksPath := filepath.Join(cfg.DataPath, "repos", repo, "hooks") + if err := os.MkdirAll(hooksPath, os.ModePerm); err != nil { + return err + } + + ex, err := os.Executable() + if err != nil { + return err + } + + dp, err := filepath.Abs(cfg.DataPath) + if err != nil { + return fmt.Errorf("failed to get absolute path for data path: %w", err) + } + + cp := filepath.Join(dp, "config.yaml") + // Add extra environment variables to the hooks here. + envs := []string{} + + for _, hook := range []string{ + PreReceiveHook, + UpdateHook, + PostReceiveHook, + PostUpdateHook, + } { + var data bytes.Buffer + var args string + + // Hooks script/directory path + hp := filepath.Join(hooksPath, hook) + + // Write the hooks primary script + if err := os.WriteFile(hp, []byte(hookTemplate), os.ModePerm); err != nil { + return err + } + + // Create ${hook}.d directory. + hp += ".d" + if err := os.MkdirAll(hp, os.ModePerm); err != nil { + return err + } + + switch hook { + case UpdateHook: + args = "$1 $2 $3" + case PostUpdateHook: + args = "$@" + } + + if err := hooksTmpl.Execute(&data, struct { + Executable string + Config string + Envs []string + Hook string + Args string + }{ + Executable: ex, + Config: cp, + Envs: envs, + Hook: hook, + Args: args, + }); err != nil { + log.WithPrefix("hooks").Error("failed to execute hook template", "err", err) + continue + } + + // Write the soft-serve hook inside ${hook}.d directory. + hp = filepath.Join(hp, "soft-serve") + err = os.WriteFile(hp, data.Bytes(), os.ModePerm) //nolint:gosec + if err != nil { + log.WithPrefix("hooks").Error("failed to write hook", "err", err) + continue + } + } + + return nil +} + +const ( + // hookTemplate allows us to run multiple hooks from a directory. It should + // support every type of git hook, as it proxies both stdin and arguments. + hookTemplate = `#!/usr/bin/env bash +# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY +data=$(cat) +exitcodes="" +hookname=$(basename $0) +GIT_DIR=${GIT_DIR:-$(dirname $0)/..} +for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do + # Avoid running non-executable hooks + test -x "${hook}" && test -f "${hook}" || continue + + # Run the actual hook + echo "${data}" | "${hook}" "$@" + + # Store the exit code for later use + exitcodes="${exitcodes} $?" +done + +# Exit on the first non-zero exit code. +for i in ${exitcodes}; do + [ ${i} -eq 0 ] || exit ${i} +done +` +) + +// hooksTmpl is the soft-serve hook that will be run by the git hooks +// inside the hooks directory. +var hooksTmpl = template.Must(template.New("hooks").Parse(`#!/usr/bin/env bash +# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY +if [ -z "$SOFT_SERVE_REPO_NAME" ]; then + echo "Warning: SOFT_SERVE_REPO_NAME not defined. Skipping hooks." + exit 0 +fi +{{ range $_, $env := .Envs }} +{{ $env }} \{{ end }} +{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }} +`)) diff --git a/server/hooks/hooks.go b/server/hooks/hooks.go index c625769f2..0278050ef 100644 --- a/server/hooks/hooks.go +++ b/server/hooks/hooks.go @@ -1,156 +1,21 @@ package hooks import ( - "bytes" "context" - "flag" - "fmt" - "os" - "path/filepath" - "text/template" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/utils" -) - -// The names of git server-side hooks. -const ( - PreReceiveHook = "pre-receive" - UpdateHook = "update" - PostReceiveHook = "post-receive" - PostUpdateHook = "post-update" + "io" ) -// GenerateHooks generates git server-side hooks for a repository. Currently, it supports the following hooks: -// - pre-receive -// - update -// - post-receive -// - post-update -// -// This function should be called by the backend when a repository is created. -// TODO: support context. -func GenerateHooks(_ context.Context, cfg *config.Config, repo string) error { - // TODO: support git hook tests. - if flag.Lookup("test.v") != nil { - log.WithPrefix("backend.hooks").Warn("refusing to set up hooks when in test") - return nil - } - repo = utils.SanitizeRepo(repo) + ".git" - hooksPath := filepath.Join(cfg.DataPath, "repos", repo, "hooks") - if err := os.MkdirAll(hooksPath, os.ModePerm); err != nil { - return err - } - - ex, err := os.Executable() - if err != nil { - return err - } - - dp, err := filepath.Abs(cfg.DataPath) - if err != nil { - return fmt.Errorf("failed to get absolute path for data path: %w", err) - } - - cp := filepath.Join(dp, "config.yaml") - // Add extra environment variables to the hooks here. - envs := []string{} - - for _, hook := range []string{ - PreReceiveHook, - UpdateHook, - PostReceiveHook, - PostUpdateHook, - } { - var data bytes.Buffer - var args string - - // Hooks script/directory path - hp := filepath.Join(hooksPath, hook) - - // Write the hooks primary script - if err := os.WriteFile(hp, []byte(hookTemplate), os.ModePerm); err != nil { - return err - } - - // Create ${hook}.d directory. - hp += ".d" - if err := os.MkdirAll(hp, os.ModePerm); err != nil { - return err - } - - switch hook { - case UpdateHook: - args = "$1 $2 $3" - case PostUpdateHook: - args = "$@" - } - - if err := hooksTmpl.Execute(&data, struct { - Executable string - Config string - Envs []string - Hook string - Args string - }{ - Executable: ex, - Config: cp, - Envs: envs, - Hook: hook, - Args: args, - }); err != nil { - log.WithPrefix("backend.hooks").Error("failed to execute hook template", "err", err) - continue - } - - // Write the soft-serve hook inside ${hook}.d directory. - hp = filepath.Join(hp, "soft-serve") - err = os.WriteFile(hp, data.Bytes(), os.ModePerm) //nolint:gosec - if err != nil { - log.WithPrefix("backend.hooks").Error("failed to write hook", "err", err) - continue - } - } - - return nil +// HookArg is an argument to a git hook. +type HookArg struct { + OldSha string + NewSha string + RefName string } -const ( - // hookTemplate allows us to run multiple hooks from a directory. It should - // support every type of git hook, as it proxies both stdin and arguments. - hookTemplate = `#!/usr/bin/env bash -# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY -data=$(cat) -exitcodes="" -hookname=$(basename $0) -GIT_DIR=${GIT_DIR:-$(dirname $0)/..} -for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do - # Avoid running non-executable hooks - test -x "${hook}" && test -f "${hook}" || continue - - # Run the actual hook - echo "${data}" | "${hook}" "$@" - - # Store the exit code for later use - exitcodes="${exitcodes} $?" -done - -# Exit on the first non-zero exit code. -for i in ${exitcodes}; do - [ ${i} -eq 0 ] || exit ${i} -done -` -) - -// hooksTmpl is the soft-serve hook that will be run by the git hooks -// inside the hooks directory. -var hooksTmpl = template.Must(template.New("hooks").Parse(`#!/usr/bin/env bash -# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY -if [ -z "$SOFT_SERVE_REPO_NAME" ]; then - echo "Warning: SOFT_SERVE_REPO_NAME not defined. Skipping hooks." - exit 0 -fi -{{ range $_, $env := .Envs }} -{{ $env }} \{{ end }} -{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }} -`)) +// Hooks provides an interface for git server-side hooks. +type Hooks interface { + PreReceive(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args []HookArg) + Update(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, arg HookArg) + PostReceive(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args []HookArg) + PostUpdate(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args ...string) +} diff --git a/server/jobs.go b/server/jobs.go index 239b08d8b..679bb5d15 100644 --- a/server/jobs.go +++ b/server/jobs.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/internal/sync" + "github.com/charmbracelet/soft-serve/server/backend" ) var jobSpecs = map[string]string{ @@ -14,12 +15,11 @@ var jobSpecs = map[string]string{ } // mirrorJob runs the (pull) mirror job task. -func (s *Server) mirrorJob() func() { +func (s *Server) mirrorJob(b *backend.Backend) func() { cfg := s.Config - b := cfg.Backend logger := s.logger return func() { - repos, err := b.Repositories() + repos, err := b.Repositories(s.ctx) if err != nil { logger.Error("error getting repositories", "err", err) return @@ -48,6 +48,7 @@ func (s *Server) mirrorJob() func() { cfg.SSH.ClientKeyPath, ), ) + if _, err := cmd.RunInDir(r.Path); err != nil { logger.Error("error running git remote update", "repo", name, "err", err) } diff --git a/server/server.go b/server/server.go index 1c0470af3..4d4db98d8 100644 --- a/server/server.go +++ b/server/server.go @@ -4,16 +4,15 @@ import ( "context" "errors" "fmt" - "io" "net/http" "github.com/charmbracelet/log" "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/backend/sqlite" "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/soft-serve/server/cron" "github.com/charmbracelet/soft-serve/server/daemon" + "github.com/charmbracelet/soft-serve/server/db" sshsrv "github.com/charmbracelet/soft-serve/server/ssh" "github.com/charmbracelet/soft-serve/server/stats" "github.com/charmbracelet/soft-serve/server/web" @@ -29,7 +28,8 @@ type Server struct { StatsServer *stats.StatsServer Cron *cron.CronScheduler Config *config.Config - Backend backend.Backend + Backend *backend.Backend + DB *db.DB logger *log.Logger ctx context.Context @@ -42,28 +42,24 @@ type Server struct { // publicly writable until configured otherwise by cloning the `config` repo. func NewServer(ctx context.Context) (*Server, error) { cfg := config.FromContext(ctx) - - var err error - if cfg.Backend == nil { - sb, err := sqlite.NewSqliteBackend(ctx) - if err != nil { - return nil, fmt.Errorf("create backend: %w", err) - } - - cfg = cfg.WithBackend(sb) - ctx = backend.WithContext(ctx, sb) + db, err := db.Open(cfg.DB.Driver, cfg.DB.DataSource) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) } + be := backend.New(ctx, cfg, db) + ctx = backend.WithContext(ctx, be) srv := &Server{ Cron: cron.NewCronScheduler(ctx), Config: cfg, - Backend: cfg.Backend, + Backend: be, + DB: db, logger: log.FromContext(ctx).WithPrefix("server"), ctx: ctx, } // Add cron jobs. - _, _ = srv.Cron.AddFunc(jobSpecs["mirror"], srv.mirrorJob()) + _, _ = srv.Cron.AddFunc(jobSpecs["mirror"], srv.mirrorJob(be)) srv.SSHServer, err = sshsrv.NewSSHServer(ctx) if err != nil { @@ -159,9 +155,7 @@ func (s *Server) Shutdown(ctx context.Context) error { s.Cron.Stop() return nil }) - if closer, ok := s.Backend.(io.Closer); ok { - defer closer.Close() // nolint: errcheck - } + defer s.DB.Close() // nolint: errcheck return errg.Wait() } @@ -176,8 +170,6 @@ func (s *Server) Close() error { s.Cron.Stop() return nil }) - if closer, ok := s.Backend.(io.Closer); ok { - defer closer.Close() // nolint: errcheck - } + defer s.DB.Close() // nolint: errcheck return errg.Wait() } diff --git a/server/ssh/session.go b/server/ssh/session.go index 26ee3a503..9ab74d71d 100644 --- a/server/ssh/session.go +++ b/server/ssh/session.go @@ -1,6 +1,7 @@ package ssh import ( + "context" "strings" "time" @@ -10,6 +11,7 @@ import ( "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/soft-serve/server/errors" + "github.com/charmbracelet/soft-serve/server/store" "github.com/charmbracelet/soft-serve/server/ui" "github.com/charmbracelet/soft-serve/server/ui/common" "github.com/charmbracelet/ssh" @@ -35,19 +37,22 @@ var tuiSessionDuration = promauto.NewCounterVec(prometheus.CounterOpts{ }, []string{"repo", "term"}) // SessionHandler is the soft-serve bubbletea ssh session handler. -func SessionHandler(cfg *config.Config) bm.ProgramHandler { +func SessionHandler(be *backend.Backend, cfg *config.Config) bm.ProgramHandler { return func(s ssh.Session) *tea.Program { pty, _, active := s.Pty() if !active { return nil } + var ctx context.Context = s.Context() + ctx = backend.WithContext(ctx, be) + ctx = config.WithContext(ctx, cfg) cmd := s.Command() initialRepo := "" if len(cmd) == 1 { initialRepo = cmd[0] - auth := cfg.Backend.AccessLevelByPublicKey(initialRepo, s.PublicKey()) - if auth < backend.ReadOnlyAccess { + auth := be.AccessLevelByPublicKey(ctx, initialRepo, s.PublicKey()) + if auth < store.ReadOnlyAccess { wish.Fatalln(s, errors.ErrUnauthorized) return nil } @@ -56,7 +61,7 @@ func SessionHandler(cfg *config.Config) bm.ProgramHandler { envs := &sessionEnv{s} output := termenv.NewOutput(s, termenv.WithColorCache(true), termenv.WithEnvironment(envs)) logger := NewDefaultLogger() - ctx := log.WithContext(s.Context(), logger) + ctx = log.WithContext(ctx, logger) c := common.NewCommon(ctx, output, pty.Window.Width, pty.Window.Height) c.SetValue(common.ConfigKey, cfg) m := ui.New(c, initialRepo) diff --git a/server/ssh/session_test.go b/server/ssh/session_test.go index 995ab20fd..cc61d7bbc 100644 --- a/server/ssh/session_test.go +++ b/server/ssh/session_test.go @@ -9,8 +9,10 @@ import ( "testing" "time" - "github.com/charmbracelet/soft-serve/server/backend/sqlite" + "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/soft-serve/server/db" + "github.com/charmbracelet/soft-serve/server/db/migrate" "github.com/charmbracelet/soft-serve/server/test" "github.com/charmbracelet/ssh" bm "github.com/charmbracelet/wish/bubbletea" @@ -18,6 +20,7 @@ import ( "github.com/matryer/is" "github.com/muesli/termenv" gossh "golang.org/x/crypto/ssh" + _ "modernc.org/sqlite" // sqlite driver ) func TestSession(t *testing.T) { @@ -31,16 +34,15 @@ func TestSession(t *testing.T) { is.NoErr(err) go func() { time.Sleep(1 * time.Second) - s.Signal(gossh.SIGTERM) - // FIXME: exit with code 0 instead of forcibly closing the session - s.Close() + // s.Signal(gossh.SIGTERM) + s.Close() // nolint: errcheck }() t.Log("waiting for session to exit") _, err = s.Output("test") var ee *gossh.ExitMissingError is.True(errors.As(err, &ee)) t.Log("session exited") - _ = close() + is.NoErr(close()) }) } @@ -60,18 +62,22 @@ func setup(tb testing.TB) (*gossh.Session, func() error) { ctx := context.TODO() cfg := config.DefaultConfig() ctx = config.WithContext(ctx, cfg) - fb, err := sqlite.NewSqliteBackend(ctx) + db, err := db.Open(cfg.DB.Driver, cfg.DB.DataSource) if err != nil { log.Fatal(err) } - cfg = cfg.WithBackend(fb) + if err := migrate.Migrate(ctx, db); err != nil { + log.Fatal(err) + } + be := backend.New(ctx, cfg, db) + ctx = backend.WithContext(ctx, be) return testsession.New(tb, &ssh.Server{ - Handler: bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256)(func(s ssh.Session) { + Handler: bm.MiddlewareWithProgramHandler(SessionHandler(be, cfg), termenv.ANSI256)(func(s ssh.Session) { _, _, active := s.Pty() if !active { os.Exit(1) } s.Exit(0) }), - }, nil), fb.Close + }, nil), db.Close } diff --git a/server/ssh/ssh.go b/server/ssh/ssh.go index 8e98d0ebc..07121f5ef 100644 --- a/server/ssh/ssh.go +++ b/server/ssh/ssh.go @@ -17,6 +17,8 @@ import ( cm "github.com/charmbracelet/soft-serve/server/cmd" "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/soft-serve/server/git" + "github.com/charmbracelet/soft-serve/server/sshutils" + "github.com/charmbracelet/soft-serve/server/store" "github.com/charmbracelet/soft-serve/server/utils" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" @@ -98,7 +100,7 @@ var ( type SSHServer struct { srv *ssh.Server cfg *config.Config - be backend.Backend + be *backend.Backend ctx context.Context logger *log.Logger } @@ -120,9 +122,9 @@ func NewSSHServer(ctx context.Context) (*SSHServer, error) { rm.MiddlewareWithLogger( logger, // BubbleTea middleware. - bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256), + bm.MiddlewareWithProgramHandler(SessionHandler(s.be, cfg), termenv.ANSI256), // CLI middleware. - cm.Middleware(cfg, logger), + cm.Middleware(s.be, cfg, logger), // Git middleware. s.Middleware(cfg), // Logging middleware. @@ -187,21 +189,21 @@ func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed return false } - ak := backend.MarshalAuthorizedKey(pk) + ak := sshutils.MarshalAuthorizedKey(pk) defer func(allowed *bool) { publicKeyCounter.WithLabelValues(strconv.FormatBool(*allowed)).Inc() }(&allowed) - ac := s.cfg.Backend.AccessLevelByPublicKey("", pk) + ac := s.be.AccessLevelByPublicKey(ctx, "", pk) s.logger.Debugf("access level for %q: %s", ak, ac) - allowed = ac >= backend.ReadWriteAccess + allowed = ac >= store.ReadWriteAccess return } // KeyboardInteractiveHandler handles keyboard interactive authentication. // This is used after all public key authentication has failed. func (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool { - ac := s.cfg.Backend.AllowKeyless() + ac := s.be.AllowKeyless(ctx) keyboardInteractiveCounter.WithLabelValues(strconv.FormatBool(ac)).Inc() return ac } @@ -217,15 +219,16 @@ func (ss *SSHServer) Middleware(cfg *config.Config) wish.Middleware { func() { start := time.Now() cmdLine := s.Command() - ctx := s.Context() - be := ss.be.WithContext(ctx) + var ctx context.Context = s.Context() + be := ss.be + ctx = backend.WithContext(ctx, be) if len(cmdLine) >= 2 && strings.HasPrefix(cmdLine[0], "git") { // repo should be in the form of "repo.git" name := utils.SanitizeRepo(cmdLine[1]) pk := s.PublicKey() - ak := backend.MarshalAuthorizedKey(pk) - access := cfg.Backend.AccessLevelByPublicKey(name, pk) + ak := sshutils.MarshalAuthorizedKey(pk) + access := ss.be.AccessLevelByPublicKey(ctx, name, pk) // git bare repositories should end in ".git" // https://git-scm.com/docs/gitrepository-layout repo := name + ".git" @@ -240,7 +243,7 @@ func (ss *SSHServer) Middleware(cfg *config.Config) wish.Middleware { "SOFT_SERVE_REPO_NAME=" + name, "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo), "SOFT_SERVE_PUBLIC_KEY=" + ak, - "SOFT_SERVE_USERNAME=" + ctx.User(), + "SOFT_SERVE_USERNAME=" + s.User(), } // Add ssh session & config environ @@ -265,12 +268,12 @@ func (ss *SSHServer) Middleware(cfg *config.Config) wish.Middleware { defer func() { receivePackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds()) }() - if access < backend.ReadWriteAccess { + if access < store.ReadWriteAccess { sshFatal(s, git.ErrNotAuthed) return } - if _, err := be.Repository(name); err != nil { - if _, err := be.CreateRepository(name, backend.RepositoryOptions{Private: false}); err != nil { + if _, err := be.Repository(ctx, name); err != nil { + if _, err := be.CreateRepository(ctx, name, store.RepositoryOptions{Private: false}); err != nil { log.Errorf("failed to create repo: %s", err) sshFatal(s, err) return @@ -289,7 +292,7 @@ func (ss *SSHServer) Middleware(cfg *config.Config) wish.Middleware { receivePackCounter.WithLabelValues(name).Inc() return case git.UploadPackService, git.UploadArchiveService: - if access < backend.ReadOnlyAccess { + if access < store.ReadOnlyAccess { sshFatal(s, git.ErrNotAuthed) return } diff --git a/server/sshutils/utils.go b/server/sshutils/utils.go new file mode 100644 index 000000000..cdc127e45 --- /dev/null +++ b/server/sshutils/utils.go @@ -0,0 +1,31 @@ +package sshutils + +import ( + "bytes" + + "github.com/charmbracelet/ssh" + gossh "golang.org/x/crypto/ssh" +) + +// ParseAuthorizedKey parses an authorized key string into a public key. +func ParseAuthorizedKey(ak string) (gossh.PublicKey, string, error) { + pk, c, _, _, err := gossh.ParseAuthorizedKey([]byte(ak)) + return pk, c, err +} + +// MarshalAuthorizedKey marshals a public key into an authorized key string. +// +// This is the inverse of ParseAuthorizedKey. +// This function is a copy of ssh.MarshalAuthorizedKey, but without the trailing newline. +// It returns an empty string if pk is nil. +func MarshalAuthorizedKey(pk gossh.PublicKey) string { + if pk == nil { + return "" + } + return string(bytes.TrimSuffix(gossh.MarshalAuthorizedKey(pk), []byte("\n"))) +} + +// KeysEqual returns whether the two public keys are equal. +func KeysEqual(a, b gossh.PublicKey) bool { + return ssh.KeysEqual(a, b) +} diff --git a/server/ui/common/common.go b/server/ui/common/common.go index d603906c9..884b7d200 100644 --- a/server/ui/common/common.go +++ b/server/ui/common/common.go @@ -5,6 +5,7 @@ import ( "github.com/charmbracelet/log" "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/soft-serve/server/ui/keymap" "github.com/charmbracelet/soft-serve/server/ui/styles" @@ -62,13 +63,19 @@ func (c *Common) SetSize(width, height int) { c.Height = height } +// Context returns the context. +func (c *Common) Context() context.Context { + return c.ctx +} + // Config returns the server config. func (c *Common) Config() *config.Config { - v := c.ctx.Value(ConfigKey) - if cfg, ok := v.(*config.Config); ok { - return cfg - } - return nil + return config.FromContext(c.ctx) +} + +// Backend returns the Soft Serve backend. +func (c *Common) Backend() *backend.Backend { + return backend.FromContext(c.ctx) } // Repo returns the repository. diff --git a/server/ui/pages/repo/files.go b/server/ui/pages/repo/files.go index e4692cb90..0ac3fa274 100644 --- a/server/ui/pages/repo/files.go +++ b/server/ui/pages/repo/files.go @@ -10,7 +10,7 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/store" "github.com/charmbracelet/soft-serve/server/ui/common" "github.com/charmbracelet/soft-serve/server/ui/components/code" "github.com/charmbracelet/soft-serve/server/ui/components/selector" @@ -52,7 +52,7 @@ type Files struct { selector *selector.Selector ref *git.Reference activeView filesView - repo backend.Repository + repo store.Repository code *code.Code path string currentItem *FileItem diff --git a/server/ui/pages/repo/log.go b/server/ui/pages/repo/log.go index 21bbcdf9f..ef3d5f268 100644 --- a/server/ui/pages/repo/log.go +++ b/server/ui/pages/repo/log.go @@ -11,7 +11,7 @@ import ( gansi "github.com/charmbracelet/glamour/ansi" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/store" "github.com/charmbracelet/soft-serve/server/ui/common" "github.com/charmbracelet/soft-serve/server/ui/components/footer" "github.com/charmbracelet/soft-serve/server/ui/components/selector" @@ -47,7 +47,7 @@ type Log struct { selector *selector.Selector vp *viewport.Viewport activeView logView - repo backend.Repository + repo store.Repository ref *git.Reference count int64 nextPage int diff --git a/server/ui/pages/repo/readme.go b/server/ui/pages/repo/readme.go index a1623524f..c32818c2a 100644 --- a/server/ui/pages/repo/readme.go +++ b/server/ui/pages/repo/readme.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/store" "github.com/charmbracelet/soft-serve/server/ui/common" "github.com/charmbracelet/soft-serve/server/ui/components/code" ) @@ -21,7 +22,7 @@ type Readme struct { common common.Common code *code.Code ref RefMsg - repo backend.Repository + repo store.Repository readmePath string } diff --git a/server/ui/pages/repo/refs.go b/server/ui/pages/repo/refs.go index 8669e151c..dcf1feee2 100644 --- a/server/ui/pages/repo/refs.go +++ b/server/ui/pages/repo/refs.go @@ -10,7 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/soft-serve/git" ggit "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/store" "github.com/charmbracelet/soft-serve/server/ui/common" "github.com/charmbracelet/soft-serve/server/ui/components/selector" "github.com/charmbracelet/soft-serve/server/ui/components/tabs" @@ -33,7 +33,7 @@ type RefItemsMsg struct { type Refs struct { common common.Common selector *selector.Selector - repo backend.Repository + repo store.Repository ref *git.Reference activeRef *git.Reference refPrefix string @@ -216,7 +216,7 @@ func switchRefCmd(ref *ggit.Reference) tea.Cmd { } // UpdateRefCmd gets the repository's HEAD reference and sends a RefMsg. -func UpdateRefCmd(repo backend.Repository) tea.Cmd { +func UpdateRefCmd(repo store.Repository) tea.Cmd { return func() tea.Msg { r, err := repo.Open() if err != nil { diff --git a/server/ui/pages/repo/repo.go b/server/ui/pages/repo/repo.go index 5cb2075db..407cc2cde 100644 --- a/server/ui/pages/repo/repo.go +++ b/server/ui/pages/repo/repo.go @@ -9,7 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/store" "github.com/charmbracelet/soft-serve/server/ui/common" "github.com/charmbracelet/soft-serve/server/ui/components/footer" "github.com/charmbracelet/soft-serve/server/ui/components/statusbar" @@ -54,7 +54,7 @@ type CopyURLMsg struct{} type UpdateStatusBarMsg struct{} // RepoMsg is a message that contains a git.Repository. -type RepoMsg backend.Repository +type RepoMsg store.Repository // BackMsg is a message to go back to the previous view. type BackMsg struct{} @@ -68,7 +68,7 @@ type CopyMsg struct { // Repo is a view for a git repository. type Repo struct { common common.Common - selectedRepo backend.Repository + selectedRepo store.Repository activeTab tab tabs *tabs.Tabs statusbar *statusbar.StatusBar diff --git a/server/ui/pages/selection/item.go b/server/ui/pages/selection/item.go index 6550497c7..e14943b7f 100644 --- a/server/ui/pages/selection/item.go +++ b/server/ui/pages/selection/item.go @@ -11,8 +11,8 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/soft-serve/server/store" "github.com/charmbracelet/soft-serve/server/ui/common" "github.com/dustin/go-humanize" ) @@ -48,13 +48,13 @@ func (it Items) Swap(i int, j int) { // Item represents a single item in the selector. type Item struct { - repo backend.Repository + repo store.Repository lastUpdate *time.Time cmd string } // New creates a new Item. -func NewItem(repo backend.Repository, cfg *config.Config) (Item, error) { +func NewItem(repo store.Repository, cfg *config.Config) (Item, error) { var lastUpdate *time.Time lu := repo.UpdatedAt() if !lu.IsZero() { diff --git a/server/ui/pages/selection/selection.go b/server/ui/pages/selection/selection.go index dc066f32a..975d66b4c 100644 --- a/server/ui/pages/selection/selection.go +++ b/server/ui/pages/selection/selection.go @@ -9,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/store" "github.com/charmbracelet/soft-serve/server/ui/common" "github.com/charmbracelet/soft-serve/server/ui/components/code" "github.com/charmbracelet/soft-serve/server/ui/components/selector" @@ -187,12 +188,14 @@ func (s *Selection) Init() tea.Cmd { return nil } + ctx := s.common.Context() + be := s.common.Backend() pk := s.common.PublicKey() - if pk == nil && !cfg.Backend.AllowKeyless() { + if pk == nil && !be.AllowKeyless(ctx) { return nil } - repos, err := cfg.Backend.Repositories() + repos, err := be.Repositories(ctx) if err != nil { return common.ErrorCmd(err) } @@ -210,8 +213,8 @@ func (s *Selection) Init() tea.Cmd { if r.IsHidden() { continue } - al := cfg.Backend.AccessLevelByPublicKey(r.Name(), pk) - if al >= backend.ReadOnlyAccess { + al := be.AccessLevelByPublicKey(ctx, r.Name(), pk) + if al >= store.ReadOnlyAccess { item, err := NewItem(r, cfg) if err != nil { s.common.Logger.Debugf("ui: failed to create item for %s: %v", r.Name(), err) diff --git a/server/ui/ui.go b/server/ui/ui.go index 69b526d6e..d71042855 100644 --- a/server/ui/ui.go +++ b/server/ui/ui.go @@ -7,7 +7,7 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/store" "github.com/charmbracelet/soft-serve/server/ui/common" "github.com/charmbracelet/soft-serve/server/ui/components/footer" "github.com/charmbracelet/soft-serve/server/ui/components/header" @@ -283,12 +283,15 @@ func (ui *UI) View() string { ) } -func (ui *UI) openRepo(rn string) (backend.Repository, error) { +func (ui *UI) openRepo(rn string) (store.Repository, error) { cfg := ui.common.Config() if cfg == nil { return nil, errors.New("config is nil") } - repos, err := cfg.Backend.Repositories() + + ctx := ui.common.Context() + be := ui.common.Backend() + repos, err := be.Repositories(ctx) if err != nil { ui.common.Logger.Debugf("ui: failed to list repos: %v", err) return nil, err diff --git a/server/web/git.go b/server/web/git.go index 2ca926591..b975f759e 100644 --- a/server/web/git.go +++ b/server/web/git.go @@ -18,6 +18,7 @@ import ( "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/soft-serve/server/git" + "github.com/charmbracelet/soft-serve/server/store" "github.com/charmbracelet/soft-serve/server/utils" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -32,7 +33,7 @@ type GitRoute struct { handler http.HandlerFunc cfg *config.Config - be backend.Backend + be *backend.Backend logger *log.Logger } @@ -68,7 +69,7 @@ func (g GitRoute) Match(r *http.Request) *http.Request { } if g.be != nil { - ctx = backend.WithContext(ctx, g.be.WithContext(ctx)) + ctx = backend.WithContext(ctx, g.be) } if g.logger != nil { @@ -186,32 +187,32 @@ func withAccess(fn http.HandlerFunc) http.HandlerFunc { be := backend.FromContext(ctx) logger := log.FromContext(ctx) - if !be.AllowKeyless() { + if !be.AllowKeyless(ctx) { renderForbidden(w) return } repo := pat.Param(r, "repo") service := git.Service(pat.Param(r, "service")) - access := be.AccessLevel(repo, "") + access := be.AccessLevel(ctx, repo, "") switch service { case git.ReceivePackService: - if access < backend.ReadWriteAccess { + if access < store.ReadWriteAccess { renderUnauthorized(w) return } // Create the repo if it doesn't exist. - if _, err := be.Repository(repo); err != nil { - if _, err := be.CreateRepository(repo, backend.RepositoryOptions{}); err != nil { + if _, err := be.Repository(ctx, repo); err != nil { + if _, err := be.CreateRepository(ctx, repo, store.RepositoryOptions{}); err != nil { logger.Error("failed to create repository", "repo", repo, "err", err) renderInternalServerError(w) return } } default: - if access < backend.ReadOnlyAccess { + if access < store.ReadOnlyAccess { renderUnauthorized(w) return } diff --git a/server/web/goget.go b/server/web/goget.go index 7e7c8c9d6..64c97a268 100644 --- a/server/web/goget.go +++ b/server/web/goget.go @@ -36,7 +36,7 @@ Redirecting to docs at