e398b4dde723437f637b06710e0938d349b360e8

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

Message

refactor: implement database module and abstract backend (#337)

* refactor(server): abstract database from backend

Prepare for multi database driver support

* feat(server): add db models

* feat(db): add database migrations

* feat(db): add support to postgres

* feat: implement database store logic

* refactor: use db module and abstract backend logic

* fix(db): postgres migrate sql

* feat(db): add database query tracing

* refactor: move internal packages to server

* fix(config): normalize sqlite database path

* fix,feat: support custom log path and fix logging leak in hooks

* fix(test): race condition

* refactor: tidy up files and use middlewares

* chore: add test for repo commit command

Reference: https://github.com/charmbracelet/soft-serve/pull/331

* fix: lint errors

* fix: use utc time and fix git packp error format

* fix: lint errors

* fix: testscript on windows

* chore: format sql files

* fix: lint issues

re-enable revive linter

* refactor: clean up server/config

* feat: add admin command to manage server

* fix(db): use migration versions

* chore: add deprecation warning.

* refactor: move shared interfaces and errors to proto

* fix: increase golangci lint timeout

* feat: add move tests

Diff

This diff is truncated to protect this page.

   1diff --git a/.golangci-soft.yml b/.golangci-soft.yml
   2index ef456e0605919f959a254d6dbe917b5d26f7ffcd..0500f19d30a2e9355aa138c3225fc615c8c0c2d0 100644
   3--- a/.golangci-soft.yml
   4+++ b/.golangci-soft.yml
   5@@ -1,5 +1,6 @@
   6 run:
   7   tests: false
   8+  timeout: 5m
   9 
  10 issues:
  11   include:
  12diff --git a/.golangci.yml b/.golangci.yml
  13index a5a91d0d91348545ae21ac105126432198795697..4ae3fa1bbc7a11b759e838a9e205eba6228274ed 100644
  14--- a/.golangci.yml
  15+++ b/.golangci.yml
  16@@ -1,5 +1,6 @@
  17 run:
  18   tests: false
  19+  timeout: 5m
  20 
  21 issues:
  22   include:
  23@@ -27,3 +28,10 @@ linters:
  24     - unconvert
  25     - unparam
  26     - whitespace
  27+
  28+severity:
  29+  default-severity: error
  30+  rules:
  31+    - linters:
  32+        - revive
  33+      severity: info
  34diff --git a/cmd/soft/admin.go b/cmd/soft/admin.go
  35new file mode 100644
  36index 0000000000000000000000000000000000000000..16d31f2783026e7ff59fb93b7aa271edfed7dbc8
  37--- /dev/null
  38+++ b/cmd/soft/admin.go
  39@@ -0,0 +1,75 @@
  40+package main
  41+
  42+import (
  43+	"fmt"
  44+
  45+	"github.com/charmbracelet/soft-serve/server/backend"
  46+	"github.com/charmbracelet/soft-serve/server/config"
  47+	"github.com/charmbracelet/soft-serve/server/db"
  48+	"github.com/charmbracelet/soft-serve/server/db/migrate"
  49+	"github.com/spf13/cobra"
  50+)
  51+
  52+var (
  53+	adminCmd = &cobra.Command{
  54+		Use:   "admin",
  55+		Short: "Administrate the server",
  56+	}
  57+
  58+	migrateCmd = &cobra.Command{
  59+		Use:                "migrate",
  60+		Short:              "Migrate the database to the latest version",
  61+		PersistentPreRunE:  initBackendContext,
  62+		PersistentPostRunE: closeDBContext,
  63+		RunE: func(cmd *cobra.Command, _ []string) error {
  64+			ctx := cmd.Context()
  65+			db := db.FromContext(ctx)
  66+			if err := migrate.Migrate(ctx, db); err != nil {
  67+				return fmt.Errorf("migration: %w", err)
  68+			}
  69+
  70+			return nil
  71+		},
  72+	}
  73+
  74+	rollbackCmd = &cobra.Command{
  75+		Use:                "rollback",
  76+		Short:              "Rollback the database to the previous version",
  77+		PersistentPreRunE:  initBackendContext,
  78+		PersistentPostRunE: closeDBContext,
  79+		RunE: func(cmd *cobra.Command, _ []string) error {
  80+			ctx := cmd.Context()
  81+			db := db.FromContext(ctx)
  82+			if err := migrate.Rollback(ctx, db); err != nil {
  83+				return fmt.Errorf("rollback: %w", err)
  84+			}
  85+
  86+			return nil
  87+		},
  88+	}
  89+
  90+	syncHooksCmd = &cobra.Command{
  91+		Use:                "sync-hooks",
  92+		Short:              "Update repository hooks",
  93+		PersistentPreRunE:  initBackendContext,
  94+		PersistentPostRunE: closeDBContext,
  95+		RunE: func(cmd *cobra.Command, _ []string) error {
  96+			ctx := cmd.Context()
  97+			cfg := config.FromContext(ctx)
  98+			be := backend.FromContext(ctx)
  99+			if err := initializeHooks(ctx, cfg, be); err != nil {
 100+				return fmt.Errorf("initialize hooks: %w", err)
 101+			}
 102+
 103+			return nil
 104+		},
 105+	}
 106+)
 107+
 108+func init() {
 109+	adminCmd.AddCommand(
 110+		syncHooksCmd,
 111+		migrateCmd,
 112+		rollbackCmd,
 113+	)
 114+}
 115diff --git a/cmd/soft/hook.go b/cmd/soft/hook.go
 116index c1f9d4233ab56846043584f052c1195bb235cffa..fdfbaed46d1b441a5f89f8412a90b2e2464c7b0a 100644
 117--- a/cmd/soft/hook.go
 118+++ b/cmd/soft/hook.go
 119@@ -11,66 +11,33 @@ import (
 120 	"path/filepath"
 121 	"strings"
 122 
 123-	"github.com/charmbracelet/log"
 124 	"github.com/charmbracelet/soft-serve/server/backend"
 125-	"github.com/charmbracelet/soft-serve/server/backend/sqlite"
 126 	"github.com/charmbracelet/soft-serve/server/config"
 127 	"github.com/charmbracelet/soft-serve/server/hooks"
 128 	"github.com/spf13/cobra"
 129 )
 130 
 131 var (
 132+	// Deprecated: this flag is ignored.
 133 	configPath string
 134 
 135-	logFileCtxKey = struct{}{}
 136-
 137 	hookCmd = &cobra.Command{
 138-		Use:    "hook",
 139-		Short:  "Run git server hooks",
 140-		Long:   "Handles Soft Serve git server hooks.",
 141-		Hidden: true,
 142-		PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
 143-			ctx := cmd.Context()
 144-			cfg, err := config.ParseConfig(configPath)
 145-			if err != nil {
 146-				return fmt.Errorf("could not parse config: %w", err)
 147-			}
 148-
 149-			ctx = config.WithContext(ctx, cfg)
 150-
 151-			logPath := filepath.Join(cfg.DataPath, "log", "hooks.log")
 152-			f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
 153-			if err != nil {
 154-				return fmt.Errorf("opening file: %w", err)
 155-			}
 156-
 157-			ctx = context.WithValue(ctx, logFileCtxKey, f)
 158-			logger := log.FromContext(ctx)
 159-			logger.SetOutput(f)
 160-			ctx = log.WithContext(ctx, logger)
 161-			cmd.SetContext(ctx)
 162-
 163-			// Set up the backend
 164-			// TODO: support other backends
 165-			sb, err := sqlite.NewSqliteBackend(ctx)
 166-			if err != nil {
 167-				return fmt.Errorf("failed to create sqlite backend: %w", err)
 168-			}
 169-
 170-			cfg = cfg.WithBackend(sb)
 171-
 172-			return nil
 173-		},
 174-		PersistentPostRunE: func(cmd *cobra.Command, _ []string) error {
 175-			f := cmd.Context().Value(logFileCtxKey).(*os.File)
 176-			return f.Close()
 177-		},
 178+		Use:                "hook",
 179+		Short:              "Run git server hooks",
 180+		Long:               "Handles Soft Serve git server hooks.",
 181+		Hidden:             true,
 182+		PersistentPreRunE:  initBackendContext,
 183+		PersistentPostRunE: closeDBContext,
 184 	}
 185 
 186+	// Git hooks read the config from the environment, based on
 187+	// $SOFT_SERVE_DATA_PATH. We already parse the config when the binary
 188+	// starts, so we don't need to do it again.
 189+	// The --config flag is now deprecated.
 190 	hooksRunE = func(cmd *cobra.Command, args []string) error {
 191 		ctx := cmd.Context()
 192+		hks := backend.FromContext(ctx)
 193 		cfg := config.FromContext(ctx)
 194-		hks := cfg.Backend.(backend.Hooks)
 195 
 196 		// This is set in the server before invoking git-receive-pack/git-upload-pack
 197 		repoName := os.Getenv("SOFT_SERVE_REPO_NAME")
 198@@ -80,10 +47,10 @@ var (
 199 		stderr := cmd.ErrOrStderr()
 200 
 201 		cmdName := cmd.Name()
 202-		customHookPath := filepath.Join(filepath.Dir(configPath), "hooks", cmdName)
 203+		customHookPath := filepath.Join(cfg.DataPath, "hooks", cmdName)
 204 
 205 		var buf bytes.Buffer
 206-		opts := make([]backend.HookArg, 0)
 207+		opts := make([]hooks.HookArg, 0)
 208 
 209 		switch cmdName {
 210 		case hooks.PreReceiveHook, hooks.PostReceiveHook:
 211@@ -94,7 +61,7 @@ var (
 212 				if len(fields) != 3 {
 213 					return fmt.Errorf("invalid hook input: %s", scanner.Text())
 214 				}
 215-				opts = append(opts, backend.HookArg{
 216+				opts = append(opts, hooks.HookArg{
 217 					OldSha:  fields[0],
 218 					NewSha:  fields[1],
 219diff --git a/cmd/soft/man.go b/cmd/soft/man.go
 220index 71b1dfb2c3534a3e41d57baf34b5c0fadd09dd59..966fe27d349ae11d407ba618dc6d647218ecc692 100644
 221--- a/cmd/soft/man.go
 222+++ b/cmd/soft/man.go
 223@@ -8,22 +8,20 @@ import (
 224 	"github.com/spf13/cobra"
 225 )
 226 
 227-var (
 228-	manCmd = &cobra.Command{
 229-		Use:    "man",
 230-		Short:  "Generate man pages",
 231-		Args:   cobra.NoArgs,
 232-		Hidden: true,
 233-		RunE: func(cmd *cobra.Command, args []string) error {
 234-			manPage, err := mcobra.NewManPage(1, rootCmd) //.
 235-			if err != nil {
 236-				return err
 237-			}
 238+var manCmd = &cobra.Command{
 239+	Use:    "man",
 240+	Short:  "Generate man pages",
 241+	Args:   cobra.NoArgs,
 242+	Hidden: true,
 243+	RunE: func(_ *cobra.Command, _ []string) error {
 244+		manPage, err := mcobra.NewManPage(1, rootCmd) //.
 245+		if err != nil {
 246+			return err
 247+		}
 248 
 249-			manPage = manPage.WithSection("Copyright", "(C) 2021-2023 Charmbracelet, Inc.\n"+
 250-				"Released under MIT license.")
 251-			fmt.Println(manPage.Build(roff.NewDocument()))
 252-			return nil
 253-		},
 254-	}
 255-)
 256+		manPage = manPage.WithSection("Copyright", "(C) 2021-2023 Charmbracelet, Inc.\n"+
 257+			"Released under MIT license.")
 258+		fmt.Println(manPage.Build(roff.NewDocument()))
 259+		return nil
 260+	},
 261+}
 262diff --git a/cmd/soft/migrate_config.go b/cmd/soft/migrate_config.go
 263index 6624da972e92d86a861ac65a64eaaaf45d441ec7..4d85c0bd82accb9ca38600b29b1352ea4027a36f 100644
 264--- a/cmd/soft/migrate_config.go
 265+++ b/cmd/soft/migrate_config.go
 266@@ -12,9 +12,12 @@ import (
 267 
 268 	"github.com/charmbracelet/log"
 269 	"github.com/charmbracelet/soft-serve/git"
 270+	"github.com/charmbracelet/soft-serve/server/access"
 271 	"github.com/charmbracelet/soft-serve/server/backend"
 272-	"github.com/charmbracelet/soft-serve/server/backend/sqlite"
 273 	"github.com/charmbracelet/soft-serve/server/config"
 274+	"github.com/charmbracelet/soft-serve/server/db"
 275+	"github.com/charmbracelet/soft-serve/server/proto"
 276+	"github.com/charmbracelet/soft-serve/server/sshutils"
 277 	"github.com/charmbracelet/soft-serve/server/utils"
 278 	gitm "github.com/gogs/git-module"
 279 	"github.com/spf13/cobra"
 280@@ -22,296 +25,300 @@ import (
 281 	"gopkg.in/yaml.v3"
 282 )
 283 
 284-var (
 285-	migrateConfig = &cobra.Command{
 286-		Use:    "migrate-config",
 287-		Short:  "Migrate config to new format",
 288-		Hidden: true,
 289-		RunE: func(cmd *cobra.Command, _ []string) error {
 290-			ctx := cmd.Context()
 291-
 292-			logger := log.FromContext(ctx)
 293-			// Disable logging timestamp
 294-			logger.SetReportTimestamp(false)
 295-
 296-			keyPath := os.Getenv("SOFT_SERVE_KEY_PATH")
 297-			reposPath := os.Getenv("SOFT_SERVE_REPO_PATH")
 298-			bindAddr := os.Getenv("SOFT_SERVE_BIND_ADDRESS")
 299-			cfg := config.DefaultConfig()
 300-			ctx = config.WithContext(ctx, cfg)
 301-			sb, err := sqlite.NewSqliteBackend(ctx)
 302-			if err != nil {
 303-				return fmt.Errorf("failed to create sqlite backend: %w", err)
 304-			}
 305+// Deprecated: will be removed in a future release.
 306+var migrateConfig = &cobra.Command{
 307+	Use:    "migrate-config",
 308+	Short:  "Migrate config to new format",
 309+	Hidden: true,
 310+	RunE: func(cmd *cobra.Command, _ []string) error {
 311+		ctx := cmd.Context()
 312+
 313+		logger := log.FromContext(ctx)
 314+		// Disable logging timestamp
 315+		logger.SetReportTimestamp(false)
 316+
 317+		keyPath := os.Getenv("SOFT_SERVE_KEY_PATH")
 318+		reposPath := os.Getenv("SOFT_SERVE_REPO_PATH")
 319+		bindAddr := os.Getenv("SOFT_SERVE_BIND_ADDRESS")
 320+		cfg := config.DefaultConfig()
 321+		if err := cfg.ParseEnv(); err != nil {
 322+			return fmt.Errorf("parse env: %w", err)
 323+		}
 324 
 325-			// FIXME: Admin user gets created when the database is created.
 326-			sb.DeleteUser("admin") // nolint: errcheck
 327+		ctx = config.WithContext(ctx, cfg)
 328+		db, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
 329+		if err != nil {
 330+			return fmt.Errorf("open database: %w", err)
 331+		}
 332 
 333-			cfg = cfg.WithBackend(sb)
 334+		defer db.Close() // nolint: errcheck
 335+		sb := backend.New(ctx, cfg, db)
 336 
 337-			// Set SSH listen address
 338-			logger.Info("Setting SSH listen address...")
 339-			if bindAddr != "" {
 340-				cfg.SSH.ListenAddr = bindAddr
 341-			}
 342+		// FIXME: Admin user gets created when the database is created.
 343+		sb.DeleteUser(ctx, "admin") // nolint: errcheck
 344 
 345-			// Copy SSH host key
 346-			logger.Info("Copying SSH host key...")
 347-			if keyPath != "" {
 348-				if err := os.MkdirAll(filepath.Join(cfg.DataPath, "ssh"), os.ModePerm); err != nil {
 349-					return fmt.Errorf("failed to create ssh directory: %w", err)
 350-				}
 351+		// Set SSH listen address
 352+		logger.Info("Setting SSH listen address...")
 353+		if bindAddr != "" {
 354+			cfg.SSH.ListenAddr = bindAddr
 355+		}
 356 
 357-				if err := copyFile(keyPath, filepath.Join(cfg.DataPath, "ssh", filepath.Base(keyPath))); err != nil {
 358-					return fmt.Errorf("failed to copy ssh key: %w", err)
 359-				}
 360+		// Copy SSH host key
 361+		logger.Info("Copying SSH host key...")
 362+		if keyPath != "" {
 363+			if err := os.MkdirAll(filepath.Join(cfg.DataPath, "ssh"), os.ModePerm); err != nil {
 364+				return fmt.Errorf("failed to create ssh directory: %w", err)
 365+			}
 366diff --git a/cmd/soft/root.go b/cmd/soft/root.go
 367index f78c2260645f98a2bd979d70daf43bd0a759556a..b3216162f5601ed44bddfd3fa0bdcc86a8a1825c 100644
 368--- a/cmd/soft/root.go
 369+++ b/cmd/soft/root.go
 370@@ -2,13 +2,21 @@ package main
 371 
 372 import (
 373 	"context"
 374+	"fmt"
 375 	"os"
 376 	"runtime/debug"
 377+	"strings"
 378+	"time"
 379 
 380 	"github.com/charmbracelet/log"
 381-	. "github.com/charmbracelet/soft-serve/internal/log"
 382+	"github.com/charmbracelet/soft-serve/server/backend"
 383+	"github.com/charmbracelet/soft-serve/server/config"
 384+	"github.com/charmbracelet/soft-serve/server/db"
 385+	_ "github.com/lib/pq" // postgres driver
 386 	"github.com/spf13/cobra"
 387 	"go.uber.org/automaxprocs/maxprocs"
 388+
 389+	_ "modernc.org/sqlite" // sqlite driver
 390 )
 391 
 392 var (
 393@@ -34,6 +42,7 @@ func init() {
 394 		manCmd,
 395 		hookCmd,
 396 		migrateConfig,
 397+		adminCmd,
 398 	)
 399 	rootCmd.CompletionOptions.HiddenDefaultCmd = true
 400 
 401@@ -52,19 +61,111 @@ func init() {
 402 }
 403 
 404 func main() {
 405-	logger := NewDefaultLogger()
 406+	ctx := context.Background()
 407+	cfg := config.DefaultConfig()
 408+	if cfg.Exist() {
 409+		if err := cfg.Parse(); err != nil {
 410+			log.Fatal(err)
 411+		}
 412+	}
 413+
 414+	if err := cfg.ParseEnv(); err != nil {
 415+		log.Fatal(err)
 416+	}
 417+
 418+	ctx = config.WithContext(ctx, cfg)
 419+	logger, f, err := newDefaultLogger(cfg)
 420+	if err != nil {
 421+		log.Errorf("failed to create logger: %v", err)
 422+	}
 423+
 424+	ctx = log.WithContext(ctx, logger)
 425+	if f != nil {
 426+		defer f.Close() // nolint: errcheck
 427+	}
 428 
 429 	// Set global logger
 430 	log.SetDefault(logger)
 431 
 432+	var opts []maxprocs.Option
 433+	if config.IsVerbose() {
 434+		opts = append(opts, maxprocs.Logger(log.Debugf))
 435+	}
 436+
 437 	// Set the max number of processes to the number of CPUs
 438 	// This is useful when running soft serve in a container
 439-	if _, err := maxprocs.Set(maxprocs.Logger(logger.Debugf)); err != nil {
 440-		logger.Warn("couldn't set automaxprocs", "error", err)
 441+	if _, err := maxprocs.Set(opts...); err != nil {
 442+		log.Warn("couldn't set automaxprocs", "error", err)
 443 	}
 444 
 445-	ctx := log.WithContext(context.Background(), logger)
 446 	if err := rootCmd.ExecuteContext(ctx); err != nil {
 447 		os.Exit(1)
 448 	}
 449 }
 450+
 451+// newDefaultLogger returns a new logger with default settings.
 452+func newDefaultLogger(cfg *config.Config) (*log.Logger, *os.File, error) {
 453+	logger := log.NewWithOptions(os.Stderr, log.Options{
 454+		ReportTimestamp: true,
 455+		TimeFormat:      time.DateOnly,
 456+	})
 457+
 458+	switch {
 459+	case config.IsVerbose():
 460+		logger.SetReportCaller(true)
 461+		fallthrough
 462+	case config.IsDebug():
 463+		logger.SetLevel(log.DebugLevel)
 464+	}
 465+
 466+	logger.SetTimeFormat(cfg.Log.TimeFormat)
 467+
 468+	switch strings.ToLower(cfg.Log.Format) {
 469+	case "json":
 470diff --git a/cmd/soft/serve.go b/cmd/soft/serve.go
 471index b9a9b2937a1be656acbe38b32325161cee61c572..0111f311a71a1eb3d48004a52831811b02f7c86c 100644
 472--- a/cmd/soft/serve.go
 473+++ b/cmd/soft/serve.go
 474@@ -10,21 +10,39 @@ import (
 475 	"time"
 476 
 477 	"github.com/charmbracelet/soft-serve/server"
 478+	"github.com/charmbracelet/soft-serve/server/backend"
 479 	"github.com/charmbracelet/soft-serve/server/config"
 480+	"github.com/charmbracelet/soft-serve/server/db"
 481+	"github.com/charmbracelet/soft-serve/server/db/migrate"
 482+	"github.com/charmbracelet/soft-serve/server/hooks"
 483 	"github.com/spf13/cobra"
 484 )
 485 
 486 var (
 487+	syncHooks bool
 488+
 489 	serveCmd = &cobra.Command{
 490-		Use:   "serve",
 491-		Short: "Start the server",
 492-		Long:  "Start the server",
 493-		Args:  cobra.NoArgs,
 494+		Use:                "serve",
 495+		Short:              "Start the server",
 496+		Args:               cobra.NoArgs,
 497+		PersistentPreRunE:  initBackendContext,
 498+		PersistentPostRunE: closeDBContext,
 499 		RunE: func(cmd *cobra.Command, _ []string) error {
 500 			ctx := cmd.Context()
 501 			cfg := config.DefaultConfig()
 502-			ctx = config.WithContext(ctx, cfg)
 503-			cmd.SetContext(ctx)
 504+			if cfg.Exist() {
 505+				if err := cfg.ParseFile(); err != nil {
 506+					return fmt.Errorf("parse config file: %w", err)
 507+				}
 508+			} else {
 509+				if err := cfg.WriteConfig(); err != nil {
 510+					return fmt.Errorf("write config file: %w", err)
 511+				}
 512+			}
 513+
 514+			if err := cfg.ParseEnv(); err != nil {
 515+				return fmt.Errorf("parse environment variables: %w", err)
 516+			}
 517 
 518 			// Create custom hooks directory if it doesn't exist
 519 			customHooksPath := filepath.Join(cfg.DataPath, "hooks")
 520@@ -33,7 +51,7 @@ var (
 521 				// Generate update hook example without executable permissions
 522 				hookPath := filepath.Join(customHooksPath, "update.sample")
 523 				// nolint: gosec
 524-				if err := os.WriteFile(hookPath, []byte(updateHookExample), 0744); err != nil {
 525+				if err := os.WriteFile(hookPath, []byte(updateHookExample), 0o744); err != nil {
 526 					return fmt.Errorf("failed to generate update hook example: %w", err)
 527 				}
 528 			}
 529@@ -44,11 +62,23 @@ var (
 530 				os.MkdirAll(logPath, os.ModePerm) // nolint: errcheck
 531 			}
 532 
 533+			db := db.FromContext(ctx)
 534+			if err := migrate.Migrate(ctx, db); err != nil {
 535+				return fmt.Errorf("migration error: %w", err)
 536+			}
 537+
 538 			s, err := server.NewServer(ctx)
 539 			if err != nil {
 540 				return fmt.Errorf("start server: %w", err)
 541 			}
 542 
 543+			if syncHooks {
 544+				be := backend.FromContext(ctx)
 545+				if err := initializeHooks(ctx, cfg, be); err != nil {
 546+					return fmt.Errorf("initialize hooks: %w", err)
 547+				}
 548+			}
 549+
 550 			done := make(chan os.Signal, 1)
 551 			lch := make(chan error, 1)
 552 			go func() {
 553@@ -71,3 +101,22 @@ var (
 554 		},
 555 	}
 556 )
 557+
 558+func init() {
 559+	serveCmd.Flags().BoolVarP(&syncHooks, "sync-hooks", "", false, "synchronize hooks for all repositories before running the server")
 560+}
 561+
 562+func initializeHooks(ctx context.Context, cfg *config.Config, be *backend.Backend) error {
 563+	repos, err := be.Repositories(ctx)
 564+	if err != nil {
 565+		return err
 566+	}
 567+
 568+	for _, repo := range repos {
 569+		if err := hooks.GenerateHooks(ctx, cfg, repo.Name()); err != nil {
 570+			return err
 571+		}
 572+	}
 573+
 574diff --git a/examples/setuid/main.go b/examples/setuid/main.go
 575deleted file mode 100644
 576index 4b6c4770d5d6d2a759b1dd291bc67faf655faa4d..0000000000000000000000000000000000000000
 577--- a/examples/setuid/main.go
 578+++ /dev/null
 579@@ -1,74 +0,0 @@
 580-//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
 581-// +build darwin dragonfly freebsd linux netbsd openbsd solaris
 582-
 583-// This is an example of binding soft-serve ssh port to a restricted port (<1024) and
 584-// then droping root privileges to a different user to run the server.
 585-// Make sure you run this as root.
 586-
 587-package main
 588-
 589-import (
 590-	"context"
 591-	"flag"
 592-	"fmt"
 593-	"net"
 594-	"os"
 595-	"os/signal"
 596-	"syscall"
 597-	"time"
 598-
 599-	"github.com/charmbracelet/log"
 600-
 601-	"github.com/charmbracelet/soft-serve/server"
 602-	"github.com/charmbracelet/soft-serve/server/config"
 603-)
 604-
 605-var (
 606-	port = flag.Int("port", 22, "port to listen on")
 607-	gid  = flag.Int("gid", 1000, "group id to run as")
 608-	uid  = flag.Int("uid", 1000, "user id to run as")
 609-)
 610-
 611-func main() {
 612-	flag.Parse()
 613-	addr := fmt.Sprintf(":%d", *port)
 614-	// To listen on port 22 we need root privileges
 615-	ls, err := net.Listen("tcp", addr)
 616-	if err != nil {
 617-		log.Fatal("Can't listen", "err", err)
 618-	}
 619-	// We don't need root privileges any more
 620-	if err := syscall.Setgid(*gid); err != nil {
 621-		log.Fatal("Setgid error", "err", err)
 622-	}
 623-	if err := syscall.Setuid(*uid); err != nil {
 624-		log.Fatal("Setuid error", "err", err)
 625-	}
 626-	ctx := context.Background()
 627-	cfg := config.DefaultConfig()
 628-	ctx = config.WithContext(ctx, cfg)
 629-	cfg.SSH.ListenAddr = fmt.Sprintf(":%d", *port)
 630-	s, err := server.NewServer(ctx)
 631-	if err != nil {
 632-		log.Fatal(err)
 633-	}
 634-
 635-	done := make(chan os.Signal, 1)
 636-	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
 637-
 638-	log.Print("Starting SSH server", "addr", cfg.SSH.ListenAddr)
 639-	go func() {
 640-		if err := s.SSHServer.Serve(ls); err != nil {
 641-			log.Fatal(err)
 642-		}
 643-	}()
 644-
 645-	<-done
 646-
 647-	log.Print("Stopping SSH server", "addr", cfg.SSH.ListenAddr)
 648-	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
 649-	defer func() { cancel() }()
 650-	if err := s.Shutdown(ctx); err != nil {
 651-		log.Fatal(err)
 652-	}
 653-}
 654diff --git a/git/commit.go b/git/commit.go
 655index 6a7d2778064dd35923e92788eab19f75048a201c..3dd0a8ff377914166b3c66ca3b2748b4ee979605 100644
 656--- a/git/commit.go
 657+++ b/git/commit.go
 658@@ -4,9 +4,8 @@ import (
 659 	"github.com/gogs/git-module"
 660 )
 661 
 662-var (
 663-	ZeroHash Hash = git.EmptyID
 664-)
 665+// ZeroHash is the zero hash.
 666+var ZeroHash Hash = git.EmptyID
 667 
 668 // Hash represents a git hash.
 669 type Hash string
 670diff --git a/git/patch.go b/git/patch.go
 671index 114137090bae4584c49989981dadd9d97bf1c791..aaa6b9d78fb2692f530115e0038112bffa1dbdde 100644
 672--- a/git/patch.go
 673+++ b/git/patch.go
 674@@ -82,7 +82,7 @@ func diffsToString(diffs []diffmatchpatch.Diff, lineType git.DiffLineType) strin
 675 		}
 676 	}
 677 
 678-	return string(buf.Bytes())
 679+	return buf.String()
 680 }
 681 
 682 // DiffFile is a wrapper to git.DiffFile with helper methods.
 683diff --git a/go.mod b/go.mod
 684index 9e30fdb459e96002f5b99b68478e038f3e5cdabe..d1fd82477d6aa2f82a7dd24edc4d0c787a37ddce 100644
 685--- a/go.mod
 686+++ b/go.mod
 687@@ -20,12 +20,13 @@ require (
 688 require (
 689 	github.com/caarlos0/env/v8 v8.0.0
 690 	github.com/charmbracelet/keygen v0.4.3
 691-	github.com/charmbracelet/log v0.2.2
 692-	github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
 693+	github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35
 694+	github.com/charmbracelet/ssh v0.0.0-20230712221603-7e03c5063afc
 695 	github.com/gobwas/glob v0.2.3
 696 	github.com/gogs/git-module v1.8.2
 697 	github.com/hashicorp/golang-lru/v2 v2.0.4
 698 	github.com/jmoiron/sqlx v1.3.5
 699+	github.com/lib/pq v1.2.0
 700 	github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f
 701 	github.com/muesli/mango-cobra v1.2.0
 702 	github.com/muesli/roff v0.1.0
 703diff --git a/go.sum b/go.sum
 704index 1744b5f06bed66a4035414d16f7e868ae962c634..68b784413895c021f3185f5335e857e01a21c6de 100644
 705--- a/go.sum
 706+++ b/go.sum
 707@@ -27,10 +27,10 @@ github.com/charmbracelet/keygen v0.4.3 h1:ywOZRwkDlpmkawl0BgLTxaYWDSqp6Y4nfVVmgy
 708 github.com/charmbracelet/keygen v0.4.3/go.mod h1:4e4FT3HSdLU/u83RfJWvzJIaVb8aX4MxtDlfXwpDJaI=
 709 github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
 710 github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
 711-github.com/charmbracelet/log v0.2.2 h1:CaXgos+ikGn5tcws5Cw3paQuk9e/8bIwuYGhnkqQFjo=
 712-github.com/charmbracelet/log v0.2.2/go.mod h1:Zs11hKpb8l+UyX4y1srwZIGW+MPCXJHIty3MB9l/sno=
 713-github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103 h1:wpHMERIN0pQZE635jWwT1dISgfjbpUcEma+fbPKSMCU=
 714-github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103/go.mod h1:0Vm2/8yBljiLDnGJHU8ehswfawrEybGk33j5ssqKQVM=
 715+github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35 h1:VXEaJ1iM2L5N8T2WVbv4y631pzCD3O9s75dONqK+87g=
 716+github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8=
 717+github.com/charmbracelet/ssh v0.0.0-20230712221603-7e03c5063afc h1:JUm+5HigAM5utFiThwIDX9iU0BaheKpuNVr+umi3sFg=
 718+github.com/charmbracelet/ssh v0.0.0-20230712221603-7e03c5063afc/go.mod h1:F1vgddWsb/Yr/OZilFeRZEh5sE/qU0Dt1mKkmke6Zvg=
 719 github.com/charmbracelet/wish v1.1.1 h1:KdICASKd2oh2JPvk1Z4CJtAi97cFErXF7NKienPICO4=
 720 github.com/charmbracelet/wish v1.1.1/go.mod h1:xh4KZpSULw+Xqb9bcbhw92QAinVB75CVLWrFuyY6IVs=
 721 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
 722@@ -161,7 +161,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 723 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 724 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 725 github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 726-github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
 727+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
 728 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 729 github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
 730 github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 731@@ -189,14 +189,14 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
 732 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 733 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 734 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 735-golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 736 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 737+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 738 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 739 golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
 740 golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 741 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 742 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 743-golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 744+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 745 golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
 746 golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
 747 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 748diff --git a/internal/log/log.go b/internal/log/log.go
 749deleted file mode 100644
 750index b6c4b1443d19a5bda654a22428efb1fb70cbe16d..0000000000000000000000000000000000000000
 751--- a/internal/log/log.go
 752+++ /dev/null
 753@@ -1,53 +0,0 @@
 754-package log
 755-
 756-import (
 757-	"os"
 758-	"path/filepath"
 759-	"strconv"
 760-	"strings"
 761-	"time"
 762-
 763-	"github.com/charmbracelet/log"
 764-	"github.com/charmbracelet/soft-serve/server/config"
 765-)
 766-
 767-var contextKey = &struct{ string }{"logger"}
 768-
 769-// NewDefaultLogger returns a new logger with default settings.
 770-func NewDefaultLogger() *log.Logger {
 771-	dp := os.Getenv("SOFT_SERVE_DATA_PATH")
 772-	if dp == "" {
 773-		dp = "data"
 774-	}
 775-
 776-	cfg, err := config.ParseConfig(filepath.Join(dp, "config.yaml"))
 777-	if err != nil {
 778-		log.Errorf("failed to parse config: %v", err)
 779-	}
 780-
 781-	logger := log.NewWithOptions(os.Stderr, log.Options{
 782-		ReportTimestamp: true,
 783-		TimeFormat:      time.DateOnly,
 784-	})
 785-
 786-	if debug, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_DEBUG")); debug {
 787-		logger.SetLevel(log.DebugLevel)
 788-
 789-		if verbose, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_VERBOSE")); verbose {
 790-			logger.SetReportCaller(true)
 791-		}
 792-	}
 793-
 794-	logger.SetTimeFormat(cfg.Log.TimeFormat)
 795-
 796-	switch strings.ToLower(cfg.Log.Format) {
 797-	case "json":
 798-		logger.SetFormatter(log.JSONFormatter)
 799-	case "logfmt":
 800-		logger.SetFormatter(log.LogfmtFormatter)
 801-	case "text":
 802-		logger.SetFormatter(log.TextFormatter)
 803-	}
 804-
 805-	return logger
 806-}
 807diff --git a/server/backend/access.go b/server/access/access.go
 808rename from server/backend/access.go
 809rename to server/access/access.go
 810index 8ed8dbfc339d489a5464feeaf975bc762f10d2fa..2ddc88b398c8e00eafeaee91e328859ccaaa38ba 100644
 811--- a/server/backend/access.go
 812+++ b/server/access/access.go
 813@@ -1,7 +1,7 @@
 814-package backend
 815+package access
 816 
 817 // AccessLevel is the level of access allowed to a repo.
 818-type AccessLevel int
 819+type AccessLevel int // nolint: revive
 820 
 821 const (
 822 	// NoAccess does not allow access to the repo.
 823diff --git a/server/access/access_test.go b/server/access/access_test.go
 824new file mode 100644
 825index 0000000000000000000000000000000000000000..6d591b32aa50100b47fb3d142e4bc45b46f391b1
 826--- /dev/null
 827+++ b/server/access/access_test.go
 828@@ -0,0 +1,24 @@
 829+package access
 830+
 831+import "testing"
 832+
 833+func TestParseAccessLevel(t *testing.T) {
 834+	cases := []struct {
 835+		in  string
 836+		out AccessLevel
 837+	}{
 838+		{"", -1},
 839+		{"foo", -1},
 840+		{AdminAccess.String(), AdminAccess},
 841+		{ReadOnlyAccess.String(), ReadOnlyAccess},
 842+		{ReadWriteAccess.String(), ReadWriteAccess},
 843+		{NoAccess.String(), NoAccess},
 844+	}
 845+
 846+	for _, c := range cases {
 847+		out := ParseAccessLevel(c.in)
 848+		if out != c.out {
 849+			t.Errorf("ParseAccessLevel(%q) => %d, want %d", c.in, out, c.out)
 850+		}
 851+	}
 852+}
 853diff --git a/server/backend/backend.go b/server/backend/backend.go
 854index 1aa408b46a5bf92cf54f4d4dc676eceffabf8e6a..2d6104d56f2a32563c7724ccd762312895c804a2 100644
 855--- a/server/backend/backend.go
 856+++ b/server/backend/backend.go
 857@@ -1,47 +1,41 @@
 858 package backend
 859 
 860 import (
 861-	"bytes"
 862 	"context"
 863 
 864-	"github.com/charmbracelet/ssh"
 865-	gossh "golang.org/x/crypto/ssh"
 866+	"github.com/charmbracelet/log"
 867+	"github.com/charmbracelet/soft-serve/server/config"
 868+	"github.com/charmbracelet/soft-serve/server/db"
 869+	"github.com/charmbracelet/soft-serve/server/store"
 870+	"github.com/charmbracelet/soft-serve/server/store/database"
 871 )
 872 
 873-// Backend is an interface that handles repositories management and any
 874-// non-Git related operations.
 875-type Backend interface {
 876-	SettingsBackend
 877-	RepositoryStore
 878-	RepositoryMetadata
 879-	RepositoryAccess
 880-	UserStore
 881-	UserAccess
 882-	Hooks
 883-
 884-	// WithContext returns a copy Backend with the given context.
 885-	WithContext(ctx context.Context) Backend
 886-}
 887-
 888-// ParseAuthorizedKey parses an authorized key string into a public key.
 889-func ParseAuthorizedKey(ak string) (gossh.PublicKey, string, error) {
 890-	pk, c, _, _, err := gossh.ParseAuthorizedKey([]byte(ak))
 891-	return pk, c, err
 892+// Backend is the Soft Serve backend that handles users, repositories, and
 893+// server settings management and operations.
 894+type Backend struct {
 895+	ctx    context.Context
 896+	cfg    *config.Config
 897+	db     *db.DB
 898+	store  store.Store
 899+	logger *log.Logger
 900+	cache  *cache
 901 }
 902 
 903-// MarshalAuthorizedKey marshals a public key into an authorized key string.
 904-//
 905-// This is the inverse of ParseAuthorizedKey.
 906-// This function is a copy of ssh.MarshalAuthorizedKey, but without the trailing newline.
 907-// It returns an empty string if pk is nil.
 908-func MarshalAuthorizedKey(pk gossh.PublicKey) string {
 909-	if pk == nil {
 910-		return ""
 911+// New returns a new Soft Serve backend.
 912+func New(ctx context.Context, cfg *config.Config, db *db.DB) *Backend {
 913+	dbstore := database.New(ctx, db)
 914+	logger := log.FromContext(ctx).WithPrefix("backend")
 915+	b := &Backend{
 916+		ctx:    ctx,
 917+		cfg:    cfg,
 918+		db:     db,
 919+		store:  dbstore,
 920+		logger: logger,
 921 	}
 922-	return string(bytes.TrimSuffix(gossh.MarshalAuthorizedKey(pk), []byte("\n")))
 923-}
 924 
 925-// KeysEqual returns whether the two public keys are equal.
 926-func KeysEqual(a, b gossh.PublicKey) bool {
 927-	return ssh.KeysEqual(a, b)
 928+	// TODO: implement a proper caching interface
 929+	cache := newCache(b, 1000)
 930+	b.cache = cache
 931+
 932+	return b
 933 }
 934diff --git a/server/backend/cache.go b/server/backend/cache.go
 935new file mode 100644
 936index 0000000000000000000000000000000000000000..8d99401dd94fee4c02ce145693de8c53ee5bfaae
 937--- /dev/null
 938+++ b/server/backend/cache.go
 939@@ -0,0 +1,35 @@
 940+package backend
 941+
 942+import lru "github.com/hashicorp/golang-lru/v2"
 943+
 944+// TODO: implement a caching interface.
 945+type cache struct {
 946+	b     *Backend
 947+	repos *lru.Cache[string, *repo]
 948+}
 949+
 950+func newCache(b *Backend, size int) *cache {
 951+	if size <= 0 {
 952+		size = 1
 953+	}
 954+	c := &cache{b: b}
 955+	cache, _ := lru.New[string, *repo](size)
 956+	c.repos = cache
 957+	return c
 958+}
 959+
 960+func (c *cache) Get(repo string) (*repo, bool) {
 961+	return c.repos.Get(repo)
 962+}
 963+
 964+func (c *cache) Set(repo string, r *repo) {
 965+	c.repos.Add(repo, r)
 966+}
 967+
 968+func (c *cache) Delete(repo string) {
 969+	c.repos.Remove(repo)
 970+}
 971+
 972+func (c *cache) Len() int {
 973+	return c.repos.Len()
 974+}
 975diff --git a/server/backend/collab.go b/server/backend/collab.go
 976new file mode 100644
 977index 0000000000000000000000000000000000000000..92bfca821854554fc7e879474da2a21ac2fef8e4
 978--- /dev/null
 979+++ b/server/backend/collab.go
 980@@ -0,0 +1,78 @@
 981+package backend
 982+
 983+import (
 984+	"context"
 985+	"strings"
 986+
 987+	"github.com/charmbracelet/soft-serve/server/db"
 988+	"github.com/charmbracelet/soft-serve/server/db/models"
 989+	"github.com/charmbracelet/soft-serve/server/utils"
 990+)
 991+
 992+// AddCollaborator adds a collaborator to a repository.
 993+//
 994+// It implements backend.Backend.
 995+func (d *Backend) AddCollaborator(ctx context.Context, repo string, username string) error {
 996+	username = strings.ToLower(username)
 997+	if err := utils.ValidateUsername(username); err != nil {
 998+		return err
 999+	}
1000+
1001+	repo = utils.SanitizeRepo(repo)
1002+	return db.WrapError(
1003+		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
1004+			return d.store.AddCollabByUsernameAndRepo(ctx, tx, username, repo)
1005+		}),
1006+	)
1007+}
1008+
1009+// Collaborators returns a list of collaborators for a repository.
1010+//
1011+// It implements backend.Backend.
1012+func (d *Backend) Collaborators(ctx context.Context, repo string) ([]string, error) {
1013+	repo = utils.SanitizeRepo(repo)
1014+	var users []models.User
1015+	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
1016+		var err error
1017+		users, err = d.store.ListCollabsByRepoAsUsers(ctx, tx, repo)
1018+		return err
1019+	}); err != nil {
1020+		return nil, db.WrapError(err)
1021+	}
1022+
1023+	var usernames []string
1024+	for _, u := range users {
1025+		usernames = append(usernames, u.Username)
1026+	}
1027+
1028+	return usernames, nil
1029+}
1030+
1031+// IsCollaborator returns true if the user is a collaborator of the repository.
1032+//
1033+// It implements backend.Backend.
1034+func (d *Backend) IsCollaborator(ctx context.Context, repo string, username string) (bool, error) {
1035+	repo = utils.SanitizeRepo(repo)
1036+	var m models.Collab
1037+	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
1038+		var err error
1039+		m, err = d.store.GetCollabByUsernameAndRepo(ctx, tx, username, repo)
1040+		return err
1041+	}); err != nil {
1042+		return false, db.WrapError(err)
1043+	}
1044+
1045+	return m.ID > 0, nil
1046+}
1047+
1048+// RemoveCollaborator removes a collaborator from a repository.
1049+//
1050+// It implements backend.Backend.
1051+func (d *Backend) RemoveCollaborator(ctx context.Context, repo string, username string) error {
1052+	repo = utils.SanitizeRepo(repo)
1053+	return db.WrapError(
1054+		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
1055+			return d.store.RemoveCollabByUsernameAndRepo(ctx, tx, username, repo)
1056+		}),
1057+	)
1058+}
1059diff --git a/server/backend/context.go b/server/backend/context.go
1060index 1af19057d57ddf2c61bba91460d996af66884ec0..8971bbf9360856af3cb8a7c9d8bae35feff221f9 100644
1061--- a/server/backend/context.go
1062+++ b/server/backend/context.go
1063@@ -2,11 +2,12 @@ package backend
1064 
1065 import "context"
1066 
1067-var contextKey = &struct{ string }{"backend"}
1068+// ContextKey is the key for the backend in the context.
1069+var ContextKey = &struct{ string }{"backend"}
1070 
1071 // FromContext returns the backend from a context.
1072-func FromContext(ctx context.Context) Backend {
1073-	if b, ok := ctx.Value(contextKey).(Backend); ok {
1074+func FromContext(ctx context.Context) *Backend {
1075+	if b, ok := ctx.Value(ContextKey).(*Backend); ok {
1076 		return b
1077 	}
1078 
1079@@ -14,6 +15,6 @@ func FromContext(ctx context.Context) Backend {
1080 }
1081 
1082 // WithContext returns a new context with the backend attached.
1083-func WithContext(ctx context.Context, b Backend) context.Context {
1084-	return context.WithValue(ctx, contextKey, b)
1085+func WithContext(ctx context.Context, b *Backend) context.Context {
1086+	return context.WithValue(ctx, ContextKey, b)
1087 }
1088diff --git a/server/backend/hooks.go b/server/backend/hooks.go
1089index ba130a30195a734a8b0626524dd6e9c9c9a0c056..b28c5ecd78bf8a20d30f26cd0b4fe10adda3bae1 100644
1090--- a/server/backend/hooks.go
1091+++ b/server/backend/hooks.go
1092@@ -1,20 +1,80 @@
1093 package backend
1094 
1095 import (
1096+	"context"
1097 	"io"
1098+	"sync"
1099+
1100+	"github.com/charmbracelet/soft-serve/server/hooks"
1101+	"github.com/charmbracelet/soft-serve/server/proto"
1102 )
1103 
1104-// HookArg is an argument to a git hook.
1105-type HookArg struct {
1106-	OldSha  string
1107-	NewSha  string
1108-	RefName string
1109+var _ hooks.Hooks = (*Backend)(nil)
1110+
1111+// PostReceive is called by the git post-receive hook.
1112+//
1113+// It implements Hooks.
1114+func (d *Backend) PostReceive(_ context.Context, _ io.Writer, _ io.Writer, repo string, args []hooks.HookArg) {
1115+	d.logger.Debug("post-receive hook called", "repo", repo, "args", args)
1116+}
1117+
1118+// PreReceive is called by the git pre-receive hook.
1119+//
1120+// It implements Hooks.
1121+func (d *Backend) PreReceive(_ context.Context, _ io.Writer, _ io.Writer, repo string, args []hooks.HookArg) {
1122+	d.logger.Debug("pre-receive hook called", "repo", repo, "args", args)
1123 }
1124 
1125-// Hooks provides an interface for git server-side hooks.
1126-type Hooks interface {
1127-	PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
1128-	Update(stdout io.Writer, stderr io.Writer, repo string, arg HookArg)
1129-	PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
1130-	PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string)
1131+// Update is called by the git update hook.
1132+//
1133+// It implements Hooks.
1134+func (d *Backend) Update(_ context.Context, _ io.Writer, _ io.Writer, repo string, arg hooks.HookArg) {
1135+	d.logger.Debug("update hook called", "repo", repo, "arg", arg)
1136+}
1137+
1138+// PostUpdate is called by the git post-update hook.
1139+//
1140+// It implements Hooks.
1141+func (d *Backend) PostUpdate(ctx context.Context, _ io.Writer, _ io.Writer, repo string, args ...string) {
1142+	d.logger.Debug("post-update hook called", "repo", repo, "args", args)
1143+
1144+	var wg sync.WaitGroup
1145+
1146+	// Populate last-modified file.
1147+	wg.Add(1)
1148+	go func() {
1149+		defer wg.Done()
1150+		if err := populateLastModified(ctx, d, repo); err != nil {
1151+			d.logger.Error("error populating last-modified", "repo", repo, "err", err)
1152+			return
1153+		}
1154+	}()
1155+
1156+	wg.Wait()
1157+}
1158+
1159+func populateLastModified(ctx context.Context, d *Backend, name string) error {
1160+	var rr *repo
1161+	_rr, err := d.Repository(ctx, name)
1162+	if err != nil {
1163+		return err
1164+	}
1165+
1166+	if r, ok := _rr.(*repo); ok {
1167+		rr = r
1168+	} else {
1169+		return proto.ErrRepoNotExist
1170+	}
1171+
1172+	r, err := rr.Open()
1173+	if err != nil {
1174+		return err
1175+	}
1176+
1177+	c, err := r.LatestCommitTime()
1178+	if err != nil {
1179+		return err
1180+	}
1181+
1182+	return rr.writeLastModified(c)
1183 }
1184diff --git a/server/backend/repo.go b/server/backend/repo.go
1185index 8d7e9cdeffa516b70c276c383b4d78746ead6876..5f07e61f175140795062d954ce67e04897d6e06f 100644
1186--- a/server/backend/repo.go
1187+++ b/server/backend/repo.go
1188@@ -1,86 +1,484 @@
1189 package backend
1190 
1191 import (
1192+	"bufio"
1193+	"context"
1194+	"errors"
1195+	"fmt"
1196+	"os"
1197+	"path/filepath"
1198 	"time"
1199 
1200 	"github.com/charmbracelet/soft-serve/git"
1201+	"github.com/charmbracelet/soft-serve/server/db"
1202+	"github.com/charmbracelet/soft-serve/server/db/models"
1203+	"github.com/charmbracelet/soft-serve/server/hooks"
1204+	"github.com/charmbracelet/soft-serve/server/proto"
1205+	"github.com/charmbracelet/soft-serve/server/utils"
1206 )
1207 
1208-// RepositoryOptions are options for creating a new repository.
1209-type RepositoryOptions struct {
1210-	Private     bool
1211-	Description string
1212-	ProjectName string
1213-	Mirror      bool
1214-	Hidden      bool
1215-}
1216-
1217-// RepositoryStore is an interface for managing repositories.
1218-type RepositoryStore interface {
1219-	// Repository finds the given repository.
1220-	Repository(repo string) (Repository, error)
1221-	// Repositories returns a list of all repositories.
1222-	Repositories() ([]Repository, error)
1223-	// CreateRepository creates a new repository.
1224-	CreateRepository(name string, opts RepositoryOptions) (Repository, error)
1225-	// ImportRepository creates a new repository from a Git repository.
1226-	ImportRepository(name string, remote string, opts RepositoryOptions) (Repository, error)
1227-	// DeleteRepository deletes a repository.
1228-	DeleteRepository(name string) error
1229-	// RenameRepository renames a repository.
1230-	RenameRepository(oldName, newName string) error
1231-}
1232-
1233-// RepositoryMetadata is an interface for managing repository metadata.
1234-type RepositoryMetadata interface {
1235-	// ProjectName returns the repository's project name.
1236-	ProjectName(repo string) (string, error)
1237-	// SetProjectName sets the repository's project name.
1238-	SetProjectName(repo, name string) error
1239-	// Description returns the repository's description.
1240-	Description(repo string) (string, error)
1241-	// SetDescription sets the repository's description.
1242-	SetDescription(repo, desc string) error
1243-	// IsPrivate returns whether the repository is private.
1244-	IsPrivate(repo string) (bool, error)
1245-	// SetPrivate sets whether the repository is private.
1246-	SetPrivate(repo string, private bool) error
1247-	// IsMirror returns whether the repository is a mirror.
1248-	IsMirror(repo string) (bool, error)
1249-	// IsHidden returns whether the repository is hidden.
1250-	IsHidden(repo string) (bool, error)
1251-	// SetHidden sets whether the repository is hidden.
1252-	SetHidden(repo string, hidden bool) error
1253-}
1254-
1255-// RepositoryAccess is an interface for managing repository access.
1256-type RepositoryAccess interface {
1257-	IsCollaborator(repo string, username string) (bool, error)
1258-	// AddCollaborator adds the authorized key as a collaborator on the repository.
1259-	AddCollaborator(repo string, username string) error
1260-	// RemoveCollaborator removes the authorized key as a collaborator on the repository.
1261-	RemoveCollaborator(repo string, username string) error
1262-	// Collaborators returns a list of all collaborators on the repository.
1263-	Collaborators(repo string) ([]string, error)
1264-}
1265-
1266-// Repository is a Git repository interface.
1267-type Repository interface {
1268-	// Name returns the repository's name.
1269-	Name() string
1270-	// ProjectName returns the repository's project name.
1271-	ProjectName() string
1272-	// Description returns the repository's description.
1273-	Description() string
1274-	// IsPrivate returns whether the repository is private.
1275-	IsPrivate() bool
1276-	// IsMirror returns whether the repository is a mirror.
1277-	IsMirror() bool
1278-	// IsHidden returns whether the repository is hidden.
1279-	IsHidden() bool
1280-	// UpdatedAt returns the time the repository was last updated.
1281-	// If the repository has never been updated, it returns the time it was created.
1282-	UpdatedAt() time.Time
1283-	// Open returns the underlying git.Repository.
1284-	Open() (*git.Repository, error)
1285+func (d *Backend) reposPath() string {
1286+	return filepath.Join(d.cfg.DataPath, "repos")
1287+}
1288diff --git a/server/backend/settings.go b/server/backend/settings.go
1289index c3e8a79023f250aeb7410bb46d76d8dacf261bc5..0879aa38d0b23aec92348ec877a4b69657b71320 100644
1290--- a/server/backend/settings.go
1291+++ b/server/backend/settings.go
1292@@ -1,13 +1,58 @@
1293 package backend
1294 
1295-// SettingsBackend is an interface that handles server configuration.
1296-type SettingsBackend interface {
1297-	// AnonAccess returns the access level for anonymous users.
1298-	AnonAccess() AccessLevel
1299-	// SetAnonAccess sets the access level for anonymous users.
1300-	SetAnonAccess(level AccessLevel) error
1301-	// AllowKeyless returns true if keyless access is allowed.
1302-	AllowKeyless() bool
1303-	// SetAllowKeyless sets whether or not keyless access is allowed.
1304-	SetAllowKeyless(allow bool) error
1305+import (
1306+	"context"
1307+
1308+	"github.com/charmbracelet/soft-serve/server/access"
1309+	"github.com/charmbracelet/soft-serve/server/db"
1310+)
1311+
1312+// AllowKeyless returns whether or not keyless access is allowed.
1313+//
1314+// It implements backend.Backend.
1315+func (b *Backend) AllowKeyless(ctx context.Context) bool {
1316+	var allow bool
1317+	if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error {
1318+		var err error
1319+		allow, err = b.store.GetAllowKeylessAccess(ctx, tx)
1320+		return err
1321+	}); err != nil {
1322+		return false
1323+	}
1324+
1325+	return allow
1326+}
1327+
1328+// SetAllowKeyless sets whether or not keyless access is allowed.
1329+//
1330+// It implements backend.Backend.
1331+func (b *Backend) SetAllowKeyless(ctx context.Context, allow bool) error {
1332+	return b.db.TransactionContext(ctx, func(tx *db.Tx) error {
1333+		return b.store.SetAllowKeylessAccess(ctx, tx, allow)
1334+	})
1335+}
1336+
1337+// AnonAccess returns the level of anonymous access.
1338+//
1339+// It implements backend.Backend.
1340+func (b *Backend) AnonAccess(ctx context.Context) access.AccessLevel {
1341+	var level access.AccessLevel
1342+	if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error {
1343+		var err error
1344+		level, err = b.store.GetAnonAccess(ctx, tx)
1345+		return err
1346+	}); err != nil {
1347+		return access.NoAccess
1348+	}
1349+
1350+	return level
1351+}
1352+
1353+// SetAnonAccess sets the level of anonymous access.
1354+//
1355+// It implements backend.Backend.
1356+func (b *Backend) SetAnonAccess(ctx context.Context, level access.AccessLevel) error {
1357+	return b.db.TransactionContext(ctx, func(tx *db.Tx) error {
1358+		return b.store.SetAnonAccess(ctx, tx, level)
1359+	})
1360 }
1361diff --git a/server/backend/sqlite/db.go b/server/backend/sqlite/db.go
1362deleted file mode 100644
1363index fac0394f4309af33f7c26cc84dcb94904013f512..0000000000000000000000000000000000000000
1364--- a/server/backend/sqlite/db.go
1365+++ /dev/null
1366@@ -1,141 +0,0 @@
1367-package sqlite
1368-
1369-import (
1370-	"context"
1371-	"database/sql"
1372-	"errors"
1373-	"fmt"
1374-
1375-	"github.com/charmbracelet/soft-serve/server/backend"
1376-	"github.com/jmoiron/sqlx"
1377-	"modernc.org/sqlite"
1378-	sqlite3 "modernc.org/sqlite/lib"
1379-)
1380-
1381-// Close closes the database.
1382-func (d *SqliteBackend) Close() error {
1383-	return d.db.Close()
1384-}
1385-
1386-// init creates the database.
1387-func (d *SqliteBackend) init() error {
1388-	return wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
1389-		if _, err := tx.Exec(sqlCreateSettingsTable); err != nil {
1390-			return err
1391-		}
1392-		if _, err := tx.Exec(sqlCreateUserTable); err != nil {
1393-			return err
1394-		}
1395-		if _, err := tx.Exec(sqlCreatePublicKeyTable); err != nil {
1396-			return err
1397-		}
1398-		if _, err := tx.Exec(sqlCreateRepoTable); err != nil {
1399-			return err
1400-		}
1401-		if _, err := tx.Exec(sqlCreateCollabTable); err != nil {
1402-			return err
1403-		}
1404-
1405-		// Set default settings.
1406-		if _, err := tx.Exec("INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", "allow_keyless", true); err != nil {
1407-			return err
1408-		}
1409-		if _, err := tx.Exec("INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", "anon_access", backend.ReadOnlyAccess.String()); err != nil {
1410-			return err
1411-		}
1412-
1413-		var init bool
1414-		if err := tx.Get(&init, "SELECT value FROM settings WHERE key = 'init'"); err != nil && !errors.Is(err, sql.ErrNoRows) {
1415-			return err
1416-		}
1417-
1418-		// Create default user.
1419-		if !init {
1420-			r, err := tx.Exec("INSERT OR IGNORE INTO user (username, admin, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP);", "admin", true)
1421-			if err != nil {
1422-				return err
1423-			}
1424-			userID, err := r.LastInsertId()
1425-			if err != nil {
1426-				return err
1427-			}
1428-
1429-			// Add initial keys
1430-			// Don't use cfg.AdminKeys since it also includes the internal key
1431-			// used for internal api access.
1432-			for _, k := range d.cfg.InitialAdminKeys {
1433-				pk, _, err := backend.ParseAuthorizedKey(k)
1434-				if err != nil {
1435-					d.logger.Error("error parsing initial admin key, skipping", "key", k, "err", err)
1436-					continue
1437-				}
1438-
1439-				stmt, err := tx.Prepare(`INSERT INTO public_key (user_id, public_key, updated_at)
1440-					VALUES (?, ?, CURRENT_TIMESTAMP);`)
1441-				if err != nil {
1442-					return err
1443-				}
1444-
1445-				defer stmt.Close() // nolint: errcheck
1446-				if _, err := stmt.Exec(userID, backend.MarshalAuthorizedKey(pk)); err != nil {
1447-					return err
1448-				}
1449-			}
1450-		}
1451-
1452-		// set init flag
1453-		if _, err := tx.Exec("INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", "init", true); err != nil {
1454-			return err
1455-		}
1456-
1457-		return nil
1458-	})
1459-}
1460-
1461-func wrapDbErr(err error) error {
1462-	if err != nil {
1463-		if errors.Is(err, sql.ErrNoRows) {
1464-			return ErrNoRecord
1465-		}
1466diff --git a/server/backend/sqlite/error.go b/server/backend/sqlite/error.go
1467deleted file mode 100644
1468index 8476f640ccd3fb0728e5521e1e2db2d1ec268ce0..0000000000000000000000000000000000000000
1469--- a/server/backend/sqlite/error.go
1470+++ /dev/null
1471@@ -1,20 +0,0 @@
1472-package sqlite
1473-
1474-import (
1475-	"errors"
1476-	"fmt"
1477-)
1478-
1479-var (
1480-	// ErrDuplicateKey is returned when a unique constraint is violated.
1481-	ErrDuplicateKey = errors.New("record already exists")
1482-
1483-	// ErrNoRecord is returned when a record is not found.
1484-	ErrNoRecord = errors.New("record not found")
1485-
1486-	// ErrRepoNotExist is returned when a repository does not exist.
1487-	ErrRepoNotExist = fmt.Errorf("repository does not exist")
1488-
1489-	// ErrRepoExist is returned when a repository already exists.
1490-	ErrRepoExist = fmt.Errorf("repository already exists")
1491-)
1492diff --git a/server/backend/sqlite/hooks.go b/server/backend/sqlite/hooks.go
1493deleted file mode 100644
1494index 972b3f31d9be55b4dd2547c82e4cbc797dd4110f..0000000000000000000000000000000000000000
1495--- a/server/backend/sqlite/hooks.go
1496+++ /dev/null
1497@@ -1,76 +0,0 @@
1498-package sqlite
1499-
1500-import (
1501-	"io"
1502-	"sync"
1503-
1504-	"github.com/charmbracelet/soft-serve/server/backend"
1505-)
1506-
1507-// PostReceive is called by the git post-receive hook.
1508-//
1509-// It implements Hooks.
1510-func (d *SqliteBackend) PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []backend.HookArg) {
1511-	d.logger.Debug("post-receive hook called", "repo", repo, "args", args)
1512-}
1513-
1514-// PreReceive is called by the git pre-receive hook.
1515-//
1516-// It implements Hooks.
1517-func (d *SqliteBackend) PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []backend.HookArg) {
1518-	d.logger.Debug("pre-receive hook called", "repo", repo, "args", args)
1519-}
1520-
1521-// Update is called by the git update hook.
1522-//
1523-// It implements Hooks.
1524-func (d *SqliteBackend) Update(stdout io.Writer, stderr io.Writer, repo string, arg backend.HookArg) {
1525-	d.logger.Debug("update hook called", "repo", repo, "arg", arg)
1526-}
1527-
1528-// PostUpdate is called by the git post-update hook.
1529-//
1530-// It implements Hooks.
1531-func (d *SqliteBackend) PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string) {
1532-	d.logger.Debug("post-update hook called", "repo", repo, "args", args)
1533-
1534-	var wg sync.WaitGroup
1535-
1536-	// Populate last-modified file.
1537-	wg.Add(1)
1538-	go func() {
1539-		defer wg.Done()
1540-		if err := populateLastModified(d, repo); err != nil {
1541-			d.logger.Error("error populating last-modified", "repo", repo, "err", err)
1542-			return
1543-		}
1544-	}()
1545-
1546-	wg.Wait()
1547-}
1548-
1549-func populateLastModified(d *SqliteBackend, repo string) error {
1550-	var rr *Repo
1551-	_rr, err := d.Repository(repo)
1552-	if err != nil {
1553-		return err
1554-	}
1555-
1556-	if r, ok := _rr.(*Repo); ok {
1557-		rr = r
1558-	} else {
1559-		return ErrRepoNotExist
1560-	}
1561-
1562-	r, err := rr.Open()
1563-	if err != nil {
1564-		return err
1565-	}
1566-
1567-	c, err := r.LatestCommitTime()
1568-	if err != nil {
1569-		return err
1570-	}
1571-
1572-	return rr.writeLastModified(c)
1573-}
1574diff --git a/server/backend/sqlite/repo.go b/server/backend/sqlite/repo.go
1575deleted file mode 100644
1576index 34b5e6baa597d7271f61e30189c006105ce8aee0..0000000000000000000000000000000000000000
1577--- a/server/backend/sqlite/repo.go
1578+++ /dev/null
1579@@ -1,202 +0,0 @@
1580-package sqlite
1581-
1582-import (
1583-	"bufio"
1584-	"context"
1585-	"os"
1586-	"path/filepath"
1587-	"sync"
1588-	"time"
1589-
1590-	"github.com/charmbracelet/soft-serve/git"
1591-	"github.com/charmbracelet/soft-serve/server/backend"
1592-	"github.com/jmoiron/sqlx"
1593-)
1594-
1595-var _ backend.Repository = (*Repo)(nil)
1596-
1597-// Repo is a Git repository with metadata stored in a SQLite database.
1598-type Repo struct {
1599-	name string
1600-	path string
1601-	db   *sqlx.DB
1602-
1603-	// cache
1604-	// updatedAt is cached in "last-modified" file.
1605-	mu          sync.Mutex
1606-	desc        *string
1607-	projectName *string
1608-	isMirror    *bool
1609-	isPrivate   *bool
1610-	isHidden    *bool
1611-}
1612-
1613-// Description returns the repository's description.
1614-//
1615-// It implements backend.Repository.
1616-func (r *Repo) Description() string {
1617-	r.mu.Lock()
1618-	defer r.mu.Unlock()
1619-	if r.desc != nil {
1620-		return *r.desc
1621-	}
1622-
1623-	var desc string
1624-	if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error {
1625-		return tx.Get(&desc, "SELECT description FROM repo WHERE name = ?", r.name)
1626-	}); err != nil {
1627-		return ""
1628-	}
1629-
1630-	r.desc = &desc
1631-	return desc
1632-}
1633-
1634-// IsMirror returns whether the repository is a mirror.
1635-//
1636-// It implements backend.Repository.
1637-func (r *Repo) IsMirror() bool {
1638-	r.mu.Lock()
1639-	defer r.mu.Unlock()
1640-	if r.isMirror != nil {
1641-		return *r.isMirror
1642-	}
1643-
1644-	var mirror bool
1645-	if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error {
1646-		return tx.Get(&mirror, "SELECT mirror FROM repo WHERE name = ?", r.name)
1647-	}); err != nil {
1648-		return false
1649-	}
1650-
1651-	r.isMirror = &mirror
1652-	return mirror
1653-}
1654-
1655-// IsPrivate returns whether the repository is private.
1656-//
1657-// It implements backend.Repository.
1658-func (r *Repo) IsPrivate() bool {
1659-	r.mu.Lock()
1660-	defer r.mu.Unlock()
1661-	if r.isPrivate != nil {
1662-		return *r.isPrivate
1663-	}
1664-
1665-	var private bool
1666-	if err := wrapTx(r.db, context.Background(), func(tx *sqlx.Tx) error {
1667-		return tx.Get(&private, "SELECT private FROM repo WHERE name = ?", r.name)
1668-	}); err != nil {
1669-		return false
1670-	}
1671-
1672-	r.isPrivate = &private
1673-	return private
1674-}
1675-
1676-// Name returns the repository's name.
1677-//
1678-// It implements backend.Repository.
1679diff --git a/server/backend/sqlite/sql.go b/server/backend/sqlite/sql.go
1680deleted file mode 100644
1681index 34edd17f72485256186c2f7267e14da0661c437b..0000000000000000000000000000000000000000
1682--- a/server/backend/sqlite/sql.go
1683+++ /dev/null
1684@@ -1,61 +0,0 @@
1685-package sqlite
1686-
1687-var (
1688-	sqlCreateSettingsTable = `CREATE TABLE IF NOT EXISTS settings (
1689-		id INTEGER PRIMARY KEY AUTOINCREMENT,
1690-		key TEXT NOT NULL UNIQUE,
1691-		value TEXT NOT NULL,
1692-		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
1693-		updated_at DATETIME NOT NULL
1694-	);`
1695-
1696-	sqlCreateUserTable = `CREATE TABLE IF NOT EXISTS user (
1697-		id INTEGER PRIMARY KEY AUTOINCREMENT,
1698-		username TEXT UNIQUE,
1699-		admin BOOLEAN NOT NULL,
1700-		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
1701-		updated_at DATETIME NOT NULL
1702-	);`
1703-
1704-	sqlCreatePublicKeyTable = `CREATE TABLE IF NOT EXISTS public_key (
1705-		id INTEGER PRIMARY KEY AUTOINCREMENT,
1706-		user_id INTEGER NOT NULL,
1707-		public_key TEXT NOT NULL UNIQUE,
1708-		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
1709-		updated_at DATETIME NOT NULL,
1710-		UNIQUE (user_id, public_key),
1711-		CONSTRAINT user_id_fk
1712-		FOREIGN KEY(user_id) REFERENCES user(id)
1713-		ON DELETE CASCADE
1714-		ON UPDATE CASCADE
1715-	);`
1716-
1717-	sqlCreateRepoTable = `CREATE TABLE IF NOT EXISTS repo (
1718-		id INTEGER PRIMARY KEY AUTOINCREMENT,
1719-		name TEXT NOT NULL UNIQUE,
1720-		project_name TEXT NOT NULL,
1721-		description TEXT NOT NULL,
1722-		private BOOLEAN NOT NULL,
1723-		mirror BOOLEAN NOT NULL,
1724-		hidden BOOLEAN NOT NULL,
1725-		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
1726-		updated_at DATETIME NOT NULL
1727-	);`
1728-
1729-	sqlCreateCollabTable = `CREATE TABLE IF NOT EXISTS collab (
1730-		id INTEGER PRIMARY KEY AUTOINCREMENT,
1731-		user_id INTEGER NOT NULL,
1732-		repo_id INTEGER NOT NULL,
1733-		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
1734-		updated_at DATETIME NOT NULL,
1735-		UNIQUE (user_id, repo_id),
1736-		CONSTRAINT user_id_fk
1737-		FOREIGN KEY(user_id) REFERENCES user(id)
1738-		ON DELETE CASCADE
1739-		ON UPDATE CASCADE,
1740-		CONSTRAINT repo_id_fk
1741-		FOREIGN KEY(repo_id) REFERENCES repo(id)
1742-		ON DELETE CASCADE
1743-		ON UPDATE CASCADE
1744-	);`
1745-)
1746diff --git a/server/backend/sqlite/sqlite.go b/server/backend/sqlite/sqlite.go
1747deleted file mode 100644
1748index 3273373ea54e180ce3821f9178f2a8d81e5c6031..0000000000000000000000000000000000000000
1749--- a/server/backend/sqlite/sqlite.go
1750+++ /dev/null
1751@@ -1,649 +0,0 @@
1752-package sqlite
1753-
1754-import (
1755-	"context"
1756-	"errors"
1757-	"fmt"
1758-	"os"
1759-	"path/filepath"
1760-	"strings"
1761-
1762-	"github.com/charmbracelet/log"
1763-	"github.com/charmbracelet/soft-serve/git"
1764-	"github.com/charmbracelet/soft-serve/server/backend"
1765-	"github.com/charmbracelet/soft-serve/server/config"
1766-	"github.com/charmbracelet/soft-serve/server/hooks"
1767-	"github.com/charmbracelet/soft-serve/server/utils"
1768-	lru "github.com/hashicorp/golang-lru/v2"
1769-	"github.com/jmoiron/sqlx"
1770-	_ "modernc.org/sqlite" // sqlite driver
1771-)
1772-
1773-// SqliteBackend is a backend that uses a SQLite database as a Soft Serve
1774-// backend.
1775-type SqliteBackend struct { //nolint: revive
1776-	cfg    *config.Config
1777-	ctx    context.Context
1778-	dp     string
1779-	db     *sqlx.DB
1780-	logger *log.Logger
1781-
1782-	// Repositories cache
1783-	cache *cache
1784-}
1785-
1786-var _ backend.Backend = (*SqliteBackend)(nil)
1787-
1788-func (d *SqliteBackend) reposPath() string {
1789-	return filepath.Join(d.dp, "repos")
1790-}
1791-
1792-// NewSqliteBackend creates a new SqliteBackend.
1793-func NewSqliteBackend(ctx context.Context) (*SqliteBackend, error) {
1794-	cfg := config.FromContext(ctx)
1795-	dataPath := cfg.DataPath
1796-	if err := os.MkdirAll(dataPath, os.ModePerm); err != nil {
1797-		return nil, err
1798-	}
1799-
1800-	db, err := sqlx.Connect("sqlite", filepath.Join(dataPath, "soft-serve.db"+
1801-		"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"))
1802-	if err != nil {
1803-		return nil, err
1804-	}
1805-
1806-	d := &SqliteBackend{
1807-		cfg:    cfg,
1808-		ctx:    ctx,
1809-		dp:     dataPath,
1810-		db:     db,
1811-		logger: log.FromContext(ctx).WithPrefix("sqlite"),
1812-	}
1813-
1814-	// Set up LRU cache with size 1000
1815-	d.cache = newCache(d, 1000)
1816-
1817-	if err := d.init(); err != nil {
1818-		return nil, err
1819-	}
1820-
1821-	if err := d.db.Ping(); err != nil {
1822-		return nil, err
1823-	}
1824-
1825-	return d, d.initRepos()
1826-}
1827-
1828-// WithContext returns a copy of SqliteBackend with the given context.
1829-func (d SqliteBackend) WithContext(ctx context.Context) backend.Backend {
1830-	d.ctx = ctx
1831-	return &d
1832-}
1833-
1834-// AllowKeyless returns whether or not keyless access is allowed.
1835-//
1836-// It implements backend.Backend.
1837-func (d *SqliteBackend) AllowKeyless() bool {
1838-	var allow bool
1839-	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
1840-		return tx.Get(&allow, "SELECT value FROM settings WHERE key = ?;", "allow_keyless")
1841-	}); err != nil {
1842-		return false
1843-	}
1844-
1845-	return allow
1846-}
1847-
1848-// AnonAccess returns the level of anonymous access.
1849-//
1850-// It implements backend.Backend.
1851diff --git a/server/backend/sqlite/user.go b/server/backend/sqlite/user.go
1852deleted file mode 100644
1853index 5e977a6a7af677b27618eb4baa20f6904f578e3e..0000000000000000000000000000000000000000
1854--- a/server/backend/sqlite/user.go
1855+++ /dev/null
1856@@ -1,365 +0,0 @@
1857-package sqlite
1858-
1859-import (
1860-	"context"
1861-	"strings"
1862-
1863-	"github.com/charmbracelet/soft-serve/server/backend"
1864-	"github.com/charmbracelet/soft-serve/server/utils"
1865-	"github.com/jmoiron/sqlx"
1866-	"golang.org/x/crypto/ssh"
1867-)
1868-
1869-// User represents a user.
1870-type User struct {
1871-	username string
1872-	db       *sqlx.DB
1873-}
1874-
1875-var _ backend.User = (*User)(nil)
1876-
1877-// IsAdmin returns whether the user is an admin.
1878-//
1879-// It implements backend.User.
1880-func (u *User) IsAdmin() bool {
1881-	var admin bool
1882-	if err := wrapTx(u.db, context.Background(), func(tx *sqlx.Tx) error {
1883-		return tx.Get(&admin, "SELECT admin FROM user WHERE username = ?", u.username)
1884-	}); err != nil {
1885-		return false
1886-	}
1887-
1888-	return admin
1889-}
1890-
1891-// PublicKeys returns the user's public keys.
1892-//
1893-// It implements backend.User.
1894-func (u *User) PublicKeys() []ssh.PublicKey {
1895-	var keys []ssh.PublicKey
1896-	if err := wrapTx(u.db, context.Background(), func(tx *sqlx.Tx) error {
1897-		var keyStrings []string
1898-		if err := tx.Select(&keyStrings, `SELECT public_key
1899-			FROM public_key
1900-			INNER JOIN user ON user.id = public_key.user_id
1901-			WHERE user.username = ?
1902-			ORDER BY public_key.id asc;`, u.username); err != nil {
1903-			return err
1904-		}
1905-
1906-		for _, keyString := range keyStrings {
1907-			key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyString))
1908-			if err != nil {
1909-				return err
1910-			}
1911-			keys = append(keys, key)
1912-		}
1913-
1914-		return nil
1915-	}); err != nil {
1916-		return nil
1917-	}
1918-
1919-	return keys
1920-}
1921-
1922-// Username returns the user's username.
1923-//
1924-// It implements backend.User.
1925-func (u *User) Username() string {
1926-	return u.username
1927-}
1928-
1929-// AccessLevel returns the access level of a user for a repository.
1930-//
1931-// It implements backend.Backend.
1932-func (d *SqliteBackend) AccessLevel(repo string, username string) backend.AccessLevel {
1933-	anon := d.AnonAccess()
1934-	user, _ := d.User(username)
1935-	// If the user is an admin, they have admin access.
1936-	if user != nil && user.IsAdmin() {
1937-		return backend.AdminAccess
1938-	}
1939-
1940-	// If the repository exists, check if the user is a collaborator.
1941-	r, _ := d.Repository(repo)
1942-	if r != nil {
1943-		// If the user is a collaborator, they have read/write access.
1944-		isCollab, _ := d.IsCollaborator(repo, username)
1945-		if isCollab {
1946-			if anon > backend.ReadWriteAccess {
1947-				return anon
1948-			}
1949-			return backend.ReadWriteAccess
1950-		}
1951-
1952-		// If the repository is private, the user has no access.
1953-		if r.IsPrivate() {
1954-			return backend.NoAccess
1955-		}
1956diff --git a/server/backend/user.go b/server/backend/user.go
1957index b0b5f1115dbe3760dbf6972c0eceaeb7f5fbf79a..edefaf686e55eea1b08e43e9fb9a5809ba4d61b7 100644
1958--- a/server/backend/user.go
1959+++ b/server/backend/user.go
1960@@ -1,55 +1,289 @@
1961 package backend
1962 
1963 import (
1964+	"context"
1965+	"strings"
1966+
1967+	"github.com/charmbracelet/soft-serve/server/access"
1968+	"github.com/charmbracelet/soft-serve/server/db"
1969+	"github.com/charmbracelet/soft-serve/server/db/models"
1970+	"github.com/charmbracelet/soft-serve/server/proto"
1971+	"github.com/charmbracelet/soft-serve/server/sshutils"
1972+	"github.com/charmbracelet/soft-serve/server/utils"
1973 	"golang.org/x/crypto/ssh"
1974 )
1975 
1976-// User is an interface representing a user.
1977-type User interface {
1978-	// Username returns the user's username.
1979-	Username() string
1980-	// IsAdmin returns whether the user is an admin.
1981-	IsAdmin() bool
1982-	// PublicKeys returns the user's public keys.
1983-	PublicKeys() []ssh.PublicKey
1984-}
1985-
1986-// UserAccess is an interface that handles user access to repositories.
1987-type UserAccess interface {
1988-	// AccessLevel returns the access level of the username to the repository.
1989-	AccessLevel(repo string, username string) AccessLevel
1990-	// AccessLevelByPublicKey returns the access level of the public key to the repository.
1991-	AccessLevelByPublicKey(repo string, pk ssh.PublicKey) AccessLevel
1992-}
1993-
1994-// UserStore is an interface for managing users.
1995-type UserStore interface {
1996-	// User finds the given user.
1997-	User(username string) (User, error)
1998-	// UserByPublicKey finds the user with the given public key.
1999-	UserByPublicKey(pk ssh.PublicKey) (User, error)
2000-	// Users returns a list of all users.
2001-	Users() ([]string, error)
2002-	// CreateUser creates a new user.
2003-	CreateUser(username string, opts UserOptions) (User, error)
2004-	// DeleteUser deletes a user.
2005-	DeleteUser(username string) error
2006-	// SetUsername sets the username of the user.
2007-	SetUsername(oldUsername string, newUsername string) error
2008-	// SetAdmin sets whether the user is an admin.
2009-	SetAdmin(username string, admin bool) error
2010-	// AddPublicKey adds a public key to the user.
2011-	AddPublicKey(username string, pk ssh.PublicKey) error
2012-	// RemovePublicKey removes a public key from the user.
2013-	RemovePublicKey(username string, pk ssh.PublicKey) error
2014-	// ListPublicKeys lists the public keys of the user.
2015-	ListPublicKeys(username string) ([]ssh.PublicKey, error)
2016-}
2017-
2018-// UserOptions are options for creating a user.
2019-type UserOptions struct {
2020-	// Admin is whether the user is an admin.
2021-	Admin bool
2022-	// PublicKeys are the user's public keys.
2023-	PublicKeys []ssh.PublicKey
2024+// AccessLevel returns the access level of a user for a repository.
2025+//
2026+// It implements backend.Backend.
2027+func (d *Backend) AccessLevel(ctx context.Context, repo string, username string) access.AccessLevel {
2028+	anon := d.AnonAccess(ctx)
2029+	user, _ := d.User(ctx, username)
2030+	// If the user is an admin, they have admin access.
2031+	if user != nil && user.IsAdmin() {
2032+		return access.AdminAccess
2033+	}
2034+
2035+	// If the repository exists, check if the user is a collaborator.
2036+	r, _ := d.Repository(ctx, repo)
2037+	if r != nil {
2038+		// If the user is a collaborator, they have read/write access.
2039+		isCollab, _ := d.IsCollaborator(ctx, repo, username)
2040+		if isCollab {
2041+			if anon > access.ReadWriteAccess {
2042+				return anon
2043+			}
2044+			return access.ReadWriteAccess
2045+		}
2046+
2047+		// If the repository is private, the user has no access.
2048+		if r.IsPrivate() {
2049+			return access.NoAccess
2050+		}
2051+
2052+		// Otherwise, the user has read-only access.
2053+		return access.ReadOnlyAccess
2054+	}
2055+
2056+	if user != nil {
2057+		// If the repository doesn't exist, the user has read/write access.
2058+		if anon > access.ReadWriteAccess {
2059+			return anon
2060diff --git a/server/backend/utils.go b/server/backend/utils.go
2061index 03d3cccda37c7010e749bd5203d15f20ea2a9f65..024ba8af228a6741d1e957580f90ae722c05584f 100644
2062--- a/server/backend/utils.go
2063+++ b/server/backend/utils.go
2064@@ -2,11 +2,12 @@ package backend
2065 
2066 import (
2067 	"github.com/charmbracelet/soft-serve/git"
2068+	"github.com/charmbracelet/soft-serve/server/proto"
2069 )
2070 
2071 // LatestFile returns the contents of the latest file at the specified path in
2072 // the repository and its file path.
2073-func LatestFile(r Repository, pattern string) (string, string, error) {
2074+func LatestFile(r proto.Repository, pattern string) (string, string, error) {
2075 	repo, err := r.Open()
2076 	if err != nil {
2077 		return "", "", err
2078@@ -15,7 +16,7 @@ func LatestFile(r Repository, pattern string) (string, string, error) {
2079 }
2080 
2081 // Readme returns the repository's README.
2082-func Readme(r Repository) (readme string, path string, err error) {
2083+func Readme(r proto.Repository) (readme string, path string, err error) {
2084 	pattern := "[rR][eE][aA][dD][mM][eE]*"
2085 	readme, path, err = LatestFile(r, pattern)
2086 	return
2087diff --git a/server/cmd/set_username.go b/server/cmd/set_username.go
2088deleted file mode 100644
2089index 152403a57c68fa18035f7392b2bb67e7b06c977d..0000000000000000000000000000000000000000
2090--- a/server/cmd/set_username.go
2091+++ /dev/null
2092@@ -1,22 +0,0 @@
2093-package cmd
2094-
2095-import "github.com/spf13/cobra"
2096-
2097-func setUsernameCommand() *cobra.Command {
2098-	cmd := &cobra.Command{
2099-		Use:   "set-username USERNAME",
2100-		Short: "Set your username",
2101-		Args:  cobra.ExactArgs(1),
2102-		RunE: func(cmd *cobra.Command, args []string) error {
2103-			cfg, s := fromContext(cmd)
2104-			user, err := cfg.Backend.UserByPublicKey(s.PublicKey())
2105-			if err != nil {
2106-				return err
2107-			}
2108-
2109-			return cfg.Backend.SetUsername(user.Username(), args[0])
2110-		},
2111-	}
2112-
2113-	return cmd
2114-}
2115diff --git a/server/config/config.go b/server/config/config.go
2116index c8b9fa9bf159e24edcb4ce52f89374a66d6eb925..d815dcc465bdfaa6e0529863cdd6b5d3773a432f 100644
2117--- a/server/config/config.go
2118+++ b/server/config/config.go
2119@@ -1,17 +1,15 @@
2120 package config
2121 
2122 import (
2123-	"context"
2124-	"errors"
2125 	"fmt"
2126 	"os"
2127 	"path/filepath"
2128+	"strconv"
2129 	"strings"
2130 	"time"
2131 
2132 	"github.com/caarlos0/env/v8"
2133-	"github.com/charmbracelet/log"
2134-	"github.com/charmbracelet/soft-serve/server/backend"
2135+	"github.com/charmbracelet/soft-serve/server/sshutils"
2136 	"golang.org/x/crypto/ssh"
2137 	"gopkg.in/yaml.v3"
2138 )
2139@@ -82,6 +80,19 @@ type LogConfig struct {
2140 	// Time format for the log `ts` field.
2141 	// Format must be described in Golang's time format.
2142 	TimeFormat string `env:"TIME_FORMAT" yaml:"time_format"`
2143+
2144+	// Path to a file to write logs to.
2145+	// If not set, logs will be written to stderr.
2146+	Path string `env:"PATH" yaml:"path"`
2147+}
2148+
2149+// DBConfig is the database connection configuration.
2150+type DBConfig struct {
2151+	// Driver is the driver for the database.
2152+	Driver string `env:"DRIVER" yaml:"driver"`
2153+
2154+	// DataSource is the database data source name.
2155+	DataSource string `env:"DATA_SOURCE" yaml:"data_source"`
2156 }
2157 
2158 // Config is the configuration for Soft Serve.
2159@@ -104,14 +115,14 @@ type Config struct {
2160 	// Log is the logger configuration.
2161 	Log LogConfig `envPrefix:"LOG_" yaml:"log"`
2162 
2163+	// DB is the database configuration.
2164+	DB DBConfig `envPrefix:"DB_" yaml:"db"`
2165+
2166 	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
2167 	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
2168 
2169 	// DataPath is the path to the directory where Soft Serve will store its data.
2170 	DataPath string `env:"DATA_PATH" yaml:"-"`
2171-
2172-	// Backend is the Git backend to use.
2173-	Backend backend.Backend `yaml:"-"`
2174 }
2175 
2176 // Environ returns the config as a list of environment variables.
2177@@ -123,8 +134,8 @@ func (c *Config) Environ() []string {
2178 
2179 	// TODO: do this dynamically
2180 	envs = append(envs, []string{
2181-		fmt.Sprintf("SOFT_SERVE_NAME=%s", c.Name),
2182 		fmt.Sprintf("SOFT_SERVE_DATA_PATH=%s", c.DataPath),
2183+		fmt.Sprintf("SOFT_SERVE_NAME=%s", c.Name),
2184 		fmt.Sprintf("SOFT_SERVE_INITIAL_ADMIN_KEYS=%s", strings.Join(c.InitialAdminKeys, "\n")),
2185 		fmt.Sprintf("SOFT_SERVE_SSH_LISTEN_ADDR=%s", c.SSH.ListenAddr),
2186 		fmt.Sprintf("SOFT_SERVE_SSH_PUBLIC_URL=%s", c.SSH.PublicURL),
2187@@ -143,51 +154,50 @@ func (c *Config) Environ() []string {
2188 		fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr),
2189 		fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format),
2190 		fmt.Sprintf("SOFT_SERVE_LOG_TIME_FORMAT=%s", c.Log.TimeFormat),
2191+		fmt.Sprintf("SOFT_SERVE_DB_DRIVER=%s", c.DB.Driver),
2192+		fmt.Sprintf("SOFT_SERVE_DB_DATA_SOURCE=%s", c.DB.DataSource),
2193 	}...)
2194 
2195 	return envs
2196 }
2197 
2198-func parseConfig(path string) (*Config, error) {
2199-	dataPath := filepath.Dir(path)
2200-	cfg := &Config{
2201-		Name:     "Soft Serve",
2202-		DataPath: dataPath,
2203-		SSH: SSHConfig{
2204-			ListenAddr:    ":23231",
2205-			PublicURL:     "ssh://localhost:23231",
2206-			KeyPath:       filepath.Join("ssh", "soft_serve_host_ed25519"),
2207-			ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
2208-			MaxTimeout:    0,
2209-			IdleTimeout:   0,
2210-		},
2211-		Git: GitConfig{
2212-			ListenAddr:     ":9418",
2213-			MaxTimeout:     0,
2214-			IdleTimeout:    3,
2215-			MaxConnections: 32,
2216-		},
2217-		HTTP: HTTPConfig{
2218-			ListenAddr: ":23232",
2219diff --git a/server/config/config_test.go b/server/config/config_test.go
2220index 3812e4f9e72b871f3611b53ee2a1db1a12c803cd..503e4de49e349a1464433c76ed138a069ec9df6b 100644
2221--- a/server/config/config_test.go
2222+++ b/server/config/config_test.go
2223@@ -2,11 +2,9 @@ package config
2224 
2225 import (
2226 	"os"
2227-	"path/filepath"
2228 	"testing"
2229 
2230 	"github.com/matryer/is"
2231-	"gopkg.in/yaml.v3"
2232 )
2233 
2234 func TestParseMultipleKeys(t *testing.T) {
2235@@ -19,6 +17,7 @@ func TestParseMultipleKeys(t *testing.T) {
2236 		is.NoErr(os.Unsetenv("SOFT_SERVE_DATA_PATH"))
2237 	})
2238 	cfg := DefaultConfig()
2239+	is.NoErr(cfg.ParseEnv())
2240 	is.Equal(cfg.InitialAdminKeys, []string{
2241 		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH",
2242 		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8",
2243@@ -29,15 +28,12 @@ func TestMergeInitAdminKeys(t *testing.T) {
2244 	is := is.New(t)
2245 	is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEYS", "testdata/k1.pub"))
2246 	t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEYS")) })
2247-	bts, err := yaml.Marshal(&Config{
2248+	cfg := &Config{
2249+		DataPath:         t.TempDir(),
2250 		InitialAdminKeys: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b"},
2251-	})
2252-	is.NoErr(err)
2253-	fp := filepath.Join(t.TempDir(), "config.yaml")
2254-	err = os.WriteFile(fp, bts, 0o644)
2255-	is.NoErr(err)
2256-	cfg, err := ParseConfig(fp)
2257-	is.NoErr(err)
2258+	}
2259+	is.NoErr(cfg.WriteConfig())
2260+	is.NoErr(cfg.Parse())
2261 	is.Equal(cfg.InitialAdminKeys, []string{
2262 		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH",
2263 		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8",
2264@@ -46,19 +42,16 @@ func TestMergeInitAdminKeys(t *testing.T) {
2265 
2266 func TestValidateInitAdminKeys(t *testing.T) {
2267 	is := is.New(t)
2268-	bts, err := yaml.Marshal(&Config{
2269+	cfg := &Config{
2270+		DataPath: t.TempDir(),
2271 		InitialAdminKeys: []string{
2272 			"testdata/k1.pub",
2273 			"abc",
2274 			"",
2275 		},
2276-	})
2277-	is.NoErr(err)
2278-	fp := filepath.Join(t.TempDir(), "config.yaml")
2279-	err = os.WriteFile(fp, bts, 0o644)
2280-	is.NoErr(err)
2281-	cfg, err := ParseConfig(fp)
2282-	is.NoErr(err)
2283+	}
2284+	is.NoErr(cfg.WriteConfig())
2285+	is.NoErr(cfg.Parse())
2286 	is.Equal(cfg.InitialAdminKeys, []string{
2287 		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH",
2288 	})
2289diff --git a/server/config/context.go b/server/config/context.go
2290new file mode 100644
2291index 0000000000000000000000000000000000000000..2a9c47bcf3433870310ce950e0fd0743e8f84b4c
2292--- /dev/null
2293+++ b/server/config/context.go
2294@@ -0,0 +1,20 @@
2295+package config
2296+
2297+import "context"
2298+
2299+// ContextKey is the context key for the config.
2300+var ContextKey = struct{ string }{"config"}
2301+
2302+// WithContext returns a new context with the configuration attached.
2303+func WithContext(ctx context.Context, cfg *Config) context.Context {
2304+	return context.WithValue(ctx, ContextKey, cfg)
2305+}
2306+
2307+// FromContext returns the configuration from the context.
2308+func FromContext(ctx context.Context) *Config {
2309+	if c, ok := ctx.Value(ContextKey).(*Config); ok {
2310+		return c
2311+	}
2312+
2313+	return DefaultConfig()
2314+}
2315diff --git a/server/config/file.go b/server/config/file.go
2316index 09e5ce2e00dec68891f27e63bd5cf14fa1faca2c..a3b78c0160d57909f9f120489ed87a41daf489fc 100644
2317--- a/server/config/file.go
2318+++ b/server/config/file.go
2319@@ -18,6 +18,8 @@ log:
2320   # Time format for the log "timestamp" field.
2321   # Should be described in Golang's time format.
2322   time_format: "{{ .Log.TimeFormat }}"
2323+  # Path to the log file. Leave empty to write to stderr.
2324+  #path: "{{ .Log.Path }}"
2325 
2326 # The SSH server configuration.
2327 ssh:
2328@@ -79,6 +81,15 @@ stats:
2329   # The address on which the stats server will listen.
2330   listen_addr: "{{ .Stats.ListenAddr }}"
2331 
2332+# The database configuration.
2333+db:
2334+  # The database driver to use.
2335+  # Valid values are "sqlite" and "postgres".
2336+  driver: "{{ .DB.Driver }}"
2337+  # The database data source name.
2338+  # This is driver specific and can be a file path or connection string.
2339+  data_source: "{{ .DB.DataSource }}"
2340+
2341 # Additional admin keys.
2342 #initial_admin_keys:
2343 #  - "ssh-rsa AAAAB3NzaC1yc2..."
2344diff --git a/server/cron/cron.go b/server/cron/cron.go
2345index af4baf0ad5f83d8398e403ffe15c124833b1dbda..aae506ed54d09edff27558ba2664079e8eafab5f 100644
2346--- a/server/cron/cron.go
2347+++ b/server/cron/cron.go
2348@@ -8,17 +8,9 @@ import (
2349 	"github.com/robfig/cron/v3"
2350 )
2351 
2352-// CronScheduler is a cron-like job scheduler.
2353-type CronScheduler struct {
2354+// Scheduler is a cron-like job scheduler.
2355+type Scheduler struct {
2356 	*cron.Cron
2357-	logger cron.Logger
2358-}
2359-
2360-// Entry is a cron job.
2361-type Entry struct {
2362-	ID   cron.EntryID
2363-	Desc string
2364-	Spec string
2365 }
2366 
2367 // cronLogger is a wrapper around the logger to make it compatible with the
2368@@ -37,22 +29,22 @@ func (l cronLogger) Error(err error, msg string, keysAndValues ...interface{}) {
2369 	l.logger.Error(msg, append(keysAndValues, "err", err)...)
2370 }
2371 
2372-// NewCronScheduler returns a new Cron.
2373-func NewCronScheduler(ctx context.Context) *CronScheduler {
2374+// NewScheduler returns a new Cron.
2375+func NewScheduler(ctx context.Context) *Scheduler {
2376 	logger := cronLogger{log.FromContext(ctx).WithPrefix("cron")}
2377-	return &CronScheduler{
2378+	return &Scheduler{
2379 		Cron: cron.New(cron.WithLogger(logger)),
2380 	}
2381 }
2382 
2383-// Shutdonw gracefully shuts down the CronServer.
2384-func (s *CronScheduler) Shutdown() {
2385+// Shutdonw gracefully shuts down the Scheduler.
2386+func (s *Scheduler) Shutdown() {
2387 	ctx, cancel := context.WithTimeout(s.Cron.Stop(), 30*time.Second)
2388 	defer func() { cancel() }()
2389 	<-ctx.Done()
2390 }
2391 
2392-// Start starts the CronServer.
2393-func (s *CronScheduler) Start() {
2394+// Start starts the Scheduler.
2395+func (s *Scheduler) Start() {
2396 	s.Cron.Start()
2397 }
2398diff --git a/server/daemon/conn.go b/server/daemon/conn.go
2399index 090d76aeecc3ff3ab847e036753ddfdaa3c3705b..b4a342309904f83d2f9c5c1555fefe7390280d48 100644
2400--- a/server/daemon/conn.go
2401+++ b/server/daemon/conn.go
2402@@ -91,15 +91,15 @@ func (c *serverConn) updateDeadline() {
2403 		initTimeout := time.Now().Add(c.initTimeout)
2404 		c.initTimeout = 0
2405 		if initTimeout.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {
2406-			c.Conn.SetDeadline(initTimeout)
2407+			c.Conn.SetDeadline(initTimeout) // nolint: errcheck
2408 			return
2409 		}
2410 	case c.idleTimeout > 0:
2411 		idleDeadline := time.Now().Add(c.idleTimeout)
2412 		if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {
2413-			c.Conn.SetDeadline(idleDeadline)
2414+			c.Conn.SetDeadline(idleDeadline) // nolint: errcheck
2415 			return
2416 		}
2417 	}
2418-	c.Conn.SetDeadline(c.maxDeadline)
2419+	c.Conn.SetDeadline(c.maxDeadline) // nolint: errcheck
2420 }
2421diff --git a/server/daemon/daemon.go b/server/daemon/daemon.go
2422index 1820c0e3e2c141f79488dac97537e5655a2ad4d5..fcd0ae4f92b1d8a39bcd365f94da2713d308a9bb 100644
2423--- a/server/daemon/daemon.go
2424+++ b/server/daemon/daemon.go
2425@@ -11,6 +11,7 @@ import (
2426 	"time"
2427 
2428 	"github.com/charmbracelet/log"
2429+	"github.com/charmbracelet/soft-serve/server/access"
2430 	"github.com/charmbracelet/soft-serve/server/backend"
2431 	"github.com/charmbracelet/soft-serve/server/config"
2432 	"github.com/charmbracelet/soft-serve/server/git"
2433@@ -50,7 +51,7 @@ type GitDaemon struct {
2434 	finished chan struct{}
2435 	conns    connections
2436 	cfg      *config.Config
2437-	be       backend.Backend
2438+	be       *backend.Backend
2439 	wg       sync.WaitGroup
2440 	once     sync.Once
2441 	logger   *log.Logger
2442@@ -94,7 +95,7 @@ func (d *GitDaemon) Start() error {
2443 			default:
2444 				d.logger.Debugf("git: error accepting connection: %v", err)
2445 			}
2446-			if ne, ok := err.(net.Error); ok && ne.Temporary() {
2447+			if ne, ok := err.(net.Error); ok && ne.Temporary() { // nolint: staticcheck
2448 				if tempDelay == 0 {
2449 					tempDelay = 5 * time.Millisecond
2450 				} else {
2451@@ -125,7 +126,7 @@ func (d *GitDaemon) Start() error {
2452 }
2453 
2454 func (d *GitDaemon) fatal(c net.Conn, err error) {
2455-	git.WritePktline(c, err)
2456+	git.WritePktlineErr(c, err) // nolint: errcheck
2457 	if err := c.Close(); err != nil {
2458 		d.logger.Debugf("git: error closing connection: %v", err)
2459 	}
2460@@ -146,7 +147,7 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
2461 	}
2462 	d.conns.Add(c)
2463 	defer func() {
2464-		d.conns.Close(c)
2465+		d.conns.Close(c) // nolint: errcheck
2466 	}()
2467 
2468 	readc := make(chan struct{}, 1)
2469@@ -227,8 +228,8 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
2470 			}
2471 		}
2472 
2473-		be := d.be.WithContext(ctx)
2474-		if !be.AllowKeyless() {
2475+		be := d.be
2476+		if !be.AllowKeyless(ctx) {
2477 			d.fatal(c, git.ErrNotAuthed)
2478 			return
2479 		}
2480@@ -247,13 +248,13 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
2481 			return
2482 		}
2483 
2484-		if _, err := d.be.Repository(repo); err != nil {
2485+		if _, err := d.be.Repository(ctx, repo); err != nil {
2486 			d.fatal(c, git.ErrInvalidRepo)
2487 			return
2488 		}
2489 
2490-		auth := be.AccessLevel(name, "")
2491-		if auth < backend.ReadOnlyAccess {
2492+		auth := be.AccessLevel(ctx, name, "")
2493+		if auth < access.ReadOnlyAccess {
2494 			d.fatal(c, git.ErrNotAuthed)
2495 			return
2496 		}
2497@@ -263,6 +264,7 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
2498 			"SOFT_SERVE_REPO_NAME=" + name,
2499 			"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
2500 			"SOFT_SERVE_HOST=" + host,
2501+			"SOFT_SERVE_LOG_PATH=" + filepath.Join(d.cfg.DataPath, "log", "hooks.log"),
2502 		}
2503 
2504 		// Add git protocol environment variable.
2505@@ -301,7 +303,7 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
2506 func (d *GitDaemon) Close() error {
2507 	d.once.Do(func() { close(d.finished) })
2508 	err := d.listener.Close()
2509-	d.conns.CloseAll()
2510+	d.conns.CloseAll() // nolint: errcheck
2511 	return err
2512 }
2513 
2514diff --git a/server/daemon/daemon_test.go b/server/daemon/daemon_test.go
2515index a28fb68d4404d5e3eaa9e50535a984874275de7e..c11ddefb5648ad306b0489aa0d24a5d136b08b26 100644
2516--- a/server/daemon/daemon_test.go
2517+++ b/server/daemon/daemon_test.go
2518@@ -13,11 +13,13 @@ import (
2519 	"testing"
2520 
2521 	"github.com/charmbracelet/soft-serve/server/backend"
2522-	"github.com/charmbracelet/soft-serve/server/backend/sqlite"
2523 	"github.com/charmbracelet/soft-serve/server/config"
2524+	"github.com/charmbracelet/soft-serve/server/db"
2525+	"github.com/charmbracelet/soft-serve/server/db/migrate"
2526 	"github.com/charmbracelet/soft-serve/server/git"
2527 	"github.com/charmbracelet/soft-serve/server/test"
2528 	"github.com/go-git/go-git/v5/plumbing/format/pktline"
2529+	_ "modernc.org/sqlite" // sqlite driver
2530 )
2531 
2532 var testDaemon *GitDaemon
2533@@ -35,13 +37,20 @@ func TestMain(m *testing.M) {
2534 	os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", test.RandomPort()))
2535 	ctx := context.TODO()
2536 	cfg := config.DefaultConfig()
2537+	if err := cfg.Validate(); err != nil {
2538+		log.Fatal(err)
2539+	}
2540 	ctx = config.WithContext(ctx, cfg)
2541-	fb, err := sqlite.NewSqliteBackend(ctx)
2542+	db, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
2543 	if err != nil {
2544 		log.Fatal(err)
2545 	}
2546-	cfg = cfg.WithBackend(fb)
2547-	ctx = backend.WithContext(ctx, fb)
2548+	defer db.Close() // nolint: errcheck
2549+	if err := migrate.Migrate(ctx, db); err != nil {
2550+		log.Fatal(err)
2551+	}
2552+	be := backend.New(ctx, cfg, db)
2553+	ctx = backend.WithContext(ctx, be)
2554 	d, err := NewGitDaemon(ctx)
2555 	if err != nil {
2556 		log.Fatal(err)
2557@@ -59,7 +68,7 @@ func TestMain(m *testing.M) {
2558 	os.Unsetenv("SOFT_SERVE_GIT_IDLE_TIMEOUT")
2559 	os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR")
2560 	_ = d.Close()
2561-	_ = fb.Close()
2562+	_ = db.Close()
2563 	os.Exit(code)
2564 }
2565 
2566@@ -72,7 +81,7 @@ func TestIdleTimeout(t *testing.T) {
2567 	if err != nil && !errors.Is(err, io.EOF) {
2568 		t.Fatalf("expected nil, got error: %v", err)
2569 	}
2570-	if out != git.ErrTimeout.Error() && out != "" {
2571+	if out != "ERR "+git.ErrTimeout.Error() && out != "" {
2572 		t.Fatalf("expected %q error, got %q", git.ErrTimeout, out)
2573 	}
2574 }
2575@@ -89,7 +98,7 @@ func TestInvalidRepo(t *testing.T) {
2576 	if err != nil {
2577 		t.Fatalf("expected nil, got error: %v", err)
2578 	}
2579-	if out != git.ErrInvalidRepo.Error() {
2580+	if out != "ERR "+git.ErrInvalidRepo.Error() {
2581 		t.Fatalf("expected %q error, got %q", git.ErrInvalidRepo, out)
2582 	}
2583 }
2584diff --git a/server/db/context.go b/server/db/context.go
2585new file mode 100644
2586index 0000000000000000000000000000000000000000..17c70ee4978d8b3c97e0a05177d9cd30d82c3ae8
2587--- /dev/null
2588+++ b/server/db/context.go
2589@@ -0,0 +1,18 @@
2590+package db
2591+
2592+import "context"
2593+
2594+var contextKey = struct{ string }{"db"}
2595+
2596+// FromContext returns the database from the context.
2597+func FromContext(ctx context.Context) *DB {
2598+	if db, ok := ctx.Value(contextKey).(*DB); ok {
2599+		return db
2600+	}
2601+	return nil
2602+}
2603+
2604+// WithContext returns a new context with the database.
2605+func WithContext(ctx context.Context, db *DB) context.Context {
2606+	return context.WithValue(ctx, contextKey, db)
2607+}
2608diff --git a/server/db/db.go b/server/db/db.go
2609new file mode 100644
2610index 0000000000000000000000000000000000000000..587880dc6201ff4bfb68071a99d55f9e6b73de47
2611--- /dev/null
2612+++ b/server/db/db.go
2613@@ -0,0 +1,88 @@
2614+package db
2615+
2616+import (
2617+	"context"
2618+	"database/sql"
2619+	"errors"
2620+	"fmt"
2621+
2622+	"github.com/charmbracelet/log"
2623+	"github.com/charmbracelet/soft-serve/server/config"
2624+	"github.com/jmoiron/sqlx"
2625+	_ "modernc.org/sqlite" // sqlite driver
2626+)
2627+
2628+// DB is the interface for a Soft Serve database.
2629+type DB struct {
2630+	*sqlx.DB
2631+	logger *log.Logger
2632+}
2633+
2634+// Open opens a database connection.
2635+func Open(ctx context.Context, driverName string, dsn string) (*DB, error) {
2636+	db, err := sqlx.ConnectContext(ctx, driverName, dsn)
2637+	if err != nil {
2638+		return nil, err
2639+	}
2640+
2641+	d := &DB{
2642+		DB: db,
2643+	}
2644+
2645+	if config.IsVerbose() {
2646+		logger := log.FromContext(ctx).WithPrefix("db")
2647+		d.logger = logger
2648+	}
2649+
2650+	return d, nil
2651+}
2652+
2653+// Close implements db.DB.
2654+func (d *DB) Close() error {
2655+	return d.DB.Close()
2656+}
2657+
2658+// Tx is a database transaction.
2659+type Tx struct {
2660+	*sqlx.Tx
2661+	logger *log.Logger
2662+}
2663+
2664+// Transaction implements db.DB.
2665+func (d *DB) Transaction(fn func(tx *Tx) error) error {
2666+	return d.TransactionContext(context.Background(), fn)
2667+}
2668+
2669+// TransactionContext implements db.DB.
2670+func (d *DB) TransactionContext(ctx context.Context, fn func(tx *Tx) error) error {
2671+	txx, err := d.DB.BeginTxx(ctx, nil)
2672+	if err != nil {
2673+		return fmt.Errorf("failed to begin transaction: %w", err)
2674+	}
2675+
2676+	tx := &Tx{txx, d.logger}
2677+	if err := fn(tx); err != nil {
2678+		return rollback(tx, err)
2679+	}
2680+
2681+	if err := tx.Commit(); err != nil {
2682+		if errors.Is(err, sql.ErrTxDone) {
2683+			// this is ok because whoever did finish the tx should have also written the error already.
2684+			return nil
2685+		}
2686+		return fmt.Errorf("failed to commit transaction: %w", err)
2687+	}
2688+
2689+	return nil
2690+}
2691+
2692+func rollback(tx *Tx, err error) error {
2693+	if rerr := tx.Rollback(); rerr != nil {
2694+		if errors.Is(rerr, sql.ErrTxDone) {
2695+			return err
2696+		}
2697+		return fmt.Errorf("failed to rollback: %s: %w", err.Error(), rerr)
2698+	}
2699+
2700+	return err
2701+}
2702diff --git a/server/db/errors.go b/server/db/errors.go
2703new file mode 100644
2704index 0000000000000000000000000000000000000000..f793a0588a94c7c2461da50aaf87060ac724d923
2705--- /dev/null
2706+++ b/server/db/errors.go
2707@@ -0,0 +1,48 @@
2708+package db
2709+
2710+import (
2711+	"database/sql"
2712+	"errors"
2713+
2714+	"github.com/lib/pq"
2715+	sqlite "modernc.org/sqlite"
2716+	sqlite3 "modernc.org/sqlite/lib"
2717+)
2718+
2719+var (
2720+	// ErrDuplicateKey is a constraint violation error.
2721+	ErrDuplicateKey = errors.New("duplicate key value violates table constraint")
2722+
2723+	// ErrRecordNotFound is returned when a record is not found.
2724+	ErrRecordNotFound = errors.New("record not found")
2725+)
2726+
2727+// WrapError is a convenient function that unite various database driver
2728+// errors to consistent errors.
2729+func WrapError(err error) error {
2730+	if err != nil {
2731+		if errors.Is(err, sql.ErrNoRows) {
2732+			return ErrRecordNotFound
2733+		}
2734+
2735+		// Handle sqlite constraint error.
2736+		if liteErr, ok := err.(*sqlite.Error); ok {
2737+			code := liteErr.Code()
2738+			if code == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY ||
2739+				code == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY ||
2740+				code == sqlite3.SQLITE_CONSTRAINT_UNIQUE {
2741+				return ErrDuplicateKey
2742+			}
2743+		}
2744+
2745+		// Handle postgres constraint error.
2746+		if pgErr, ok := err.(*pq.Error); ok {
2747+			if pgErr.Code == "23505" ||
2748+				pgErr.Code == "23503" ||
2749+				pgErr.Code == "23514" {
2750+				return ErrDuplicateKey
2751+			}
2752+		}
2753+	}
2754+	return err
2755+}
2756diff --git a/server/db/logger.go b/server/db/logger.go
2757new file mode 100644
2758index 0000000000000000000000000000000000000000..bf3d932b24c2f57330a3d7d3e7ca0181c8869d24
2759--- /dev/null
2760+++ b/server/db/logger.go
2761@@ -0,0 +1,135 @@
2762+package db
2763+
2764+import (
2765+	"context"
2766+	"database/sql"
2767+
2768+	"github.com/charmbracelet/log"
2769+	"github.com/jmoiron/sqlx"
2770+)
2771+
2772+func trace(l *log.Logger, query string, args ...interface{}) {
2773+	if l != nil {
2774+		l.Debug("trace", "query", query, "args", args)
2775+	}
2776+}
2777+
2778+// Select is a wrapper around sqlx.Select that logs the query and arguments.
2779+func (d *DB) Select(dest interface{}, query string, args ...interface{}) error {
2780+	trace(d.logger, query, args...)
2781+	return d.DB.Select(dest, query, args...)
2782+}
2783+
2784+// Get is a wrapper around sqlx.Get that logs the query and arguments.
2785+func (d *DB) Get(dest interface{}, query string, args ...interface{}) error {
2786+	trace(d.logger, query, args...)
2787+	return d.DB.Get(dest, query, args...)
2788+}
2789+
2790+// Queryx is a wrapper around sqlx.Queryx that logs the query and arguments.
2791+func (d *DB) Queryx(query string, args ...interface{}) (*sqlx.Rows, error) {
2792+	trace(d.logger, query, args...)
2793+	return d.DB.Queryx(query, args...)
2794+}
2795+
2796+// QueryRowx is a wrapper around sqlx.QueryRowx that logs the query and arguments.
2797+func (d *DB) QueryRowx(query string, args ...interface{}) *sqlx.Row {
2798+	trace(d.logger, query, args...)
2799+	return d.DB.QueryRowx(query, args...)
2800+}
2801+
2802+// Exec is a wrapper around sqlx.Exec that logs the query and arguments.
2803+func (d *DB) Exec(query string, args ...interface{}) (sql.Result, error) {
2804+	trace(d.logger, query, args...)
2805+	return d.DB.Exec(query, args...)
2806+}
2807+
2808+// SelectContext is a wrapper around sqlx.SelectContext that logs the query and arguments.
2809+func (d *DB) SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
2810+	trace(d.logger, query, args...)
2811+	return d.DB.SelectContext(ctx, dest, query, args...)
2812+}
2813+
2814+// GetContext is a wrapper around sqlx.GetContext that logs the query and arguments.
2815+func (d *DB) GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
2816+	trace(d.logger, query, args...)
2817+	return d.DB.GetContext(ctx, dest, query, args...)
2818+}
2819+
2820+// QueryxContext is a wrapper around sqlx.QueryxContext that logs the query and arguments.
2821+func (d *DB) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) {
2822+	trace(d.logger, query, args...)
2823+	return d.DB.QueryxContext(ctx, query, args...)
2824+}
2825+
2826+// QueryRowxContext is a wrapper around sqlx.QueryRowxContext that logs the query and arguments.
2827+func (d *DB) QueryRowxContext(ctx context.Context, query string, args ...interface{}) *sqlx.Row {
2828+	trace(d.logger, query, args...)
2829+	return d.DB.QueryRowxContext(ctx, query, args...)
2830+}
2831+
2832+// ExecContext is a wrapper around sqlx.ExecContext that logs the query and arguments.
2833+func (d *DB) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
2834+	trace(d.logger, query, args...)
2835+	return d.DB.ExecContext(ctx, query, args...)
2836+}
2837+
2838+// Select is a wrapper around sqlx.Select that logs the query and arguments.
2839+func (t *Tx) Select(dest interface{}, query string, args ...interface{}) error {
2840+	trace(t.logger, query, args...)
2841+	return t.Tx.Select(dest, query, args...)
2842+}
2843+
2844+// Get is a wrapper around sqlx.Get that logs the query and arguments.
2845+func (t *Tx) Get(dest interface{}, query string, args ...interface{}) error {
2846+	trace(t.logger, query, args...)
2847+	return t.Tx.Get(dest, query, args...)
2848+}
2849+
2850+// Queryx is a wrapper around sqlx.Queryx that logs the query and arguments.
2851+func (t *Tx) Queryx(query string, args ...interface{}) (*sqlx.Rows, error) {
2852+	trace(t.logger, query, args...)
2853+	return t.Tx.Queryx(query, args...)
2854+}
2855+
2856+// QueryRowx is a wrapper around sqlx.QueryRowx that logs the query and arguments.
2857+func (t *Tx) QueryRowx(query string, args ...interface{}) *sqlx.Row {
2858+	trace(t.logger, query, args...)
2859+	return t.Tx.QueryRowx(query, args...)
2860+}
2861diff --git a/server/db/migrate/0001_create_tables.go b/server/db/migrate/0001_create_tables.go
2862new file mode 100644
2863index 0000000000000000000000000000000000000000..b6cf1c85a95cebbcc52d43f4546f14dc6e150db3
2864--- /dev/null
2865+++ b/server/db/migrate/0001_create_tables.go
2866@@ -0,0 +1,134 @@
2867+package migrate
2868+
2869+import (
2870+	"context"
2871+	"errors"
2872+	"fmt"
2873+
2874+	"github.com/charmbracelet/soft-serve/server/access"
2875+	"github.com/charmbracelet/soft-serve/server/config"
2876+	"github.com/charmbracelet/soft-serve/server/db"
2877+	"github.com/charmbracelet/soft-serve/server/sshutils"
2878+)
2879+
2880+const (
2881+	createTablesName    = "create tables"
2882+	createTablesVersion = 1
2883+)
2884+
2885+var createTables = Migration{
2886+	Version: createTablesVersion,
2887+	Name:    createTablesName,
2888+	Migrate: func(ctx context.Context, tx *db.Tx) error {
2889+		cfg := config.FromContext(ctx)
2890+
2891+		insert := "INSERT "
2892+
2893+		// Alter old tables (if exist)
2894+		// This is to support prior versions of Soft Serve
2895+		switch tx.DriverName() {
2896+		case "sqlite3", "sqlite":
2897+			insert += "OR IGNORE "
2898+
2899+			hasUserTable := hasTable(tx, "user")
2900+			if hasUserTable {
2901+				if _, err := tx.ExecContext(ctx, "ALTER TABLE user RENAME TO users"); err != nil {
2902+					return err
2903+				}
2904+			}
2905+
2906+			if hasTable(tx, "public_key") {
2907+				if _, err := tx.ExecContext(ctx, "ALTER TABLE public_key RENAME TO public_keys"); err != nil {
2908+					return err
2909+				}
2910+			}
2911+
2912+			if hasTable(tx, "collab") {
2913+				if _, err := tx.ExecContext(ctx, "ALTER TABLE collab RENAME TO collabs"); err != nil {
2914+					return err
2915+				}
2916+			}
2917+
2918+			if hasTable(tx, "repo") {
2919+				if _, err := tx.ExecContext(ctx, "ALTER TABLE repo RENAME TO repos"); err != nil {
2920+					return err
2921+				}
2922+			}
2923+
2924+			// Fix username being nullable
2925+			if hasUserTable {
2926+				sqlm := `
2927+				PRAGMA foreign_keys = OFF;
2928+
2929+				CREATE TABLE users_new (
2930+					id INTEGER PRIMARY KEY AUTOINCREMENT,
2931+					username TEXT NOT NULL UNIQUE,
2932+					admin BOOLEAN NOT NULL,
2933+					created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
2934+					updated_at DATETIME NOT NULL
2935+				);
2936+
2937+				INSERT INTO users_new (username, admin, updated_at)
2938+					SELECT username, admin, updated_at FROM users;
2939+
2940+				DROP TABLE users;
2941+				ALTER TABLE users_new RENAME TO users;
2942+
2943+				PRAGMA foreign_keys = ON;
2944+				`
2945+				if _, err := tx.ExecContext(ctx, sqlm); err != nil {
2946+					return err
2947+				}
2948+			}
2949+		}
2950+
2951+		if err := migrateUp(ctx, tx, createTablesVersion, createTablesName); err != nil {
2952+			return err
2953+		}
2954+
2955+		// Insert default user
2956+		insertUser := tx.Rebind(insert + "INTO users (username, admin, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)")
2957+		if _, err := tx.ExecContext(ctx, insertUser, "admin", true); err != nil {
2958+			return err
2959+		}
2960+
2961+		for _, k := range cfg.AdminKeys() {
2962+			query := insert + "INTO public_keys (user_id, public_key, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)"
2963+			if tx.DriverName() == "postgres" {
2964+				query += " ON CONFLICT DO NOTHING"
2965+			}
2966diff --git a/server/db/migrate/0001_create_tables_postgres.down.sql b/server/db/migrate/0001_create_tables_postgres.down.sql
2967new file mode 100644
2968index 0000000000000000000000000000000000000000..35eeb70dedeae7d862935e287a9ab3516d56b674
2969--- /dev/null
2970+++ b/server/db/migrate/0001_create_tables_postgres.down.sql
2971@@ -0,0 +1,5 @@
2972+DROP TABLE IF EXISTS collabs;
2973+DROP TABLE IF EXISTS repos;
2974+DROP TABLE IF EXISTS public_keys;
2975+DROP TABLE IF EXISTS users;
2976+DROP TABLE IF EXISTS settings;
2977diff --git a/server/db/migrate/0001_create_tables_postgres.up.sql b/server/db/migrate/0001_create_tables_postgres.up.sql
2978new file mode 100644
2979index 0000000000000000000000000000000000000000..29a8e0bacc3af6cd5419c701a996d2b29c0d762b
2980--- /dev/null
2981+++ b/server/db/migrate/0001_create_tables_postgres.up.sql
2982@@ -0,0 +1,59 @@
2983+CREATE TABLE IF NOT EXISTS settings (
2984+    id SERIAL PRIMARY KEY,
2985+    key TEXT NOT NULL UNIQUE,
2986+    value TEXT NOT NULL,
2987+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
2988+    updated_at TIMESTAMP NOT NULL
2989+);
2990+
2991+CREATE TABLE IF NOT EXISTS users (
2992+    id SERIAL PRIMARY KEY,
2993+    username TEXT NOT NULL UNIQUE,
2994+    admin BOOLEAN NOT NULL,
2995+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
2996+    updated_at TIMESTAMP NOT NULL
2997+);
2998+
2999+CREATE TABLE IF NOT EXISTS public_keys (
3000+    id SERIAL PRIMARY KEY,
3001+    user_id INTEGER NOT NULL,
3002+    public_key TEXT NOT NULL UNIQUE,
3003+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
3004+    updated_at TIMESTAMP NOT NULL,
3005+    UNIQUE (user_id, public_key),
3006+    CONSTRAINT user_id_fk
3007+    FOREIGN KEY(user_id) REFERENCES users(id)
3008+    ON DELETE CASCADE
3009+    ON UPDATE CASCADE
3010+);
3011+
3012+CREATE TABLE IF NOT EXISTS repos (
3013+    id SERIAL PRIMARY KEY,
3014+    name TEXT NOT NULL UNIQUE,
3015+    project_name TEXT NOT NULL,
3016+    description TEXT NOT NULL,
3017+    private BOOLEAN NOT NULL,
3018+    mirror BOOLEAN NOT NULL,
3019+    hidden BOOLEAN NOT NULL,
3020+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
3021+    updated_at TIMESTAMP NOT NULL
3022+);
3023+
3024+CREATE TABLE IF NOT EXISTS collabs (
3025+    id SERIAL PRIMARY KEY,
3026+    user_id INTEGER NOT NULL,
3027+    repo_id INTEGER NOT NULL,
3028+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
3029+    updated_at TIMESTAMP NOT NULL,
3030+    UNIQUE (user_id, repo_id),
3031+    CONSTRAINT user_id_fk
3032+    FOREIGN KEY(user_id) REFERENCES users(id)
3033+    ON DELETE CASCADE
3034+    ON UPDATE CASCADE,
3035+    CONSTRAINT repo_id_fk
3036+    FOREIGN KEY(repo_id) REFERENCES repos(id)
3037+    ON DELETE CASCADE
3038+    ON UPDATE CASCADE
3039+);
3040+
3041+
3042diff --git a/server/db/migrate/0001_create_tables_sqlite.down.sql b/server/db/migrate/0001_create_tables_sqlite.down.sql
3043new file mode 100644
3044index 0000000000000000000000000000000000000000..35eeb70dedeae7d862935e287a9ab3516d56b674
3045--- /dev/null
3046+++ b/server/db/migrate/0001_create_tables_sqlite.down.sql
3047@@ -0,0 +1,5 @@
3048+DROP TABLE IF EXISTS collabs;
3049+DROP TABLE IF EXISTS repos;
3050+DROP TABLE IF EXISTS public_keys;
3051+DROP TABLE IF EXISTS users;
3052+DROP TABLE IF EXISTS settings;
3053diff --git a/server/db/migrate/0001_create_tables_sqlite.up.sql b/server/db/migrate/0001_create_tables_sqlite.up.sql
3054new file mode 100644
3055index 0000000000000000000000000000000000000000..0880f464fd1a854399ad525cda016e90ea583a4f
3056--- /dev/null
3057+++ b/server/db/migrate/0001_create_tables_sqlite.up.sql
3058@@ -0,0 +1,58 @@
3059+CREATE TABLE IF NOT EXISTS settings (
3060+  id INTEGER PRIMARY KEY AUTOINCREMENT,
3061+  key TEXT NOT NULL UNIQUE,
3062+  value TEXT NOT NULL,
3063+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
3064+  updated_at DATETIME NOT NULL
3065+);
3066+
3067+CREATE TABLE IF NOT EXISTS users (
3068+  id INTEGER PRIMARY KEY AUTOINCREMENT,
3069+  username TEXT NOT NULL UNIQUE,
3070+  admin BOOLEAN NOT NULL,
3071+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
3072+  updated_at DATETIME NOT NULL
3073+);
3074+
3075+CREATE TABLE IF NOT EXISTS public_keys (
3076+  id INTEGER PRIMARY KEY AUTOINCREMENT,
3077+  user_id INTEGER NOT NULL,
3078+  public_key TEXT NOT NULL UNIQUE,
3079+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
3080+  updated_at DATETIME NOT NULL,
3081+  UNIQUE (user_id, public_key),
3082+  CONSTRAINT user_id_fk
3083+  FOREIGN KEY(user_id) REFERENCES users(id)
3084+  ON DELETE CASCADE
3085+  ON UPDATE CASCADE
3086+);
3087+
3088+CREATE TABLE IF NOT EXISTS repos (
3089+  id INTEGER PRIMARY KEY AUTOINCREMENT,
3090+  name TEXT NOT NULL UNIQUE,
3091+  project_name TEXT NOT NULL,
3092+  description TEXT NOT NULL,
3093+  private BOOLEAN NOT NULL,
3094+  mirror BOOLEAN NOT NULL,
3095+  hidden BOOLEAN NOT NULL,
3096+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
3097+  updated_at DATETIME NOT NULL
3098+);
3099+
3100+CREATE TABLE IF NOT EXISTS collabs (
3101+  id INTEGER PRIMARY KEY AUTOINCREMENT,
3102+  user_id INTEGER NOT NULL,
3103+  repo_id INTEGER NOT NULL,
3104+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
3105+  updated_at DATETIME NOT NULL,
3106+  UNIQUE (user_id, repo_id),
3107+  CONSTRAINT user_id_fk
3108+  FOREIGN KEY(user_id) REFERENCES users(id)
3109+  ON DELETE CASCADE
3110+  ON UPDATE CASCADE,
3111+  CONSTRAINT repo_id_fk
3112+  FOREIGN KEY(repo_id) REFERENCES repos(id)
3113+  ON DELETE CASCADE
3114+  ON UPDATE CASCADE
3115+);
3116+