e398b4dde723437f637b06710e0938d349b360e8
- Author
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Committer
- GitHub <noreply@github.com>
- Date
Message
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+