From d1a8d90e02599cde0394baa288a7b00cd3676fcd Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 27 Oct 2023 14:23:05 -0400 Subject: [PATCH] refactor: rename pkgs --- cmd/cmd.go | 68 ++ cmd/soft/{ => admin}/admin.go | 33 +- cmd/soft/{ => browse}/browse.go | 13 +- cmd/soft/{ => hook}/hook.go | 71 +- cmd/soft/{root.go => main.go} | 82 +- cmd/soft/man.go | 27 - cmd/soft/migrate_config.go | 434 -------- cmd/soft/{ => serve}/serve.go | 86 +- {server => cmd/soft/serve}/server.go | 20 +- server/access/access.go | 78 -- server/access/access_test.go | 24 - server/access/context.go | 20 - server/backend/access_token.go | 78 -- server/backend/auth.go | 45 - server/backend/auth_test.go | 38 - server/backend/backend.go | 43 - server/backend/cache.go | 35 - server/backend/collab.go | 118 --- server/backend/context.go | 20 - server/backend/hooks.go | 134 --- server/backend/lfs.go | 88 -- server/backend/repo.go | 756 -------------- server/backend/settings.go | 58 -- server/backend/user.go | 425 -------- server/backend/utils.go | 23 - server/backend/webhooks.go | 279 ----- server/config/config.go | 417 -------- server/config/config_test.go | 58 -- server/config/context.go | 20 - server/config/file.go | 118 --- server/config/ssh.go | 8 - server/config/testdata/k1.pub | 1 - server/cron/cron.go | 61 -- server/daemon/conn.go | 105 -- server/daemon/daemon.go | 322 ------ server/daemon/daemon_test.go | 120 --- server/db/context.go | 19 - server/db/db.go | 89 -- server/db/errors.go | 48 - server/db/handler.go | 25 - server/db/logger.go | 139 --- server/db/migrate/0001_create_tables.go | 180 ---- .../0001_create_tables_postgres.down.sql | 0 .../0001_create_tables_postgres.up.sql | 110 -- .../0001_create_tables_sqlite.down.sql | 0 .../migrate/0001_create_tables_sqlite.up.sql | 110 -- server/db/migrate/0002_webhooks.go | 23 - .../migrate/0002_webhooks_postgres.down.sql | 0 .../db/migrate/0002_webhooks_postgres.up.sql | 46 - .../db/migrate/0002_webhooks_sqlite.down.sql | 0 server/db/migrate/0002_webhooks_sqlite.up.sql | 46 - server/db/migrate/migrate.go | 142 --- server/db/migrate/migrations.go | 63 -- server/db/models/access_token.go | 17 - server/db/models/collab.go | 17 - server/db/models/lfs.go | 24 - server/db/models/public_key.go | 10 - server/db/models/repo.go | 20 - server/db/models/settings.go | 10 - server/db/models/user.go | 16 - server/db/models/webhook.go | 44 - server/git/errors.go | 23 - server/git/git.go | 96 -- server/git/git_test.go | 56 - server/git/lfs.go | 503 --------- server/git/lfs_auth.go | 85 -- server/git/service.go | 184 ---- server/hooks/gen.go | 143 --- server/hooks/hooks.go | 21 - server/jobs/jobs.go | 37 - server/jobs/mirror.go | 114 -- server/jwk/jwk.go | 49 - server/lfs/basic_transfer.go | 124 --- server/lfs/client.go | 27 - server/lfs/common.go | 163 --- server/lfs/endpoint.go | 70 -- server/lfs/http_client.go | 196 ---- server/lfs/pointer.go | 122 --- server/lfs/pointer_test.go | 95 -- server/lfs/scanner.go | 216 ---- server/lfs/ssh_client.go | 3 - server/lfs/transfer.go | 17 - server/log/log.go | 48 - server/proto/access_token.go | 13 - server/proto/context.go | 35 - server/proto/errors.go | 24 - server/proto/repo.go | 61 -- server/proto/user.go | 25 - server/ssh/cmd/blob.go | 108 -- server/ssh/cmd/branch.go | 208 ---- server/ssh/cmd/cmd.go | 187 ---- server/ssh/cmd/collab.go | 95 -- server/ssh/cmd/commit.go | 165 --- server/ssh/cmd/create.go | 54 - server/ssh/cmd/delete.go | 25 - server/ssh/cmd/description.go | 46 - server/ssh/cmd/git.go | 337 ------ server/ssh/cmd/hidden.go | 46 - server/ssh/cmd/import.go | 62 -- server/ssh/cmd/info.go | 35 - server/ssh/cmd/jwt.go | 57 - server/ssh/cmd/list.go | 41 - server/ssh/cmd/mirror.go | 30 - server/ssh/cmd/private.go | 50 - server/ssh/cmd/project_name.go | 46 - server/ssh/cmd/pubkey.go | 93 -- server/ssh/cmd/rename.go | 26 - server/ssh/cmd/repo.go | 107 -- server/ssh/cmd/set_username.go | 29 - server/ssh/cmd/settings.go | 73 -- server/ssh/cmd/tag.go | 120 --- server/ssh/cmd/token.go | 155 --- server/ssh/cmd/tree.go | 102 -- server/ssh/cmd/user.go | 200 ---- server/ssh/cmd/webhooks.go | 406 -------- server/ssh/middleware.go | 189 ---- server/ssh/session.go | 99 -- server/ssh/session_test.go | 90 -- server/ssh/ssh.go | 206 ---- server/ssh/ui.go | 332 ------ server/sshutils/utils.go | 51 - server/sshutils/utils_test.go | 124 --- server/stats/stats.go | 51 - server/storage/local.go | 90 -- server/storage/storage.go | 23 - server/store/access_token.go | 19 - server/store/collab.go | 18 - server/store/context.go | 20 - server/store/database/access_token.go | 74 -- server/store/database/collab.go | 126 --- server/store/database/database.go | 47 - server/store/database/lfs.go | 199 ---- server/store/database/repo.go | 152 --- server/store/database/settings.go | 47 - server/store/database/user.go | 242 ----- server/store/database/webhooks.go | 165 --- server/store/lfs.go | 28 - server/store/repo.go | 28 - server/store/settings.go | 16 - server/store/store.go | 12 - server/store/user.go | 28 - server/store/webhooks.go | 48 - server/sync/workqueue.go | 94 -- server/sync/workqueue_test.go | 35 - server/task/manager.go | 116 --- server/test/test.go | 29 - server/ui/common/common.go | 97 -- server/ui/common/component.go | 31 - server/ui/common/error.go | 20 - server/ui/common/format.go | 60 -- server/ui/common/style.go | 27 - server/ui/common/utils.go | 40 - server/ui/components/code/code.go | 261 ----- server/ui/components/footer/footer.go | 96 -- server/ui/components/header/header.go | 42 - server/ui/components/selector/selector.go | 319 ------ server/ui/components/statusbar/statusbar.go | 93 -- server/ui/components/tabs/tabs.go | 122 --- server/ui/components/viewport/viewport.go | 107 -- server/ui/keymap/keymap.go | 230 ----- server/ui/pages/repo/empty.go | 40 - server/ui/pages/repo/files.go | 548 ---------- server/ui/pages/repo/filesitem.go | 155 --- server/ui/pages/repo/log.go | 534 ---------- server/ui/pages/repo/logitem.go | 147 --- server/ui/pages/repo/readme.go | 167 --- server/ui/pages/repo/refs.go | 276 ----- server/ui/pages/repo/refsitem.go | 205 ---- server/ui/pages/repo/repo.go | 416 -------- server/ui/pages/repo/stash.go | 279 ----- server/ui/pages/repo/stashitem.go | 106 -- server/ui/pages/selection/item.go | 217 ---- server/ui/pages/selection/selection.go | 320 ------ server/ui/styles/styles.go | 521 ---------- server/utils/utils.go | 52 - server/utils/utils_test.go | 56 - server/version/version.go | 14 - server/web/auth.go | 171 --- server/web/context.go | 34 - server/web/git.go | 634 ------------ server/web/git_lfs.go | 975 ------------------ server/web/goget.go | 99 -- server/web/http.go | 56 - server/web/logging.go | 83 -- server/web/server.go | 28 - server/web/util.go | 14 - server/webhook/branch_tag.go | 86 -- server/webhook/collaborator.go | 83 -- server/webhook/common.go | 95 -- server/webhook/content_type.go | 70 -- server/webhook/event.go | 101 -- server/webhook/push.go | 117 --- server/webhook/repository.go | 82 -- server/webhook/webhook.go | 144 --- testscript/script_test.go | 20 +- 195 files changed, 214 insertions(+), 22051 deletions(-) create mode 100644 cmd/cmd.go rename cmd/soft/{ => admin}/admin.go (62%) rename cmd/soft/{ => browse}/browse.go (95%) rename cmd/soft/{ => hook}/hook.go (68%) rename cmd/soft/{root.go => main.go} (62%) delete mode 100644 cmd/soft/man.go delete mode 100644 cmd/soft/migrate_config.go rename cmd/soft/{ => serve}/serve.go (56%) rename {server => cmd/soft/serve}/server.go (88%) delete mode 100644 server/access/access.go delete mode 100644 server/access/access_test.go delete mode 100644 server/access/context.go delete mode 100644 server/backend/access_token.go delete mode 100644 server/backend/auth.go delete mode 100644 server/backend/auth_test.go delete mode 100644 server/backend/backend.go delete mode 100644 server/backend/cache.go delete mode 100644 server/backend/collab.go delete mode 100644 server/backend/context.go delete mode 100644 server/backend/hooks.go delete mode 100644 server/backend/lfs.go delete mode 100644 server/backend/repo.go delete mode 100644 server/backend/settings.go delete mode 100644 server/backend/user.go delete mode 100644 server/backend/utils.go delete mode 100644 server/backend/webhooks.go delete mode 100644 server/config/config.go delete mode 100644 server/config/config_test.go delete mode 100644 server/config/context.go delete mode 100644 server/config/file.go delete mode 100644 server/config/ssh.go delete mode 100644 server/config/testdata/k1.pub delete mode 100644 server/cron/cron.go delete mode 100644 server/daemon/conn.go delete mode 100644 server/daemon/daemon.go delete mode 100644 server/daemon/daemon_test.go delete mode 100644 server/db/context.go delete mode 100644 server/db/db.go delete mode 100644 server/db/errors.go delete mode 100644 server/db/handler.go delete mode 100644 server/db/logger.go delete mode 100644 server/db/migrate/0001_create_tables.go delete mode 100644 server/db/migrate/0001_create_tables_postgres.down.sql delete mode 100644 server/db/migrate/0001_create_tables_postgres.up.sql delete mode 100644 server/db/migrate/0001_create_tables_sqlite.down.sql delete mode 100644 server/db/migrate/0001_create_tables_sqlite.up.sql delete mode 100644 server/db/migrate/0002_webhooks.go delete mode 100644 server/db/migrate/0002_webhooks_postgres.down.sql delete mode 100644 server/db/migrate/0002_webhooks_postgres.up.sql delete mode 100644 server/db/migrate/0002_webhooks_sqlite.down.sql delete mode 100644 server/db/migrate/0002_webhooks_sqlite.up.sql delete mode 100644 server/db/migrate/migrate.go delete mode 100644 server/db/migrate/migrations.go delete mode 100644 server/db/models/access_token.go delete mode 100644 server/db/models/collab.go delete mode 100644 server/db/models/lfs.go delete mode 100644 server/db/models/public_key.go delete mode 100644 server/db/models/repo.go delete mode 100644 server/db/models/settings.go delete mode 100644 server/db/models/user.go delete mode 100644 server/db/models/webhook.go delete mode 100644 server/git/errors.go delete mode 100644 server/git/git.go delete mode 100644 server/git/git_test.go delete mode 100644 server/git/lfs.go delete mode 100644 server/git/lfs_auth.go delete mode 100644 server/git/service.go delete mode 100644 server/hooks/gen.go delete mode 100644 server/hooks/hooks.go delete mode 100644 server/jobs/jobs.go delete mode 100644 server/jobs/mirror.go delete mode 100644 server/jwk/jwk.go delete mode 100644 server/lfs/basic_transfer.go delete mode 100644 server/lfs/client.go delete mode 100644 server/lfs/common.go delete mode 100644 server/lfs/endpoint.go delete mode 100644 server/lfs/http_client.go delete mode 100644 server/lfs/pointer.go delete mode 100644 server/lfs/pointer_test.go delete mode 100644 server/lfs/scanner.go delete mode 100644 server/lfs/ssh_client.go delete mode 100644 server/lfs/transfer.go delete mode 100644 server/log/log.go delete mode 100644 server/proto/access_token.go delete mode 100644 server/proto/context.go delete mode 100644 server/proto/errors.go delete mode 100644 server/proto/repo.go delete mode 100644 server/proto/user.go delete mode 100644 server/ssh/cmd/blob.go delete mode 100644 server/ssh/cmd/branch.go delete mode 100644 server/ssh/cmd/cmd.go delete mode 100644 server/ssh/cmd/collab.go delete mode 100644 server/ssh/cmd/commit.go delete mode 100644 server/ssh/cmd/create.go delete mode 100644 server/ssh/cmd/delete.go delete mode 100644 server/ssh/cmd/description.go delete mode 100644 server/ssh/cmd/git.go delete mode 100644 server/ssh/cmd/hidden.go delete mode 100644 server/ssh/cmd/import.go delete mode 100644 server/ssh/cmd/info.go delete mode 100644 server/ssh/cmd/jwt.go delete mode 100644 server/ssh/cmd/list.go delete mode 100644 server/ssh/cmd/mirror.go delete mode 100644 server/ssh/cmd/private.go delete mode 100644 server/ssh/cmd/project_name.go delete mode 100644 server/ssh/cmd/pubkey.go delete mode 100644 server/ssh/cmd/rename.go delete mode 100644 server/ssh/cmd/repo.go delete mode 100644 server/ssh/cmd/set_username.go delete mode 100644 server/ssh/cmd/settings.go delete mode 100644 server/ssh/cmd/tag.go delete mode 100644 server/ssh/cmd/token.go delete mode 100644 server/ssh/cmd/tree.go delete mode 100644 server/ssh/cmd/user.go delete mode 100644 server/ssh/cmd/webhooks.go delete mode 100644 server/ssh/middleware.go delete mode 100644 server/ssh/session.go delete mode 100644 server/ssh/session_test.go delete mode 100644 server/ssh/ssh.go delete mode 100644 server/ssh/ui.go delete mode 100644 server/sshutils/utils.go delete mode 100644 server/sshutils/utils_test.go delete mode 100644 server/stats/stats.go delete mode 100644 server/storage/local.go delete mode 100644 server/storage/storage.go delete mode 100644 server/store/access_token.go delete mode 100644 server/store/collab.go delete mode 100644 server/store/context.go delete mode 100644 server/store/database/access_token.go delete mode 100644 server/store/database/collab.go delete mode 100644 server/store/database/database.go delete mode 100644 server/store/database/lfs.go delete mode 100644 server/store/database/repo.go delete mode 100644 server/store/database/settings.go delete mode 100644 server/store/database/user.go delete mode 100644 server/store/database/webhooks.go delete mode 100644 server/store/lfs.go delete mode 100644 server/store/repo.go delete mode 100644 server/store/settings.go delete mode 100644 server/store/store.go delete mode 100644 server/store/user.go delete mode 100644 server/store/webhooks.go delete mode 100644 server/sync/workqueue.go delete mode 100644 server/sync/workqueue_test.go delete mode 100644 server/task/manager.go delete mode 100644 server/test/test.go delete mode 100644 server/ui/common/common.go delete mode 100644 server/ui/common/component.go delete mode 100644 server/ui/common/error.go delete mode 100644 server/ui/common/format.go delete mode 100644 server/ui/common/style.go delete mode 100644 server/ui/common/utils.go delete mode 100644 server/ui/components/code/code.go delete mode 100644 server/ui/components/footer/footer.go delete mode 100644 server/ui/components/header/header.go delete mode 100644 server/ui/components/selector/selector.go delete mode 100644 server/ui/components/statusbar/statusbar.go delete mode 100644 server/ui/components/tabs/tabs.go delete mode 100644 server/ui/components/viewport/viewport.go delete mode 100644 server/ui/keymap/keymap.go delete mode 100644 server/ui/pages/repo/empty.go delete mode 100644 server/ui/pages/repo/files.go delete mode 100644 server/ui/pages/repo/filesitem.go delete mode 100644 server/ui/pages/repo/log.go delete mode 100644 server/ui/pages/repo/logitem.go delete mode 100644 server/ui/pages/repo/readme.go delete mode 100644 server/ui/pages/repo/refs.go delete mode 100644 server/ui/pages/repo/refsitem.go delete mode 100644 server/ui/pages/repo/repo.go delete mode 100644 server/ui/pages/repo/stash.go delete mode 100644 server/ui/pages/repo/stashitem.go delete mode 100644 server/ui/pages/selection/item.go delete mode 100644 server/ui/pages/selection/selection.go delete mode 100644 server/ui/styles/styles.go delete mode 100644 server/utils/utils.go delete mode 100644 server/utils/utils_test.go delete mode 100644 server/version/version.go delete mode 100644 server/web/auth.go delete mode 100644 server/web/context.go delete mode 100644 server/web/git.go delete mode 100644 server/web/git_lfs.go delete mode 100644 server/web/goget.go delete mode 100644 server/web/http.go delete mode 100644 server/web/logging.go delete mode 100644 server/web/server.go delete mode 100644 server/web/util.go delete mode 100644 server/webhook/branch_tag.go delete mode 100644 server/webhook/collaborator.go delete mode 100644 server/webhook/common.go delete mode 100644 server/webhook/content_type.go delete mode 100644 server/webhook/event.go delete mode 100644 server/webhook/push.go delete mode 100644 server/webhook/repository.go delete mode 100644 server/webhook/webhook.go diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 000000000..f7eff425a --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + + "github.com/charmbracelet/soft-serve/pkg/backend" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/db" + "github.com/charmbracelet/soft-serve/pkg/hooks" + "github.com/charmbracelet/soft-serve/pkg/store" + "github.com/charmbracelet/soft-serve/pkg/store/database" + "github.com/spf13/cobra" +) + +func InitBackendContext(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + cfg := config.FromContext(ctx) + if _, err := os.Stat(cfg.DataPath); errors.Is(err, fs.ErrNotExist) { + if err := os.MkdirAll(cfg.DataPath, os.ModePerm); err != nil { + return fmt.Errorf("create data directory: %w", err) + } + } + dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + + ctx = db.WithContext(ctx, dbx) + dbstore := database.New(ctx, dbx) + ctx = store.WithContext(ctx, dbstore) + be := backend.New(ctx, cfg, dbx) + ctx = backend.WithContext(ctx, be) + + cmd.SetContext(ctx) + + return nil +} + +func CloseDBContext(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + dbx := db.FromContext(ctx) + if dbx != nil { + if err := dbx.Close(); err != nil { + return fmt.Errorf("close database: %w", err) + } + } + + return nil +} + +func InitializeHooks(ctx context.Context, cfg *config.Config, be *backend.Backend) error { + repos, err := be.Repositories(ctx) + if err != nil { + return err + } + + for _, repo := range repos { + if err := hooks.GenerateHooks(ctx, cfg, repo.Name()); err != nil { + return err + } + } + + return nil +} diff --git a/cmd/soft/admin.go b/cmd/soft/admin/admin.go similarity index 62% rename from cmd/soft/admin.go rename to cmd/soft/admin/admin.go index 16d31f278..53d1f6132 100644 --- a/cmd/soft/admin.go +++ b/cmd/soft/admin/admin.go @@ -1,17 +1,18 @@ -package main +package admin import ( "fmt" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/migrate" + "github.com/charmbracelet/soft-serve/cmd" + "github.com/charmbracelet/soft-serve/pkg/backend" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/db" + "github.com/charmbracelet/soft-serve/pkg/db/migrate" "github.com/spf13/cobra" ) var ( - adminCmd = &cobra.Command{ + Command = &cobra.Command{ Use: "admin", Short: "Administrate the server", } @@ -19,8 +20,8 @@ var ( migrateCmd = &cobra.Command{ Use: "migrate", Short: "Migrate the database to the latest version", - PersistentPreRunE: initBackendContext, - PersistentPostRunE: closeDBContext, + PersistentPreRunE: cmd.InitBackendContext, + PersistentPostRunE: cmd.CloseDBContext, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() db := db.FromContext(ctx) @@ -35,8 +36,8 @@ var ( rollbackCmd = &cobra.Command{ Use: "rollback", Short: "Rollback the database to the previous version", - PersistentPreRunE: initBackendContext, - PersistentPostRunE: closeDBContext, + PersistentPreRunE: cmd.InitBackendContext, + PersistentPostRunE: cmd.CloseDBContext, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() db := db.FromContext(ctx) @@ -51,13 +52,13 @@ var ( syncHooksCmd = &cobra.Command{ Use: "sync-hooks", Short: "Update repository hooks", - PersistentPreRunE: initBackendContext, - PersistentPostRunE: closeDBContext, - RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() + PersistentPreRunE: cmd.InitBackendContext, + PersistentPostRunE: cmd.CloseDBContext, + RunE: func(c *cobra.Command, _ []string) error { + ctx := c.Context() cfg := config.FromContext(ctx) be := backend.FromContext(ctx) - if err := initializeHooks(ctx, cfg, be); err != nil { + if err := cmd.InitializeHooks(ctx, cfg, be); err != nil { return fmt.Errorf("initialize hooks: %w", err) } @@ -67,7 +68,7 @@ var ( ) func init() { - adminCmd.AddCommand( + Command.AddCommand( syncHooksCmd, migrateCmd, rollbackCmd, diff --git a/cmd/soft/browse.go b/cmd/soft/browse/browse.go similarity index 95% rename from cmd/soft/browse.go rename to cmd/soft/browse/browse.go index 8cb760198..b2024bde1 100644 --- a/cmd/soft/browse.go +++ b/cmd/soft/browse/browse.go @@ -1,4 +1,4 @@ -package main +package browse import ( "fmt" @@ -9,15 +9,15 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/charmbracelet/soft-serve/server/ui/components/footer" - "github.com/charmbracelet/soft-serve/server/ui/pages/repo" + "github.com/charmbracelet/soft-serve/pkg/proto" + "github.com/charmbracelet/soft-serve/pkg/ui/common" + "github.com/charmbracelet/soft-serve/pkg/ui/components/footer" + "github.com/charmbracelet/soft-serve/pkg/ui/pages/repo" "github.com/muesli/termenv" "github.com/spf13/cobra" ) -var browseCmd = &cobra.Command{ +var Command = &cobra.Command{ Use: "browse PATH", Short: "Browse a repository", Args: cobra.MaximumNArgs(1), @@ -72,7 +72,6 @@ func init() { // HACK: This is a hack to hide the clone url // TODO: Make this configurable common.CloneCmd = func(publicURL, name string) string { return "" } - rootCmd.AddCommand(browseCmd) } type state int diff --git a/cmd/soft/hook.go b/cmd/soft/hook/hook.go similarity index 68% rename from cmd/soft/hook.go rename to cmd/soft/hook/hook.go index 1bb32b4ea..06164781b 100644 --- a/cmd/soft/hook.go +++ b/cmd/soft/hook/hook.go @@ -1,4 +1,4 @@ -package main +package hook import ( "bufio" @@ -13,9 +13,10 @@ import ( "strings" "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/hooks" + "github.com/charmbracelet/soft-serve/cmd" + "github.com/charmbracelet/soft-serve/pkg/backend" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/hooks" "github.com/spf13/cobra" ) @@ -26,23 +27,23 @@ var ( // Deprecated: this flag is ignored. configPath string - hookCmd = &cobra.Command{ + Command = &cobra.Command{ Use: "hook", Short: "Run git server hooks", Long: "Handles Soft Serve git server hooks.", Hidden: true, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - logger := log.FromContext(cmd.Context()) - if err := initBackendContext(cmd, args); err != nil { + PersistentPreRunE: func(c *cobra.Command, args []string) error { + logger := log.FromContext(c.Context()) + if err := cmd.InitBackendContext(c, args); err != nil { logger.Error("failed to initialize backend context", "err", err) return ErrInternalServerError } return nil }, - PersistentPostRunE: func(cmd *cobra.Command, args []string) error { - logger := log.FromContext(cmd.Context()) - if err := closeDBContext(cmd, args); err != nil { + PersistentPostRunE: func(c *cobra.Command, args []string) error { + logger := log.FromContext(c.Context()) + if err := cmd.CloseDBContext(c, args); err != nil { logger.Error("failed to close backend", "err", err) return ErrInternalServerError } @@ -147,8 +148,8 @@ var ( ) func init() { - hookCmd.PersistentFlags().StringVar(&configPath, "config", "", "path to config file (deprecated)") - hookCmd.AddCommand( + Command.PersistentFlags().StringVar(&configPath, "config", "", "path to config file (deprecated)") + Command.AddCommand( preReceiveCmd, updateCmd, postReceiveCmd, @@ -163,47 +164,3 @@ func runCommand(ctx context.Context, in io.Reader, out io.Writer, err io.Writer, cmd.Stderr = err return cmd.Run() } - -const updateHookExample = `#!/bin/sh -# -# An example hook script to echo information about the push -# and send it to the client. -# -# To enable this hook, rename this file to "update" and make it executable. - -refname="$1" -oldrev="$2" -newrev="$3" - -# Safety check -if [ -z "$GIT_DIR" ]; then - echo "Don't run this script from the command line." >&2 - echo " (if you want, you could supply GIT_DIR then run" >&2 - echo " $0 )" >&2 - exit 1 -fi - -if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then - echo "usage: $0 " >&2 - exit 1 -fi - -# Check types -# if $newrev is 0000...0000, it's a commit to delete a ref. -zero=$(git hash-object --stdin = 0 { - if err := sb.SetAnonAccess(ctx, anon); err != nil { - fmt.Fprintf(os.Stderr, "failed to set anon access: %s\n", err) - } - } - - // Copy repos - if reposPath != "" { - logger.Info("Copying repos...") - if err := os.MkdirAll(filepath.Join(cfg.DataPath, "repos"), os.ModePerm); err != nil { - return fmt.Errorf("failed to create repos directory: %w", err) - } - - dirs, err := os.ReadDir(reposPath) - if err != nil { - return fmt.Errorf("failed to read repos directory: %w", err) - } - - for _, dir := range dirs { - if !dir.IsDir() || dir.Name() == "config" { - continue - } - - if !isGitDir(filepath.Join(reposPath, dir.Name())) { - continue - } - - logger.Infof(" Copying repo %s", dir.Name()) - src := filepath.Join(reposPath, utils.SanitizeRepo(dir.Name())) - dst := filepath.Join(cfg.DataPath, "repos", utils.SanitizeRepo(dir.Name())) + ".git" - if err := os.MkdirAll(dst, os.ModePerm); err != nil { - return fmt.Errorf("failed to create repo directory: %w", err) - } - - if err := copyDir(src, dst); err != nil { - return fmt.Errorf("failed to copy repo: %w", err) - } - - if _, err := sb.CreateRepository(ctx, dir.Name(), nil, proto.RepositoryOptions{}); err != nil { - fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err) - } - } - - if hasReadme { - logger.Infof(" Copying readme from \"config\" to \".soft-serve\"") - - // Switch to main branch - bcmd := git.NewCommand("branch", "-M", "main") - - rp := filepath.Join(cfg.DataPath, "repos", ".soft-serve.git") - nr, err := git.Init(rp, true) - if err != nil { - return fmt.Errorf("failed to init repo: %w", err) - } - - if _, err := nr.SymbolicRef("HEAD", gitm.RefsHeads+"main"); err != nil { - return fmt.Errorf("failed to set HEAD: %w", err) - } - - tmpDir, err := os.MkdirTemp("", "soft-serve") - if err != nil { - return fmt.Errorf("failed to create temp dir: %w", err) - } - - r, err := git.Init(tmpDir, false) - if err != nil { - return fmt.Errorf("failed to clone repo: %w", err) - } - - if _, err := bcmd.RunInDir(tmpDir); err != nil { - return fmt.Errorf("failed to create main branch: %w", err) - } - - if err := os.WriteFile(filepath.Join(tmpDir, readmePath), []byte(readme), 0o644); err != nil { // nolint: gosec - return fmt.Errorf("failed to write readme: %w", err) - } - - if err := r.Add(gitm.AddOptions{ - All: true, - }); err != nil { - return fmt.Errorf("failed to add readme: %w", err) - } - - if err := r.Commit(&gitm.Signature{ - Name: "Soft Serve", - Email: "vt100@charm.sh", - When: time.Now(), - }, "Add readme"); err != nil { - return fmt.Errorf("failed to commit readme: %w", err) - } - - if err := r.RemoteAdd("origin", "file://"+rp); err != nil { - return fmt.Errorf("failed to add remote: %w", err) - } - - if err := r.Push("origin", "main"); err != nil { - return fmt.Errorf("failed to push readme: %w", err) - } - - // Create `.soft-serve` repository and add readme - if _, err := sb.CreateRepository(ctx, ".soft-serve", nil, proto.RepositoryOptions{ - ProjectName: "Home", - Description: "Soft Serve home repository", - Hidden: true, - Private: false, - }); err != nil { - fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err) - } - } - } - - // Set repos metadata & collabs - logger.Info("Setting repos metadata & collabs...") - for _, r := range ocfg.Repos { - repo, name := r.Repo, r.Name - // Special case for config repo - if repo == "config" { - repo = ".soft-serve" - r.Private = false - } - - if err := sb.SetProjectName(ctx, repo, name); err != nil { - logger.Errorf("failed to set repo name to %s: %s", repo, err) - } - - if err := sb.SetDescription(ctx, repo, r.Note); err != nil { - logger.Errorf("failed to set repo description to %s: %s", repo, err) - } - - if err := sb.SetPrivate(ctx, repo, r.Private); err != nil { - logger.Errorf("failed to set repo private to %s: %s", repo, err) - } - - for _, collab := range r.Collabs { - if err := sb.AddCollaborator(ctx, repo, collab, access.ReadWriteAccess); err != nil { - logger.Errorf("failed to add repo collab to %s: %s", repo, err) - } - } - } - - // Create users & collabs - logger.Info("Creating users & collabs...") - for _, user := range ocfg.Users { - keys := make(map[string]ssh.PublicKey) - for _, key := range user.PublicKeys { - pk, _, err := sshutils.ParseAuthorizedKey(key) - if err != nil { - continue - } - ak := sshutils.MarshalAuthorizedKey(pk) - keys[ak] = pk - } - - pubkeys := make([]ssh.PublicKey, 0) - for _, pk := range keys { - pubkeys = append(pubkeys, pk) - } - - username := strings.ToLower(user.Name) - username = strings.ReplaceAll(username, " ", "-") - logger.Infof("Creating user %q", username) - if _, err := sb.CreateUser(ctx, username, proto.UserOptions{ - Admin: user.Admin, - PublicKeys: pubkeys, - }); err != nil { - logger.Errorf("failed to create user: %s", err) - } - - for _, repo := range user.CollabRepos { - if err := sb.AddCollaborator(ctx, repo, username, access.ReadWriteAccess); err != nil { - logger.Errorf("failed to add user collab to %s: %s\n", repo, err) - } - } - } - - logger.Info("Writing config...") - defer logger.Info("Done!") - return cfg.WriteConfig() - }, -} - -// Returns true if path is a directory containing an `objects` directory and a -// `HEAD` file. -func isGitDir(path string) bool { - stat, err := os.Stat(filepath.Join(path, "objects")) - if err != nil { - return false - } - if !stat.IsDir() { - return false - } - - stat, err = os.Stat(filepath.Join(path, "HEAD")) - if err != nil { - return false - } - if stat.IsDir() { - return false - } - - return true -} - -// copyFile copies a single file from src to dst. -func copyFile(src, dst string) error { - var err error - var srcfd *os.File - var dstfd *os.File - var srcinfo os.FileInfo - - if srcfd, err = os.Open(src); err != nil { - return err - } - defer srcfd.Close() // nolint: errcheck - - if dstfd, err = os.Create(dst); err != nil { - return err - } - defer dstfd.Close() // nolint: errcheck - - if _, err = io.Copy(dstfd, srcfd); err != nil { - return err - } - if srcinfo, err = os.Stat(src); err != nil { - return err - } - return os.Chmod(dst, srcinfo.Mode()) -} - -// copyDir copies a whole directory recursively. -func copyDir(src string, dst string) error { - var err error - var fds []os.DirEntry - var srcinfo os.FileInfo - - if srcinfo, err = os.Stat(src); err != nil { - return err - } - - if err = os.MkdirAll(dst, srcinfo.Mode()); err != nil { - return err - } - - if fds, err = os.ReadDir(src); err != nil { - return err - } - - for _, fd := range fds { - srcfp := filepath.Join(src, fd.Name()) - dstfp := filepath.Join(dst, fd.Name()) - - if fd.IsDir() { - if err = copyDir(srcfp, dstfp); err != nil { - err = errors.Join(err, err) - } - } else { - if err = copyFile(srcfp, dstfp); err != nil { - err = errors.Join(err, err) - } - } - } - - return err -} - -// Config is the configuration for the server. -type Config struct { - Name string `yaml:"name" json:"name"` - Host string `yaml:"host" json:"host"` - Port int `yaml:"port" json:"port"` - AnonAccess string `yaml:"anon-access" json:"anon-access"` - AllowKeyless bool `yaml:"allow-keyless" json:"allow-keyless"` - Users []User `yaml:"users" json:"users"` - Repos []RepoConfig `yaml:"repos" json:"repos"` -} - -// User contains user-level configuration for a repository. -type User struct { - Name string `yaml:"name" json:"name"` - Admin bool `yaml:"admin" json:"admin"` - PublicKeys []string `yaml:"public-keys" json:"public-keys"` - CollabRepos []string `yaml:"collab-repos" json:"collab-repos"` -} - -// RepoConfig is a repository configuration. -type RepoConfig struct { - Name string `yaml:"name" json:"name"` - Repo string `yaml:"repo" json:"repo"` - Note string `yaml:"note" json:"note"` - Private bool `yaml:"private" json:"private"` - Readme string `yaml:"readme" json:"readme"` - Collabs []string `yaml:"collabs" json:"collabs"` -} diff --git a/cmd/soft/serve.go b/cmd/soft/serve/serve.go similarity index 56% rename from cmd/soft/serve.go rename to cmd/soft/serve/serve.go index 0111f311a..eeebeaac2 100644 --- a/cmd/soft/serve.go +++ b/cmd/soft/serve/serve.go @@ -1,4 +1,4 @@ -package main +package serve import ( "context" @@ -9,26 +9,25 @@ import ( "syscall" "time" - "github.com/charmbracelet/soft-serve/server" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/migrate" - "github.com/charmbracelet/soft-serve/server/hooks" + "github.com/charmbracelet/soft-serve/cmd" + "github.com/charmbracelet/soft-serve/pkg/backend" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/db" + "github.com/charmbracelet/soft-serve/pkg/db/migrate" "github.com/spf13/cobra" ) var ( syncHooks bool - serveCmd = &cobra.Command{ + Command = &cobra.Command{ Use: "serve", Short: "Start the server", Args: cobra.NoArgs, - PersistentPreRunE: initBackendContext, - PersistentPostRunE: closeDBContext, - RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() + PersistentPreRunE: cmd.InitBackendContext, + PersistentPostRunE: cmd.CloseDBContext, + RunE: func(c *cobra.Command, _ []string) error { + ctx := c.Context() cfg := config.DefaultConfig() if cfg.Exist() { if err := cfg.ParseFile(); err != nil { @@ -67,14 +66,14 @@ var ( return fmt.Errorf("migration error: %w", err) } - s, err := server.NewServer(ctx) + s, err := NewServer(ctx) if err != nil { return fmt.Errorf("start server: %w", err) } if syncHooks { be := backend.FromContext(ctx) - if err := initializeHooks(ctx, cfg, be); err != nil { + if err := cmd.InitializeHooks(ctx, cfg, be); err != nil { return fmt.Errorf("initialize hooks: %w", err) } } @@ -103,20 +102,49 @@ var ( ) func init() { - serveCmd.Flags().BoolVarP(&syncHooks, "sync-hooks", "", false, "synchronize hooks for all repositories before running the server") + Command.Flags().BoolVarP(&syncHooks, "sync-hooks", "", false, "synchronize hooks for all repositories before running the server") } -func initializeHooks(ctx context.Context, cfg *config.Config, be *backend.Backend) error { - repos, err := be.Repositories(ctx) - if err != nil { - return err - } - - for _, repo := range repos { - if err := hooks.GenerateHooks(ctx, cfg, repo.Name()); err != nil { - return err - } - } - - return nil -} +const updateHookExample = `#!/bin/sh +# +# An example hook script to echo information about the push +# and send it to the client. +# +# To enable this hook, rename this file to "update" and make it executable. + +refname="$1" +oldrev="$2" +newrev="$3" + +# Safety check +if [ -z "$GIT_DIR" ]; then + echo "Don't run this script from the command line." >&2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero=$(git hash-object --stdin %d, want %d", c.in, out, c.out) - } - } -} diff --git a/server/access/context.go b/server/access/context.go deleted file mode 100644 index 3bc08c011..000000000 --- a/server/access/context.go +++ /dev/null @@ -1,20 +0,0 @@ -package access - -import "context" - -// ContextKey is the context key for the access level. -var ContextKey = &struct{ string }{"access"} - -// FromContext returns the access level from the context. -func FromContext(ctx context.Context) AccessLevel { - if ac, ok := ctx.Value(ContextKey).(AccessLevel); ok { - return ac - } - - return -1 -} - -// WithContext returns a new context with the access level. -func WithContext(ctx context.Context, ac AccessLevel) context.Context { - return context.WithValue(ctx, ContextKey, ac) -} diff --git a/server/backend/access_token.go b/server/backend/access_token.go deleted file mode 100644 index bff3b25ba..000000000 --- a/server/backend/access_token.go +++ /dev/null @@ -1,78 +0,0 @@ -package backend - -import ( - "context" - "errors" - "time" - - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/proto" -) - -// CreateAccessToken creates an access token for user. -func (b *Backend) CreateAccessToken(ctx context.Context, user proto.User, name string, expiresAt time.Time) (string, error) { - token := GenerateToken() - tokenHash := HashToken(token) - - if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error { - _, err := b.store.CreateAccessToken(ctx, tx, name, user.ID(), tokenHash, expiresAt) - if err != nil { - return db.WrapError(err) - } - - return nil - }); err != nil { - return "", err - } - - return token, nil -} - -// DeleteAccessToken deletes an access token for a user. -func (b *Backend) DeleteAccessToken(ctx context.Context, user proto.User, id int64) error { - err := b.db.TransactionContext(ctx, func(tx *db.Tx) error { - _, err := b.store.GetAccessToken(ctx, tx, id) - if err != nil { - return db.WrapError(err) - } - - if err := b.store.DeleteAccessTokenForUser(ctx, tx, user.ID(), id); err != nil { - return db.WrapError(err) - } - return nil - }) - if err != nil { - if errors.Is(err, db.ErrRecordNotFound) { - return proto.ErrTokenNotFound - } - return err - } - - return nil -} - -// ListAccessTokens lists access tokens for a user. -func (b *Backend) ListAccessTokens(ctx context.Context, user proto.User) ([]proto.AccessToken, error) { - accessTokens, err := b.store.GetAccessTokensByUserID(ctx, b.db, user.ID()) - if err != nil { - return nil, db.WrapError(err) - } - - var tokens []proto.AccessToken - for _, t := range accessTokens { - token := proto.AccessToken{ - ID: t.ID, - Name: t.Name, - TokenHash: t.Token, - UserID: t.UserID, - CreatedAt: t.CreatedAt, - } - if t.ExpiresAt.Valid { - token.ExpiresAt = t.ExpiresAt.Time - } - - tokens = append(tokens, token) - } - - return tokens, nil -} diff --git a/server/backend/auth.go b/server/backend/auth.go deleted file mode 100644 index 237133478..000000000 --- a/server/backend/auth.go +++ /dev/null @@ -1,45 +0,0 @@ -package backend - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" - - "github.com/charmbracelet/log" - "golang.org/x/crypto/bcrypt" -) - -const saltySalt = "salty-soft-serve" - -// HashPassword hashes the password using bcrypt. -func HashPassword(password string) (string, error) { - crypt, err := bcrypt.GenerateFromPassword([]byte(password+saltySalt), bcrypt.DefaultCost) - if err != nil { - return "", err - } - - return string(crypt), nil -} - -// VerifyPassword verifies the password against the hash. -func VerifyPassword(password, hash string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+saltySalt)) - return err == nil -} - -// GenerateToken returns a random unique token. -func GenerateToken() string { - buf := make([]byte, 20) - if _, err := rand.Read(buf); err != nil { - log.Error("unable to generate access token") - return "" - } - - return "ss_" + hex.EncodeToString(buf) -} - -// HashToken hashes the token using sha256. -func HashToken(token string) string { - sum := sha256.Sum256([]byte(token + saltySalt)) - return hex.EncodeToString(sum[:]) -} diff --git a/server/backend/auth_test.go b/server/backend/auth_test.go deleted file mode 100644 index db40db3b9..000000000 --- a/server/backend/auth_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package backend - -import "testing" - -func TestHashPassword(t *testing.T) { - hash, err := HashPassword("password") - if err != nil { - t.Fatal(err) - } - if hash == "" { - t.Fatal("hash is empty") - } -} - -func TestVerifyPassword(t *testing.T) { - hash, err := HashPassword("password") - if err != nil { - t.Fatal(err) - } - if !VerifyPassword("password", hash) { - t.Fatal("password did not verify") - } -} - -func TestGenerateToken(t *testing.T) { - token := GenerateToken() - if token == "" { - t.Fatal("token is empty") - } -} - -func TestHashToken(t *testing.T) { - token := GenerateToken() - hash := HashToken(token) - if hash == "" { - t.Fatal("hash is empty") - } -} diff --git a/server/backend/backend.go b/server/backend/backend.go deleted file mode 100644 index ba9ad61d3..000000000 --- a/server/backend/backend.go +++ /dev/null @@ -1,43 +0,0 @@ -package backend - -import ( - "context" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/charmbracelet/soft-serve/server/task" -) - -// Backend is the Soft Serve backend that handles users, repositories, and -// server settings management and operations. -type Backend struct { - ctx context.Context - cfg *config.Config - db *db.DB - store store.Store - logger *log.Logger - cache *cache - manager *task.Manager -} - -// New returns a new Soft Serve backend. -func New(ctx context.Context, cfg *config.Config, db *db.DB) *Backend { - dbstore := store.FromContext(ctx) - logger := log.FromContext(ctx).WithPrefix("backend") - b := &Backend{ - ctx: ctx, - cfg: cfg, - db: db, - store: dbstore, - logger: logger, - manager: task.NewManager(ctx), - } - - // TODO: implement a proper caching interface - cache := newCache(b, 1000) - b.cache = cache - - return b -} diff --git a/server/backend/cache.go b/server/backend/cache.go deleted file mode 100644 index 8d99401dd..000000000 --- a/server/backend/cache.go +++ /dev/null @@ -1,35 +0,0 @@ -package backend - -import lru "github.com/hashicorp/golang-lru/v2" - -// TODO: implement a caching interface. -type cache struct { - b *Backend - repos *lru.Cache[string, *repo] -} - -func newCache(b *Backend, size int) *cache { - if size <= 0 { - size = 1 - } - c := &cache{b: b} - cache, _ := lru.New[string, *repo](size) - c.repos = cache - return c -} - -func (c *cache) Get(repo string) (*repo, bool) { - return c.repos.Get(repo) -} - -func (c *cache) Set(repo string, r *repo) { - c.repos.Add(repo, r) -} - -func (c *cache) Delete(repo string) { - c.repos.Remove(repo) -} - -func (c *cache) Len() int { - return c.repos.Len() -} diff --git a/server/backend/collab.go b/server/backend/collab.go deleted file mode 100644 index 6b03c5d0e..000000000 --- a/server/backend/collab.go +++ /dev/null @@ -1,118 +0,0 @@ -package backend - -import ( - "context" - "errors" - "strings" - - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/utils" - "github.com/charmbracelet/soft-serve/server/webhook" -) - -// AddCollaborator adds a collaborator to a repository. -// -// It implements backend.Backend. -func (d *Backend) AddCollaborator(ctx context.Context, repo string, username string, level access.AccessLevel) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - repo = utils.SanitizeRepo(repo) - r, err := d.Repository(ctx, repo) - if err != nil { - return err - } - - if err := db.WrapError( - d.db.TransactionContext(ctx, func(tx *db.Tx) error { - return d.store.AddCollabByUsernameAndRepo(ctx, tx, username, repo, level) - }), - ); err != nil { - return err - } - - wh, err := webhook.NewCollaboratorEvent(ctx, proto.UserFromContext(ctx), r, username, webhook.CollaboratorEventAdded) - if err != nil { - return err - } - - return webhook.SendEvent(ctx, wh) -} - -// Collaborators returns a list of collaborators for a repository. -// -// It implements backend.Backend. -func (d *Backend) Collaborators(ctx context.Context, repo string) ([]string, error) { - repo = utils.SanitizeRepo(repo) - var users []models.User - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - users, err = d.store.ListCollabsByRepoAsUsers(ctx, tx, repo) - return err - }); err != nil { - return nil, db.WrapError(err) - } - - var usernames []string - for _, u := range users { - usernames = append(usernames, u.Username) - } - - return usernames, nil -} - -// IsCollaborator returns the access level and true if the user is a collaborator of the repository. -// -// It implements backend.Backend. -func (d *Backend) IsCollaborator(ctx context.Context, repo string, username string) (access.AccessLevel, bool, error) { - if username == "" { - return -1, false, nil - } - - repo = utils.SanitizeRepo(repo) - var m models.Collab - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - m, err = d.store.GetCollabByUsernameAndRepo(ctx, tx, username, repo) - return err - }); err != nil { - return -1, false, db.WrapError(err) - } - - return m.AccessLevel, m.ID > 0, nil -} - -// RemoveCollaborator removes a collaborator from a repository. -// -// It implements backend.Backend. -func (d *Backend) RemoveCollaborator(ctx context.Context, repo string, username string) error { - repo = utils.SanitizeRepo(repo) - r, err := d.Repository(ctx, repo) - if err != nil { - return err - } - - wh, err := webhook.NewCollaboratorEvent(ctx, proto.UserFromContext(ctx), r, username, webhook.CollaboratorEventRemoved) - if err != nil { - return err - } - - if err := db.WrapError( - d.db.TransactionContext(ctx, func(tx *db.Tx) error { - return d.store.RemoveCollabByUsernameAndRepo(ctx, tx, username, repo) - }), - ); err != nil { - if errors.Is(err, db.ErrRecordNotFound) { - return proto.ErrCollaboratorNotFound - } - - return err - } - - return webhook.SendEvent(ctx, wh) -} diff --git a/server/backend/context.go b/server/backend/context.go deleted file mode 100644 index 8971bbf93..000000000 --- a/server/backend/context.go +++ /dev/null @@ -1,20 +0,0 @@ -package backend - -import "context" - -// ContextKey is the key for the backend in the context. -var ContextKey = &struct{ string }{"backend"} - -// FromContext returns the backend from a context. -func FromContext(ctx context.Context) *Backend { - if b, ok := ctx.Value(ContextKey).(*Backend); ok { - return b - } - - return nil -} - -// WithContext returns a new context with the backend attached. -func WithContext(ctx context.Context, b *Backend) context.Context { - return context.WithValue(ctx, ContextKey, b) -} diff --git a/server/backend/hooks.go b/server/backend/hooks.go deleted file mode 100644 index 3e5297eb9..000000000 --- a/server/backend/hooks.go +++ /dev/null @@ -1,134 +0,0 @@ -package backend - -import ( - "context" - "io" - "os" - "sync" - - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/hooks" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/sshutils" - "github.com/charmbracelet/soft-serve/server/webhook" -) - -var _ hooks.Hooks = (*Backend)(nil) - -// PostReceive is called by the git post-receive hook. -// -// It implements Hooks. -func (d *Backend) PostReceive(_ context.Context, _ io.Writer, _ io.Writer, repo string, args []hooks.HookArg) { - d.logger.Debug("post-receive hook called", "repo", repo, "args", args) -} - -// PreReceive is called by the git pre-receive hook. -// -// It implements Hooks. -func (d *Backend) PreReceive(_ context.Context, _ io.Writer, _ io.Writer, repo string, args []hooks.HookArg) { - d.logger.Debug("pre-receive hook called", "repo", repo, "args", args) -} - -// Update is called by the git update hook. -// -// It implements Hooks. -func (d *Backend) Update(ctx context.Context, _ io.Writer, _ io.Writer, repo string, arg hooks.HookArg) { - d.logger.Debug("update hook called", "repo", repo, "arg", arg) - - // Find user - var user proto.User - if pubkey := os.Getenv("SOFT_SERVE_PUBLIC_KEY"); pubkey != "" { - pk, _, err := sshutils.ParseAuthorizedKey(pubkey) - if err != nil { - d.logger.Error("error parsing public key", "err", err) - return - } - - user, err = d.UserByPublicKey(ctx, pk) - if err != nil { - d.logger.Error("error finding user from public key", "key", pubkey, "err", err) - return - } - } else if username := os.Getenv("SOFT_SERVE_USERNAME"); username != "" { - var err error - user, err = d.User(ctx, username) - if err != nil { - d.logger.Error("error finding user from username", "username", username, "err", err) - return - } - } else { - d.logger.Error("error finding user") - return - } - - // Get repo - r, err := d.Repository(ctx, repo) - if err != nil { - d.logger.Error("error finding repository", "repo", repo, "err", err) - return - } - - // TODO: run this async - // This would probably need something like an RPC server to communicate with the hook process. - if git.IsZeroHash(arg.OldSha) || git.IsZeroHash(arg.NewSha) { - wh, err := webhook.NewBranchTagEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha) - if err != nil { - d.logger.Error("error creating branch_tag webhook", "err", err) - } else if err := webhook.SendEvent(ctx, wh); err != nil { - d.logger.Error("error sending branch_tag webhook", "err", err) - } - } - wh, err := webhook.NewPushEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha) - if err != nil { - d.logger.Error("error creating push webhook", "err", err) - } else if err := webhook.SendEvent(ctx, wh); err != nil { - d.logger.Error("error sending push webhook", "err", err) - } -} - -// PostUpdate is called by the git post-update hook. -// -// It implements Hooks. -func (d *Backend) PostUpdate(ctx context.Context, _ io.Writer, _ io.Writer, repo string, args ...string) { - d.logger.Debug("post-update hook called", "repo", repo, "args", args) - - var wg sync.WaitGroup - - // Populate last-modified file. - wg.Add(1) - go func() { - defer wg.Done() - if err := populateLastModified(ctx, d, repo); err != nil { - d.logger.Error("error populating last-modified", "repo", repo, "err", err) - return - } - }() - - wg.Wait() -} - -func populateLastModified(ctx context.Context, d *Backend, name string) error { - var rr *repo - _rr, err := d.Repository(ctx, name) - if err != nil { - return err - } - - if r, ok := _rr.(*repo); ok { - rr = r - } else { - return proto.ErrRepoNotFound - } - - r, err := rr.Open() - if err != nil { - return err - } - - c, err := r.LatestCommitTime() - if err != nil { - return err - } - - return rr.writeLastModified(c) -} diff --git a/server/backend/lfs.go b/server/backend/lfs.go deleted file mode 100644 index 5b0d5a406..000000000 --- a/server/backend/lfs.go +++ /dev/null @@ -1,88 +0,0 @@ -package backend - -import ( - "context" - "errors" - "io" - "path" - "path/filepath" - "strconv" - - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/lfs" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/storage" - "github.com/charmbracelet/soft-serve/server/store" -) - -// StoreRepoMissingLFSObjects stores missing LFS objects for a repository. -func StoreRepoMissingLFSObjects(ctx context.Context, repo proto.Repository, dbx *db.DB, store store.Store, lfsClient lfs.Client) error { - cfg := config.FromContext(ctx) - repoID := strconv.FormatInt(repo.ID(), 10) - lfsRoot := filepath.Join(cfg.DataPath, "lfs", repoID) - - // TODO: support S3 storage - strg := storage.NewLocalStorage(lfsRoot) - pointerChan := make(chan lfs.PointerBlob) - errChan := make(chan error, 1) - r, err := repo.Open() - if err != nil { - return err - } - - go lfs.SearchPointerBlobs(ctx, r, pointerChan, errChan) - - download := func(pointers []lfs.Pointer) error { - return lfsClient.Download(ctx, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error { - if objectError != nil { - return objectError - } - - defer content.Close() // nolint: errcheck - return dbx.TransactionContext(ctx, func(tx *db.Tx) error { - if err := store.CreateLFSObject(ctx, tx, repo.ID(), p.Oid, p.Size); err != nil { - return db.WrapError(err) - } - - _, err := strg.Put(path.Join("objects", p.RelativePath()), content) - return err - }) - }) - } - - var batch []lfs.Pointer - for pointer := range pointerChan { - obj, err := store.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid) - if err != nil && !errors.Is(err, db.ErrRecordNotFound) { - return db.WrapError(err) - } - - exist, err := strg.Exists(path.Join("objects", pointer.RelativePath())) - if err != nil { - return err - } - - if exist && obj.ID == 0 { - if err := store.CreateLFSObject(ctx, dbx, repo.ID(), pointer.Oid, pointer.Size); err != nil { - return db.WrapError(err) - } - } else { - batch = append(batch, pointer.Pointer) - // Limit batch requests to 20 objects - if len(batch) >= 20 { - if err := download(batch); err != nil { - return err - } - - batch = nil - } - } - } - - if err, ok := <-errChan; ok { - return err - } - - return nil -} diff --git a/server/backend/repo.go b/server/backend/repo.go deleted file mode 100644 index 2a8a8d1be..000000000 --- a/server/backend/repo.go +++ /dev/null @@ -1,756 +0,0 @@ -package backend - -import ( - "bufio" - "context" - "errors" - "fmt" - "io/fs" - "os" - "path" - "path/filepath" - "strconv" - "time" - - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "github.com/charmbracelet/soft-serve/server/hooks" - "github.com/charmbracelet/soft-serve/server/lfs" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/storage" - "github.com/charmbracelet/soft-serve/server/task" - "github.com/charmbracelet/soft-serve/server/utils" - "github.com/charmbracelet/soft-serve/server/webhook" -) - -func (d *Backend) reposPath() string { - return filepath.Join(d.cfg.DataPath, "repos") -} - -// CreateRepository creates a new repository. -// -// It implements backend.Backend. -func (d *Backend) CreateRepository(ctx context.Context, name string, user proto.User, opts proto.RepositoryOptions) (proto.Repository, error) { - name = utils.SanitizeRepo(name) - if err := utils.ValidateRepo(name); err != nil { - return nil, err - } - - repo := name + ".git" - rp := filepath.Join(d.reposPath(), repo) - - var userID int64 - if user != nil { - userID = user.ID() - } - - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - if err := d.store.CreateRepo( - ctx, - tx, - name, - userID, - opts.ProjectName, - opts.Description, - opts.Private, - opts.Hidden, - opts.Mirror, - ); err != nil { - return err - } - - _, err := git.Init(rp, true) - if err != nil { - d.logger.Debug("failed to create repository", "err", err) - return err - } - - if err := os.WriteFile(filepath.Join(rp, "description"), []byte(opts.Description), fs.ModePerm); err != nil { - d.logger.Error("failed to write description", "repo", name, "err", err) - return err - } - - if !opts.Private { - if err := os.WriteFile(filepath.Join(rp, "git-daemon-export-ok"), []byte{}, fs.ModePerm); err != nil { - d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err) - return err - } - } - - return hooks.GenerateHooks(ctx, d.cfg, repo) - }); err != nil { - d.logger.Debug("failed to create repository in database", "err", err) - err = db.WrapError(err) - if errors.Is(err, db.ErrDuplicateKey) { - return nil, proto.ErrRepoExist - } - - return nil, err - } - - return d.Repository(ctx, name) -} - -// ImportRepository imports a repository from remote. -// XXX: This a expensive operation and should be run in a goroutine. -func (d *Backend) ImportRepository(_ context.Context, name string, user proto.User, remote string, opts proto.RepositoryOptions) (proto.Repository, error) { - name = utils.SanitizeRepo(name) - if err := utils.ValidateRepo(name); err != nil { - return nil, err - } - - repo := name + ".git" - rp := filepath.Join(d.reposPath(), repo) - - tid := "import:" + name - if d.manager.Exists(tid) { - return nil, task.ErrAlreadyStarted - } - - if _, err := os.Stat(rp); err == nil || os.IsExist(err) { - return nil, proto.ErrRepoExist - } - - done := make(chan error, 1) - repoc := make(chan proto.Repository, 1) - d.logger.Info("importing repository", "name", name, "remote", remote, "path", rp) - d.manager.Add(tid, func(ctx context.Context) (err error) { - copts := git.CloneOptions{ - Bare: true, - Mirror: opts.Mirror, - Quiet: true, - CommandOptions: git.CommandOptions{ - Timeout: -1, - Context: ctx, - Envs: []string{ - fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`, - filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"), - d.cfg.SSH.ClientKeyPath, - ), - }, - }, - } - - if err := git.Clone(remote, rp, copts); err != nil { - d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp) - // Cleanup the mess! - if rerr := os.RemoveAll(rp); rerr != nil { - err = errors.Join(err, rerr) - } - - return err - } - - r, err := d.CreateRepository(ctx, name, user, opts) - if err != nil { - d.logger.Error("failed to create repository", "err", err, "name", name) - return err - } - - defer func() { - if err != nil { - if rerr := d.DeleteRepository(ctx, name); rerr != nil { - d.logger.Error("failed to delete repository", "err", rerr, "name", name) - } - } - }() - - rr, err := r.Open() - if err != nil { - d.logger.Error("failed to open repository", "err", err, "path", rp) - return err - } - - repoc <- r - - rcfg, err := rr.Config() - if err != nil { - d.logger.Error("failed to get repository config", "err", err, "path", rp) - return err - } - - endpoint := remote - if opts.LFSEndpoint != "" { - endpoint = opts.LFSEndpoint - } - - rcfg.Section("lfs").SetOption("url", endpoint) - - if err := rr.SetConfig(rcfg); err != nil { - d.logger.Error("failed to set repository config", "err", err, "path", rp) - return err - } - - ep, err := lfs.NewEndpoint(endpoint) - if err != nil { - d.logger.Error("failed to create lfs endpoint", "err", err, "path", rp) - return err - } - - client := lfs.NewClient(ep) - if client == nil { - return fmt.Errorf("failed to create lfs client: unsupported endpoint %s", endpoint) - } - - if err := StoreRepoMissingLFSObjects(ctx, r, d.db, d.store, client); err != nil { - d.logger.Error("failed to store missing lfs objects", "err", err, "path", rp) - return err - } - - return nil - }) - - go func() { - d.logger.Info("running import", "name", name) - d.manager.Run(tid, done) - }() - - return <-repoc, <-done -} - -// DeleteRepository deletes a repository. -// -// It implements backend.Backend. -func (d *Backend) DeleteRepository(ctx context.Context, name string) error { - name = utils.SanitizeRepo(name) - repo := name + ".git" - rp := filepath.Join(d.reposPath(), repo) - - user := proto.UserFromContext(ctx) - r, err := d.Repository(ctx, name) - if err != nil { - return err - } - - // We create the webhook event before deleting the repository so we can - // send the event after deleting the repository. - wh, err := webhook.NewRepositoryEvent(ctx, user, r, webhook.RepositoryEventActionDelete) - if err != nil { - return err - } - - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - // Delete repo from cache - defer d.cache.Delete(name) - - repom, dberr := d.store.GetRepoByName(ctx, tx, name) - _, ferr := os.Stat(rp) - if dberr != nil && ferr != nil { - return proto.ErrRepoNotFound - } - - // If the repo is not in the database but the directory exists, remove it - if dberr != nil && ferr == nil { - return os.RemoveAll(rp) - } else if dberr != nil { - return db.WrapError(dberr) - } - - repoID := strconv.FormatInt(repom.ID, 10) - strg := storage.NewLocalStorage(filepath.Join(d.cfg.DataPath, "lfs", repoID)) - objs, err := d.store.GetLFSObjectsByName(ctx, tx, name) - if err != nil { - return db.WrapError(err) - } - - for _, obj := range objs { - p := lfs.Pointer{ - Oid: obj.Oid, - Size: obj.Size, - } - - d.logger.Debug("deleting lfs object", "repo", name, "oid", obj.Oid) - if err := strg.Delete(path.Join("objects", p.RelativePath())); err != nil { - d.logger.Error("failed to delete lfs object", "repo", name, "err", err, "oid", obj.Oid) - } - } - - if err := d.store.DeleteRepoByName(ctx, tx, name); err != nil { - return db.WrapError(err) - } - - return os.RemoveAll(rp) - }); err != nil { - if errors.Is(err, db.ErrRecordNotFound) { - return proto.ErrRepoNotFound - } - - return db.WrapError(err) - } - - return webhook.SendEvent(ctx, wh) -} - -// DeleteUserRepositories deletes all user repositories. -func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) error { - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - user, err := d.store.FindUserByUsername(ctx, tx, username) - if err != nil { - return err - } - - repos, err := d.store.GetUserRepos(ctx, tx, user.ID) - if err != nil { - return err - } - - for _, repo := range repos { - if err := d.DeleteRepository(ctx, repo.Name); err != nil { - return err - } - } - - return nil - }); err != nil { - return db.WrapError(err) - } - - return nil -} - -// RenameRepository renames a repository. -// -// It implements backend.Backend. -func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName string) error { - oldName = utils.SanitizeRepo(oldName) - if err := utils.ValidateRepo(oldName); err != nil { - return err - } - - newName = utils.SanitizeRepo(newName) - if err := utils.ValidateRepo(newName); err != nil { - return err - } - - if oldName == newName { - return nil - } - - oldRepo := oldName + ".git" - newRepo := newName + ".git" - op := filepath.Join(d.reposPath(), oldRepo) - np := filepath.Join(d.reposPath(), newRepo) - if _, err := os.Stat(op); err != nil { - return proto.ErrRepoNotFound - } - - if _, err := os.Stat(np); err == nil { - return proto.ErrRepoExist - } - - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - // Delete cache - defer d.cache.Delete(oldName) - - if err := d.store.SetRepoNameByName(ctx, tx, oldName, newName); err != nil { - return err - } - - // Make sure the new repository parent directory exists. - if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil { - return err - } - - return os.Rename(op, np) - }); err != nil { - return db.WrapError(err) - } - - user := proto.UserFromContext(ctx) - repo, err := d.Repository(ctx, newName) - if err != nil { - return err - } - - wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionRename) - if err != nil { - return err - } - - return webhook.SendEvent(ctx, wh) -} - -// Repositories returns a list of repositories per page. -// -// It implements backend.Backend. -func (d *Backend) Repositories(ctx context.Context) ([]proto.Repository, error) { - repos := make([]proto.Repository, 0) - - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - ms, err := d.store.GetAllRepos(ctx, tx) - if err != nil { - return err - } - - for _, m := range ms { - r := &repo{ - name: m.Name, - path: filepath.Join(d.reposPath(), m.Name+".git"), - repo: m, - } - - // Cache repositories - d.cache.Set(m.Name, r) - - repos = append(repos, r) - } - - return nil - }); err != nil { - return nil, db.WrapError(err) - } - - return repos, nil -} - -// Repository returns a repository by name. -// -// It implements backend.Backend. -func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository, error) { - var m models.Repo - name = utils.SanitizeRepo(name) - - if r, ok := d.cache.Get(name); ok && r != nil { - return r, nil - } - - rp := filepath.Join(d.reposPath(), name+".git") - if _, err := os.Stat(rp); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - d.logger.Errorf("failed to stat repository path: %v", err) - } - return nil, proto.ErrRepoNotFound - } - - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - m, err = d.store.GetRepoByName(ctx, tx, name) - return db.WrapError(err) - }); err != nil { - if errors.Is(err, db.ErrRecordNotFound) { - return nil, proto.ErrRepoNotFound - } - return nil, db.WrapError(err) - } - - r := &repo{ - name: name, - path: rp, - repo: m, - } - - // Add to cache - d.cache.Set(name, r) - - return r, nil -} - -// Description returns the description of a repository. -// -// It implements backend.Backend. -func (d *Backend) Description(ctx context.Context, name string) (string, error) { - name = utils.SanitizeRepo(name) - var desc string - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - desc, err = d.store.GetRepoDescriptionByName(ctx, tx, name) - return err - }); err != nil { - return "", db.WrapError(err) - } - - return desc, nil -} - -// IsMirror returns true if the repository is a mirror. -// -// It implements backend.Backend. -func (d *Backend) IsMirror(ctx context.Context, name string) (bool, error) { - name = utils.SanitizeRepo(name) - var mirror bool - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - mirror, err = d.store.GetRepoIsMirrorByName(ctx, tx, name) - return err - }); err != nil { - return false, db.WrapError(err) - } - return mirror, nil -} - -// IsPrivate returns true if the repository is private. -// -// It implements backend.Backend. -func (d *Backend) IsPrivate(ctx context.Context, name string) (bool, error) { - name = utils.SanitizeRepo(name) - var private bool - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - private, err = d.store.GetRepoIsPrivateByName(ctx, tx, name) - return err - }); err != nil { - return false, db.WrapError(err) - } - - return private, nil -} - -// IsHidden returns true if the repository is hidden. -// -// It implements backend.Backend. -func (d *Backend) IsHidden(ctx context.Context, name string) (bool, error) { - name = utils.SanitizeRepo(name) - var hidden bool - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - hidden, err = d.store.GetRepoIsHiddenByName(ctx, tx, name) - return err - }); err != nil { - return false, db.WrapError(err) - } - - return hidden, nil -} - -// ProjectName returns the project name of a repository. -// -// It implements backend.Backend. -func (d *Backend) ProjectName(ctx context.Context, name string) (string, error) { - name = utils.SanitizeRepo(name) - var pname string - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - pname, err = d.store.GetRepoProjectNameByName(ctx, tx, name) - return err - }); err != nil { - return "", db.WrapError(err) - } - - return pname, nil -} - -// SetHidden sets the hidden flag of a repository. -// -// It implements backend.Backend. -func (d *Backend) SetHidden(ctx context.Context, name string, hidden bool) error { - name = utils.SanitizeRepo(name) - - // Delete cache - d.cache.Delete(name) - - return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error { - return d.store.SetRepoIsHiddenByName(ctx, tx, name, hidden) - })) -} - -// SetDescription sets the description of a repository. -// -// It implements backend.Backend. -func (d *Backend) SetDescription(ctx context.Context, name string, desc string) error { - name = utils.SanitizeRepo(name) - rp := filepath.Join(d.reposPath(), name+".git") - - // Delete cache - d.cache.Delete(name) - - return d.db.TransactionContext(ctx, func(tx *db.Tx) error { - if err := os.WriteFile(filepath.Join(rp, "description"), []byte(desc), fs.ModePerm); err != nil { - d.logger.Error("failed to write description", "repo", name, "err", err) - return err - } - - return d.store.SetRepoDescriptionByName(ctx, tx, name, desc) - }) -} - -// SetPrivate sets the private flag of a repository. -// -// It implements backend.Backend. -func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) error { - name = utils.SanitizeRepo(name) - rp := filepath.Join(d.reposPath(), name+".git") - - // Delete cache - d.cache.Delete(name) - - if err := db.WrapError( - d.db.TransactionContext(ctx, func(tx *db.Tx) error { - fp := filepath.Join(rp, "git-daemon-export-ok") - if !private { - if err := os.WriteFile(fp, []byte{}, fs.ModePerm); err != nil { - d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err) - return err - } - } else { - if _, err := os.Stat(fp); err == nil { - if err := os.Remove(fp); err != nil { - d.logger.Error("failed to remove git-daemon-export-ok", "repo", name, "err", err) - return err - } - } - } - - return d.store.SetRepoIsPrivateByName(ctx, tx, name, private) - }), - ); err != nil { - return err - } - - user := proto.UserFromContext(ctx) - repo, err := d.Repository(ctx, name) - if err != nil { - return err - } - - if repo.IsPrivate() != !private { - wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionVisibilityChange) - if err != nil { - return err - } - - if err := webhook.SendEvent(ctx, wh); err != nil { - return err - } - } - - return nil -} - -// SetProjectName sets the project name of a repository. -// -// It implements backend.Backend. -func (d *Backend) SetProjectName(ctx context.Context, repo string, name string) error { - repo = utils.SanitizeRepo(repo) - - // Delete cache - d.cache.Delete(repo) - - return db.WrapError( - d.db.TransactionContext(ctx, func(tx *db.Tx) error { - return d.store.SetRepoProjectNameByName(ctx, tx, repo, name) - }), - ) -} - -var _ proto.Repository = (*repo)(nil) - -// repo is a Git repository with metadata stored in a SQLite database. -type repo struct { - name string - path string - repo models.Repo -} - -// ID returns the repository's ID. -// -// It implements proto.Repository. -func (r *repo) ID() int64 { - return r.repo.ID -} - -// UserID returns the repository's owner's user ID. -// If the repository is not owned by anyone, it returns 0. -// -// It implements proto.Repository. -func (r *repo) UserID() int64 { - if r.repo.UserID.Valid { - return r.repo.UserID.Int64 - } - return 0 -} - -// Description returns the repository's description. -// -// It implements backend.Repository. -func (r *repo) Description() string { - return r.repo.Description -} - -// IsMirror returns whether the repository is a mirror. -// -// It implements backend.Repository. -func (r *repo) IsMirror() bool { - return r.repo.Mirror -} - -// IsPrivate returns whether the repository is private. -// -// It implements backend.Repository. -func (r *repo) IsPrivate() bool { - return r.repo.Private -} - -// Name returns the repository's name. -// -// It implements backend.Repository. -func (r *repo) Name() string { - return r.name -} - -// Open opens the repository. -// -// It implements backend.Repository. -func (r *repo) Open() (*git.Repository, error) { - return git.Open(r.path) -} - -// ProjectName returns the repository's project name. -// -// It implements backend.Repository. -func (r *repo) ProjectName() string { - return r.repo.ProjectName -} - -// IsHidden returns whether the repository is hidden. -// -// It implements backend.Repository. -func (r *repo) IsHidden() bool { - return r.repo.Hidden -} - -// CreatedAt returns the repository's creation time. -func (r *repo) CreatedAt() time.Time { - return r.repo.CreatedAt -} - -// UpdatedAt returns the repository's last update time. -func (r *repo) UpdatedAt() time.Time { - // Try to read the last modified time from the info directory. - if t, err := readOneline(filepath.Join(r.path, "info", "last-modified")); err == nil { - if t, err := time.Parse(time.RFC3339, t); err == nil { - return t - } - } - - rr, err := git.Open(r.path) - if err == nil { - t, err := rr.LatestCommitTime() - if err == nil { - return t - } - } - - return r.repo.UpdatedAt -} - -func (r *repo) writeLastModified(t time.Time) error { - fp := filepath.Join(r.path, "info", "last-modified") - if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil { - return err - } - - return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm) -} - -func readOneline(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - - defer f.Close() // nolint: errcheck - s := bufio.NewScanner(f) - s.Scan() - return s.Text(), s.Err() -} diff --git a/server/backend/settings.go b/server/backend/settings.go deleted file mode 100644 index 0879aa38d..000000000 --- a/server/backend/settings.go +++ /dev/null @@ -1,58 +0,0 @@ -package backend - -import ( - "context" - - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/db" -) - -// AllowKeyless returns whether or not keyless access is allowed. -// -// It implements backend.Backend. -func (b *Backend) AllowKeyless(ctx context.Context) bool { - var allow bool - if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - allow, err = b.store.GetAllowKeylessAccess(ctx, tx) - return err - }); err != nil { - return false - } - - return allow -} - -// SetAllowKeyless sets whether or not keyless access is allowed. -// -// It implements backend.Backend. -func (b *Backend) SetAllowKeyless(ctx context.Context, allow bool) error { - return b.db.TransactionContext(ctx, func(tx *db.Tx) error { - return b.store.SetAllowKeylessAccess(ctx, tx, allow) - }) -} - -// AnonAccess returns the level of anonymous access. -// -// It implements backend.Backend. -func (b *Backend) AnonAccess(ctx context.Context) access.AccessLevel { - var level access.AccessLevel - if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - level, err = b.store.GetAnonAccess(ctx, tx) - return err - }); err != nil { - return access.NoAccess - } - - return level -} - -// SetAnonAccess sets the level of anonymous access. -// -// It implements backend.Backend. -func (b *Backend) SetAnonAccess(ctx context.Context, level access.AccessLevel) error { - return b.db.TransactionContext(ctx, func(tx *db.Tx) error { - return b.store.SetAnonAccess(ctx, tx, level) - }) -} diff --git a/server/backend/user.go b/server/backend/user.go deleted file mode 100644 index d5cf38ca3..000000000 --- a/server/backend/user.go +++ /dev/null @@ -1,425 +0,0 @@ -package backend - -import ( - "context" - "errors" - "strings" - "time" - - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/sshutils" - "github.com/charmbracelet/soft-serve/server/utils" - "golang.org/x/crypto/ssh" -) - -// AccessLevel returns the access level of a user for a repository. -// -// It implements backend.Backend. -func (d *Backend) AccessLevel(ctx context.Context, repo string, username string) access.AccessLevel { - user, _ := d.User(ctx, username) - return d.AccessLevelForUser(ctx, repo, user) -} - -// AccessLevelByPublicKey returns the access level of a user's public key for a repository. -// -// It implements backend.Backend. -func (d *Backend) AccessLevelByPublicKey(ctx context.Context, repo string, pk ssh.PublicKey) access.AccessLevel { - for _, k := range d.cfg.AdminKeys() { - if sshutils.KeysEqual(pk, k) { - return access.AdminAccess - } - } - - user, _ := d.UserByPublicKey(ctx, pk) - if user != nil { - return d.AccessLevel(ctx, repo, user.Username()) - } - - return d.AccessLevel(ctx, repo, "") -} - -// AccessLevelForUser returns the access level of a user for a repository. -// TODO: user repository ownership -func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user proto.User) access.AccessLevel { - var username string - anon := d.AnonAccess(ctx) - if user != nil { - username = user.Username() - } - - // If the user is an admin, they have admin access. - if user != nil && user.IsAdmin() { - return access.AdminAccess - } - - // If the repository exists, check if the user is a collaborator. - r := proto.RepositoryFromContext(ctx) - if r == nil { - r, _ = d.Repository(ctx, repo) - } - - if r != nil { - if user != nil { - // If the user is the owner, they have admin access. - if r.UserID() == user.ID() { - return access.AdminAccess - } - } - - // If the user is a collaborator, they have return their access level. - collabAccess, isCollab, _ := d.IsCollaborator(ctx, repo, username) - if isCollab { - if anon > collabAccess { - return anon - } - return collabAccess - } - - // If the repository is private, the user has no access. - if r.IsPrivate() { - return access.NoAccess - } - - // Otherwise, the user has read-only access. - return access.ReadOnlyAccess - } - - if user != nil { - // If the repository doesn't exist, the user has read/write access. - if anon > access.ReadWriteAccess { - return anon - } - - return access.ReadWriteAccess - } - - // If the user doesn't exist, give them the anonymous access level. - return anon -} - -// User finds a user by username. -// -// It implements backend.Backend. -func (d *Backend) User(ctx context.Context, username string) (proto.User, error) { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return nil, err - } - - var m models.User - var pks []ssh.PublicKey - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - m, err = d.store.FindUserByUsername(ctx, tx, username) - if err != nil { - return err - } - - pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID) - return err - }); err != nil { - err = db.WrapError(err) - if errors.Is(err, db.ErrRecordNotFound) { - return nil, proto.ErrUserNotFound - } - d.logger.Error("error finding user", "username", username, "error", err) - return nil, err - } - - return &user{ - user: m, - publicKeys: pks, - }, nil -} - -// UserByID finds a user by ID. -func (d *Backend) UserByID(ctx context.Context, id int64) (proto.User, error) { - var m models.User - var pks []ssh.PublicKey - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - m, err = d.store.GetUserByID(ctx, tx, id) - if err != nil { - return err - } - - pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID) - return err - }); err != nil { - err = db.WrapError(err) - if errors.Is(err, db.ErrRecordNotFound) { - return nil, proto.ErrUserNotFound - } - d.logger.Error("error finding user", "id", id, "error", err) - return nil, err - } - - return &user{ - user: m, - publicKeys: pks, - }, nil -} - -// UserByPublicKey finds a user by public key. -// -// It implements backend.Backend. -func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.User, error) { - var m models.User - var pks []ssh.PublicKey - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - m, err = d.store.FindUserByPublicKey(ctx, tx, pk) - if err != nil { - return db.WrapError(err) - } - - pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID) - return err - }); err != nil { - err = db.WrapError(err) - if errors.Is(err, db.ErrRecordNotFound) { - return nil, proto.ErrUserNotFound - } - d.logger.Error("error finding user", "pk", sshutils.MarshalAuthorizedKey(pk), "error", err) - return nil, err - } - - return &user{ - user: m, - publicKeys: pks, - }, nil -} - -// UserByAccessToken finds a user by access token. -// This also validates the token for expiration and returns proto.ErrTokenExpired. -func (d *Backend) UserByAccessToken(ctx context.Context, token string) (proto.User, error) { - var m models.User - var pks []ssh.PublicKey - token = HashToken(token) - - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - t, err := d.store.GetAccessTokenByToken(ctx, tx, token) - if err != nil { - return db.WrapError(err) - } - - if t.ExpiresAt.Valid && t.ExpiresAt.Time.Before(time.Now()) { - return proto.ErrTokenExpired - } - - m, err = d.store.FindUserByAccessToken(ctx, tx, token) - if err != nil { - return db.WrapError(err) - } - - pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID) - return err - }); err != nil { - err = db.WrapError(err) - if errors.Is(err, db.ErrRecordNotFound) { - return nil, proto.ErrUserNotFound - } - d.logger.Error("failed to find user by access token", "err", err, "token", token) - return nil, err - } - - return &user{ - user: m, - publicKeys: pks, - }, nil -} - -// Users returns all users. -// -// It implements backend.Backend. -func (d *Backend) Users(ctx context.Context) ([]string, error) { - var users []string - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - ms, err := d.store.GetAllUsers(ctx, tx) - if err != nil { - return err - } - - for _, m := range ms { - users = append(users, m.Username) - } - - return nil - }); err != nil { - return nil, db.WrapError(err) - } - - return users, nil -} - -// AddPublicKey adds a public key to a user. -// -// It implements backend.Backend. -func (d *Backend) AddPublicKey(ctx context.Context, username string, pk ssh.PublicKey) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - return db.WrapError( - d.db.TransactionContext(ctx, func(tx *db.Tx) error { - return d.store.AddPublicKeyByUsername(ctx, tx, username, pk) - }), - ) -} - -// CreateUser creates a new user. -// -// It implements backend.Backend. -func (d *Backend) CreateUser(ctx context.Context, username string, opts proto.UserOptions) (proto.User, error) { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return nil, err - } - - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - return d.store.CreateUser(ctx, tx, username, opts.Admin, opts.PublicKeys) - }); err != nil { - return nil, db.WrapError(err) - } - - return d.User(ctx, username) -} - -// DeleteUser deletes a user. -// -// It implements backend.Backend. -func (d *Backend) DeleteUser(ctx context.Context, username string) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - return d.db.TransactionContext(ctx, func(tx *db.Tx) error { - if err := d.store.DeleteUserByUsername(ctx, tx, username); err != nil { - return db.WrapError(err) - } - - return d.DeleteUserRepositories(ctx, username) - }) -} - -// RemovePublicKey removes a public key from a user. -// -// It implements backend.Backend. -func (d *Backend) RemovePublicKey(ctx context.Context, username string, pk ssh.PublicKey) error { - return db.WrapError( - d.db.TransactionContext(ctx, func(tx *db.Tx) error { - return d.store.RemovePublicKeyByUsername(ctx, tx, username, pk) - }), - ) -} - -// ListPublicKeys lists the public keys of a user. -func (d *Backend) ListPublicKeys(ctx context.Context, username string) ([]ssh.PublicKey, error) { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return nil, err - } - - var keys []ssh.PublicKey - if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - keys, err = d.store.ListPublicKeysByUsername(ctx, tx, username) - return err - }); err != nil { - return nil, db.WrapError(err) - } - - return keys, nil -} - -// SetUsername sets the username of a user. -// -// It implements backend.Backend. -func (d *Backend) SetUsername(ctx context.Context, username string, newUsername string) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - return db.WrapError( - d.db.TransactionContext(ctx, func(tx *db.Tx) error { - return d.store.SetUsernameByUsername(ctx, tx, username, newUsername) - }), - ) -} - -// SetAdmin sets the admin flag of a user. -// -// It implements backend.Backend. -func (d *Backend) SetAdmin(ctx context.Context, username string, admin bool) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - return db.WrapError( - d.db.TransactionContext(ctx, func(tx *db.Tx) error { - return d.store.SetAdminByUsername(ctx, tx, username, admin) - }), - ) -} - -// SetPassword sets the password of a user. -func (d *Backend) SetPassword(ctx context.Context, username string, rawPassword string) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - password, err := HashPassword(rawPassword) - if err != nil { - return err - } - - return db.WrapError( - d.db.TransactionContext(ctx, func(tx *db.Tx) error { - return d.store.SetUserPasswordByUsername(ctx, tx, username, password) - }), - ) -} - -type user struct { - user models.User - publicKeys []ssh.PublicKey -} - -var _ proto.User = (*user)(nil) - -// IsAdmin implements proto.User -func (u *user) IsAdmin() bool { - return u.user.Admin -} - -// PublicKeys implements proto.User -func (u *user) PublicKeys() []ssh.PublicKey { - return u.publicKeys -} - -// Username implements proto.User -func (u *user) Username() string { - return u.user.Username -} - -// ID implements proto.User. -func (u *user) ID() int64 { - return u.user.ID -} - -// Password implements proto.User. -func (u *user) Password() string { - if u.user.Password.Valid { - return u.user.Password.String - } - - return "" -} diff --git a/server/backend/utils.go b/server/backend/utils.go deleted file mode 100644 index 5e90873e5..000000000 --- a/server/backend/utils.go +++ /dev/null @@ -1,23 +0,0 @@ -package backend - -import ( - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/proto" -) - -// LatestFile returns the contents of the latest file at the specified path in -// the repository and its file path. -func LatestFile(r proto.Repository, ref *git.Reference, pattern string) (string, string, error) { - repo, err := r.Open() - if err != nil { - return "", "", err - } - return git.LatestFile(repo, ref, pattern) -} - -// Readme returns the repository's README. -func Readme(r proto.Repository, ref *git.Reference) (readme string, path string, err error) { - pattern := "[rR][eE][aA][dD][mM][eE]*" - readme, path, err = LatestFile(r, ref, pattern) - return -} diff --git a/server/backend/webhooks.go b/server/backend/webhooks.go deleted file mode 100644 index 6d676d356..000000000 --- a/server/backend/webhooks.go +++ /dev/null @@ -1,279 +0,0 @@ -package backend - -import ( - "context" - "encoding/json" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/charmbracelet/soft-serve/server/webhook" - "github.com/google/uuid" -) - -// CreateWebhook creates a webhook for a repository. -func (b *Backend) CreateWebhook(ctx context.Context, repo proto.Repository, url string, contentType webhook.ContentType, secret string, events []webhook.Event, active bool) error { - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - - return dbx.TransactionContext(ctx, func(tx *db.Tx) error { - lastID, err := datastore.CreateWebhook(ctx, tx, repo.ID(), url, secret, int(contentType), active) - if err != nil { - return db.WrapError(err) - } - - evs := make([]int, len(events)) - for i, e := range events { - evs[i] = int(e) - } - if err := datastore.CreateWebhookEvents(ctx, tx, lastID, evs); err != nil { - return db.WrapError(err) - } - - return nil - }) -} - -// Webhook returns a webhook for a repository. -func (b *Backend) Webhook(ctx context.Context, repo proto.Repository, id int64) (webhook.Hook, error) { - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - - var wh webhook.Hook - if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error { - h, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id) - if err != nil { - return db.WrapError(err) - } - events, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, id) - if err != nil { - return db.WrapError(err) - } - - wh = webhook.Hook{ - Webhook: h, - ContentType: webhook.ContentType(h.ContentType), - Events: make([]webhook.Event, len(events)), - } - for i, e := range events { - wh.Events[i] = webhook.Event(e.Event) - } - - return nil - }); err != nil { - return webhook.Hook{}, db.WrapError(err) - } - - return wh, nil -} - -// ListWebhooks lists webhooks for a repository. -func (b *Backend) ListWebhooks(ctx context.Context, repo proto.Repository) ([]webhook.Hook, error) { - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - - var webhooks []models.Webhook - webhookEvents := map[int64][]models.WebhookEvent{} - if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - webhooks, err = datastore.GetWebhooksByRepoID(ctx, tx, repo.ID()) - if err != nil { - return err - } - - for _, h := range webhooks { - events, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, h.ID) - if err != nil { - return err - } - webhookEvents[h.ID] = events - } - - return nil - }); err != nil { - return nil, db.WrapError(err) - } - - hooks := make([]webhook.Hook, len(webhooks)) - for i, h := range webhooks { - events := make([]webhook.Event, len(webhookEvents[h.ID])) - for i, e := range webhookEvents[h.ID] { - events[i] = webhook.Event(e.Event) - } - - hooks[i] = webhook.Hook{ - Webhook: h, - ContentType: webhook.ContentType(h.ContentType), - Events: events, - } - } - - return hooks, nil -} - -// UpdateWebhook updates a webhook. -func (b *Backend) UpdateWebhook(ctx context.Context, repo proto.Repository, id int64, url string, contentType webhook.ContentType, secret string, updatedEvents []webhook.Event, active bool) error { - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - - return dbx.TransactionContext(ctx, func(tx *db.Tx) error { - if err := datastore.UpdateWebhookByID(ctx, tx, repo.ID(), id, url, secret, int(contentType), active); err != nil { - return db.WrapError(err) - } - - currentEvents, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, id) - if err != nil { - return db.WrapError(err) - } - - // Delete events that are no longer in the list. - toBeDeleted := make([]int64, 0) - for _, e := range currentEvents { - found := false - for _, ne := range updatedEvents { - if int(ne) == e.Event { - found = true - break - } - } - if !found { - toBeDeleted = append(toBeDeleted, e.ID) - } - } - - if err := datastore.DeleteWebhookEventsByID(ctx, tx, toBeDeleted); err != nil { - return db.WrapError(err) - } - - // Prune events that are already in the list. - newEvents := make([]int, 0) - for _, e := range updatedEvents { - found := false - for _, ne := range currentEvents { - if int(e) == ne.Event { - found = true - break - } - } - if !found { - newEvents = append(newEvents, int(e)) - } - } - - if err := datastore.CreateWebhookEvents(ctx, tx, id, newEvents); err != nil { - return db.WrapError(err) - } - - return nil - }) -} - -// DeleteWebhook deletes a webhook for a repository. -func (b *Backend) DeleteWebhook(ctx context.Context, repo proto.Repository, id int64) error { - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - - return dbx.TransactionContext(ctx, func(tx *db.Tx) error { - _, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id) - if err != nil { - return db.WrapError(err) - } - if err := datastore.DeleteWebhookForRepoByID(ctx, tx, repo.ID(), id); err != nil { - return db.WrapError(err) - } - - return nil - }) -} - -// ListWebhookDeliveries lists webhook deliveries for a webhook. -func (b *Backend) ListWebhookDeliveries(ctx context.Context, id int64) ([]webhook.Delivery, error) { - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - - var deliveries []models.WebhookDelivery - if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - deliveries, err = datastore.ListWebhookDeliveriesByWebhookID(ctx, tx, id) - if err != nil { - return db.WrapError(err) - } - - return nil - }); err != nil { - return nil, db.WrapError(err) - } - - ds := make([]webhook.Delivery, len(deliveries)) - for i, d := range deliveries { - ds[i] = webhook.Delivery{ - WebhookDelivery: d, - Event: webhook.Event(d.Event), - } - } - - return ds, nil -} - -// RedeliverWebhookDelivery redelivers a webhook delivery. -func (b *Backend) RedeliverWebhookDelivery(ctx context.Context, repo proto.Repository, id int64, delID uuid.UUID) error { - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - - var delivery models.WebhookDelivery - var wh models.Webhook - if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error { - var err error - wh, err = datastore.GetWebhookByID(ctx, tx, repo.ID(), id) - if err != nil { - log.Errorf("error getting webhook: %v", err) - return db.WrapError(err) - } - - delivery, err = datastore.GetWebhookDeliveryByID(ctx, tx, id, delID) - if err != nil { - return db.WrapError(err) - } - - return nil - }); err != nil { - return db.WrapError(err) - } - - log.Infof("redelivering webhook delivery %s for webhook %d\n\n%s\n\n", delID, id, delivery.RequestBody) - - var payload json.RawMessage - if err := json.Unmarshal([]byte(delivery.RequestBody), &payload); err != nil { - log.Errorf("error unmarshaling webhook payload: %v", err) - return err - } - - return webhook.SendWebhook(ctx, wh, webhook.Event(delivery.Event), payload) -} - -// WebhookDelivery returns a webhook delivery. -func (b *Backend) WebhookDelivery(ctx context.Context, webhookID int64, id uuid.UUID) (webhook.Delivery, error) { - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - - var delivery webhook.Delivery - if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error { - d, err := datastore.GetWebhookDeliveryByID(ctx, tx, webhookID, id) - if err != nil { - return db.WrapError(err) - } - - delivery = webhook.Delivery{ - WebhookDelivery: d, - Event: webhook.Event(d.Event), - } - - return nil - }); err != nil { - return webhook.Delivery{}, db.WrapError(err) - } - - return delivery, nil -} diff --git a/server/config/config.go b/server/config/config.go deleted file mode 100644 index 4707cc452..000000000 --- a/server/config/config.go +++ /dev/null @@ -1,417 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/caarlos0/env/v8" - "github.com/charmbracelet/soft-serve/server/sshutils" - "golang.org/x/crypto/ssh" - "gopkg.in/yaml.v3" -) - -// SSHConfig is the configuration for the SSH server. -type SSHConfig struct { - // ListenAddr is the address on which the SSH server will listen. - ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"` - - // PublicURL is the public URL of the SSH server. - PublicURL string `env:"PUBLIC_URL" yaml:"public_url"` - - // KeyPath is the path to the SSH server's private key. - KeyPath string `env:"KEY_PATH" yaml:"key_path"` - - // ClientKeyPath is the path to the server's client private key. - ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"` - - // MaxTimeout is the maximum number of seconds a connection can take. - MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"` - - // IdleTimeout is the number of seconds a connection can be idle before it is closed. - IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"` -} - -// GitConfig is the Git daemon configuration for the server. -type GitConfig struct { - // ListenAddr is the address on which the Git daemon will listen. - ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"` - - // PublicURL is the public URL of the Git daemon server. - PublicURL string `env:"PUBLIC_URL" yaml:"public_url"` - - // MaxTimeout is the maximum number of seconds a connection can take. - MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"` - - // IdleTimeout is the number of seconds a connection can be idle before it is closed. - IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"` - - // MaxConnections is the maximum number of concurrent connections. - MaxConnections int `env:"MAX_CONNECTIONS" yaml:"max_connections"` -} - -// HTTPConfig is the HTTP configuration for the server. -type HTTPConfig struct { - // ListenAddr is the address on which the HTTP server will listen. - ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"` - - // TLSKeyPath is the path to the TLS private key. - TLSKeyPath string `env:"TLS_KEY_PATH" yaml:"tls_key_path"` - - // TLSCertPath is the path to the TLS certificate. - TLSCertPath string `env:"TLS_CERT_PATH" yaml:"tls_cert_path"` - - // PublicURL is the public URL of the HTTP server. - PublicURL string `env:"PUBLIC_URL" yaml:"public_url"` -} - -// StatsConfig is the configuration for the stats server. -type StatsConfig struct { - // ListenAddr is the address on which the stats server will listen. - ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"` -} - -// LogConfig is the logger configuration. -type LogConfig struct { - // Format is the format of the logs. - // Valid values are "json", "logfmt", and "text". - Format string `env:"FORMAT" yaml:"format"` - - // Time format for the log `ts` field. - // Format must be described in Golang's time format. - TimeFormat string `env:"TIME_FORMAT" yaml:"time_format"` - - // Path to a file to write logs to. - // If not set, logs will be written to stderr. - Path string `env:"PATH" yaml:"path"` -} - -// DBConfig is the database connection configuration. -type DBConfig struct { - // Driver is the driver for the database. - Driver string `env:"DRIVER" yaml:"driver"` - - // DataSource is the database data source name. - DataSource string `env:"DATA_SOURCE" yaml:"data_source"` -} - -// LFSConfig is the configuration for Git LFS. -type LFSConfig struct { - // Enabled is whether or not Git LFS is enabled. - Enabled bool `env:"ENABLED" yaml:"enabled"` - - // SSHEnabled is whether or not Git LFS over SSH is enabled. - // This is only used if LFS is enabled. - SSHEnabled bool `env:"SSH_ENABLED" yaml:"ssh_enabled"` -} - -// JobsConfig is the configuration for cron jobs. -type JobsConfig struct { - MirrorPull string `env:"MIRROR_PULL" yaml:"mirror_pull"` -} - -// Config is the configuration for Soft Serve. -type Config struct { - // Name is the name of the server. - Name string `env:"NAME" yaml:"name"` - - // SSH is the configuration for the SSH server. - SSH SSHConfig `envPrefix:"SSH_" yaml:"ssh"` - - // Git is the configuration for the Git daemon. - Git GitConfig `envPrefix:"GIT_" yaml:"git"` - - // HTTP is the configuration for the HTTP server. - HTTP HTTPConfig `envPrefix:"HTTP_" yaml:"http"` - - // Stats is the configuration for the stats server. - Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"` - - // Log is the logger configuration. - Log LogConfig `envPrefix:"LOG_" yaml:"log"` - - // DB is the database configuration. - DB DBConfig `envPrefix:"DB_" yaml:"db"` - - // LFS is the configuration for Git LFS. - LFS LFSConfig `envPrefix:"LFS_" yaml:"lfs"` - - // Jobs is the configuration for cron jobs - Jobs JobsConfig `envPrefix:"JOBS_" yaml:"jobs"` - - // InitialAdminKeys is a list of public keys that will be added to the list of admins. - InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"` - - // DataPath is the path to the directory where Soft Serve will store its data. - DataPath string `env:"DATA_PATH" yaml:"-"` -} - -// Environ returns the config as a list of environment variables. -func (c *Config) Environ() []string { - envs := []string{} - if c == nil { - return envs - } - - // TODO: do this dynamically - envs = append(envs, []string{ - fmt.Sprintf("SOFT_SERVE_DATA_PATH=%s", c.DataPath), - fmt.Sprintf("SOFT_SERVE_NAME=%s", c.Name), - fmt.Sprintf("SOFT_SERVE_INITIAL_ADMIN_KEYS=%s", strings.Join(c.InitialAdminKeys, "\n")), - fmt.Sprintf("SOFT_SERVE_SSH_LISTEN_ADDR=%s", c.SSH.ListenAddr), - fmt.Sprintf("SOFT_SERVE_SSH_PUBLIC_URL=%s", c.SSH.PublicURL), - fmt.Sprintf("SOFT_SERVE_SSH_KEY_PATH=%s", c.SSH.KeyPath), - fmt.Sprintf("SOFT_SERVE_SSH_CLIENT_KEY_PATH=%s", c.SSH.ClientKeyPath), - fmt.Sprintf("SOFT_SERVE_SSH_MAX_TIMEOUT=%d", c.SSH.MaxTimeout), - fmt.Sprintf("SOFT_SERVE_SSH_IDLE_TIMEOUT=%d", c.SSH.IdleTimeout), - fmt.Sprintf("SOFT_SERVE_GIT_LISTEN_ADDR=%s", c.Git.ListenAddr), - fmt.Sprintf("SOFT_SERVE_GIT_PUBLIC_URL=%s", c.Git.PublicURL), - fmt.Sprintf("SOFT_SERVE_GIT_MAX_TIMEOUT=%d", c.Git.MaxTimeout), - fmt.Sprintf("SOFT_SERVE_GIT_IDLE_TIMEOUT=%d", c.Git.IdleTimeout), - fmt.Sprintf("SOFT_SERVE_GIT_MAX_CONNECTIONS=%d", c.Git.MaxConnections), - fmt.Sprintf("SOFT_SERVE_HTTP_LISTEN_ADDR=%s", c.HTTP.ListenAddr), - fmt.Sprintf("SOFT_SERVE_HTTP_TLS_KEY_PATH=%s", c.HTTP.TLSKeyPath), - fmt.Sprintf("SOFT_SERVE_HTTP_TLS_CERT_PATH=%s", c.HTTP.TLSCertPath), - fmt.Sprintf("SOFT_SERVE_HTTP_PUBLIC_URL=%s", c.HTTP.PublicURL), - fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr), - fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format), - fmt.Sprintf("SOFT_SERVE_LOG_TIME_FORMAT=%s", c.Log.TimeFormat), - fmt.Sprintf("SOFT_SERVE_DB_DRIVER=%s", c.DB.Driver), - fmt.Sprintf("SOFT_SERVE_DB_DATA_SOURCE=%s", c.DB.DataSource), - fmt.Sprintf("SOFT_SERVE_LFS_ENABLED=%t", c.LFS.Enabled), - fmt.Sprintf("SOFT_SERVE_LFS_SSH_ENABLED=%t", c.LFS.SSHEnabled), - }...) - - return envs -} - -// IsDebug returns true if the server is running in debug mode. -func IsDebug() bool { - debug, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_DEBUG")) - return debug -} - -// IsVerbose returns true if the server is running in verbose mode. -// Verbose mode is only enabled if debug mode is enabled. -func IsVerbose() bool { - verbose, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_VERBOSE")) - return IsDebug() && verbose -} - -// parseFile parses the given file as a configuration file. -// The file must be in YAML format. -func parseFile(cfg *Config, path string) error { - f, err := os.Open(path) - if err != nil { - return err - } - - defer f.Close() // nolint: errcheck - if err := yaml.NewDecoder(f).Decode(cfg); err != nil { - return fmt.Errorf("decode config: %w", err) - } - - return cfg.Validate() -} - -// ParseFile parses the config from the default file path. -// This also calls Validate() on the config. -func (c *Config) ParseFile() error { - return parseFile(c, c.ConfigPath()) -} - -// parseEnv parses the environment variables as a configuration file. -func parseEnv(cfg *Config) error { - // Merge initial admin keys from both config file and environment variables. - initialAdminKeys := append([]string{}, cfg.InitialAdminKeys...) - - // Override with environment variables - if err := env.ParseWithOptions(cfg, env.Options{ - Prefix: "SOFT_SERVE_", - }); err != nil { - return fmt.Errorf("parse environment variables: %w", err) - } - - // Merge initial admin keys from environment variables. - if initialAdminKeysEnv := os.Getenv("SOFT_SERVE_INITIAL_ADMIN_KEYS"); initialAdminKeysEnv != "" { - cfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...) - } - - return cfg.Validate() -} - -// ParseEnv parses the config from the environment variables. -// This also calls Validate() on the config. -func (c *Config) ParseEnv() error { - return parseEnv(c) -} - -// Parse parses the config from the default file path and environment variables. -// This also calls Validate() on the config. -func (c *Config) Parse() error { - if err := c.ParseFile(); err != nil { - return err - } - - return c.ParseEnv() -} - -// writeConfig writes the configuration to the given file. -func writeConfig(cfg *Config, path string) error { - if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { - return err - } - return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o644) // nolint: errcheck, gosec -} - -// WriteConfig writes the configuration to the default file. -func (c *Config) WriteConfig() error { - return writeConfig(c, c.ConfigPath()) -} - -// DefaultDataPath returns the path to the data directory. -// It uses the SOFT_SERVE_DATA_PATH environment variable if set, otherwise it -// uses "data". -func DefaultDataPath() string { - dp := os.Getenv("SOFT_SERVE_DATA_PATH") - if dp == "" { - dp = "data" - } - - return dp -} - -// ConfigPath returns the path to the config file. -func (c *Config) ConfigPath() string { // nolint:revive - return filepath.Join(c.DataPath, "config.yaml") -} - -func exist(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// Exist returns true if the config file exists. -func (c *Config) Exist() bool { - return exist(filepath.Join(c.DataPath, "config.yaml")) -} - -// DefaultConfig returns the default Config. All the path values are relative -// to the data directory. -// Use Validate() to validate the config and ensure absolute paths. -func DefaultConfig() *Config { - return &Config{ - Name: "Soft Serve", - DataPath: DefaultDataPath(), - SSH: SSHConfig{ - ListenAddr: ":23231", - PublicURL: "ssh://localhost:23231", - KeyPath: filepath.Join("ssh", "soft_serve_host_ed25519"), - ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"), - MaxTimeout: 0, - IdleTimeout: 10 * 60, // 10 minutes - }, - Git: GitConfig{ - ListenAddr: ":9418", - PublicURL: "git://localhost", - MaxTimeout: 0, - IdleTimeout: 3, - MaxConnections: 32, - }, - HTTP: HTTPConfig{ - ListenAddr: ":23232", - PublicURL: "http://localhost:23232", - }, - Stats: StatsConfig{ - ListenAddr: "localhost:23233", - }, - Log: LogConfig{ - Format: "text", - TimeFormat: time.DateTime, - }, - DB: DBConfig{ - Driver: "sqlite", - DataSource: "soft-serve.db" + - "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", - }, - LFS: LFSConfig{ - Enabled: true, - SSHEnabled: true, - }, - } -} - -// Validate validates the configuration. -// It updates the configuration with absolute paths. -func (c *Config) Validate() error { - // Use absolute paths - if !filepath.IsAbs(c.DataPath) { - dp, err := filepath.Abs(c.DataPath) - if err != nil { - return err - } - c.DataPath = dp - } - - c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/") - c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/") - - if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) { - c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath) - } - - if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) { - c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath) - } - - if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) { - c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath) - } - - if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) { - c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath) - } - - if strings.HasPrefix(c.DB.Driver, "sqlite") && !filepath.IsAbs(c.DB.DataSource) { - c.DB.DataSource = filepath.Join(c.DataPath, c.DB.DataSource) - } - - // Validate keys - pks := make([]string, 0) - for _, key := range parseAuthKeys(c.InitialAdminKeys) { - ak := sshutils.MarshalAuthorizedKey(key) - pks = append(pks, ak) - } - - c.InitialAdminKeys = pks - - return nil -} - -// parseAuthKeys parses authorized keys from either file paths or string authorized_keys. -func parseAuthKeys(aks []string) []ssh.PublicKey { - exist := make(map[string]struct{}, 0) - pks := make([]ssh.PublicKey, 0) - for _, key := range aks { - if bts, err := os.ReadFile(key); err == nil { - // key is a file - key = strings.TrimSpace(string(bts)) - } - - if pk, _, err := sshutils.ParseAuthorizedKey(key); err == nil { - if _, ok := exist[key]; !ok { - pks = append(pks, pk) - exist[key] = struct{}{} - } - } - } - return pks -} - -// AdminKeys returns the server admin keys. -func (c *Config) AdminKeys() []ssh.PublicKey { - return parseAuthKeys(c.InitialAdminKeys) -} diff --git a/server/config/config_test.go b/server/config/config_test.go deleted file mode 100644 index 503e4de49..000000000 --- a/server/config/config_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package config - -import ( - "os" - "testing" - - "github.com/matryer/is" -) - -func TestParseMultipleKeys(t *testing.T) { - is := is.New(t) - td := t.TempDir() - is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEYS", "testdata/k1.pub\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b")) - is.NoErr(os.Setenv("SOFT_SERVE_DATA_PATH", td)) - t.Cleanup(func() { - is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEYS")) - is.NoErr(os.Unsetenv("SOFT_SERVE_DATA_PATH")) - }) - cfg := DefaultConfig() - is.NoErr(cfg.ParseEnv()) - is.Equal(cfg.InitialAdminKeys, []string{ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH", - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8", - }) -} - -func TestMergeInitAdminKeys(t *testing.T) { - is := is.New(t) - is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEYS", "testdata/k1.pub")) - t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEYS")) }) - cfg := &Config{ - DataPath: t.TempDir(), - InitialAdminKeys: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b"}, - } - is.NoErr(cfg.WriteConfig()) - is.NoErr(cfg.Parse()) - is.Equal(cfg.InitialAdminKeys, []string{ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH", - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8", - }) -} - -func TestValidateInitAdminKeys(t *testing.T) { - is := is.New(t) - cfg := &Config{ - DataPath: t.TempDir(), - InitialAdminKeys: []string{ - "testdata/k1.pub", - "abc", - "", - }, - } - is.NoErr(cfg.WriteConfig()) - is.NoErr(cfg.Parse()) - is.Equal(cfg.InitialAdminKeys, []string{ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH", - }) -} diff --git a/server/config/context.go b/server/config/context.go deleted file mode 100644 index e364fefba..000000000 --- a/server/config/context.go +++ /dev/null @@ -1,20 +0,0 @@ -package config - -import "context" - -// ContextKey is the context key for the config. -var ContextKey = struct{ string }{"config"} - -// WithContext returns a new context with the configuration attached. -func WithContext(ctx context.Context, cfg *Config) context.Context { - return context.WithValue(ctx, ContextKey, cfg) -} - -// FromContext returns the configuration from the context. -func FromContext(ctx context.Context) *Config { - if c, ok := ctx.Value(ContextKey).(*Config); ok { - return c - } - - return nil -} diff --git a/server/config/file.go b/server/config/file.go deleted file mode 100644 index 4bb0302db..000000000 --- a/server/config/file.go +++ /dev/null @@ -1,118 +0,0 @@ -package config - -import ( - "bytes" - "text/template" -) - -var configFileTmpl = template.Must(template.New("config").Parse(`# Soft Serve Server configurations - -# The name of the server. -# This is the name that will be displayed in the UI. -name: "{{ .Name }}" - -# Logging configuration. -log: - # Log format to use. Valid values are "json", "logfmt", and "text". - format: "{{ .Log.Format }}" - # Time format for the log "timestamp" field. - # Should be described in Golang's time format. - time_format: "{{ .Log.TimeFormat }}" - # Path to the log file. Leave empty to write to stderr. - #path: "{{ .Log.Path }}" - -# The SSH server configuration. -ssh: - # The address on which the SSH server will listen. - listen_addr: "{{ .SSH.ListenAddr }}" - - # The public URL of the SSH server. - # This is the address that will be used to clone repositories. - public_url: "{{ .SSH.PublicURL }}" - - # The path to the SSH server's private key. - key_path: {{ .SSH.KeyPath }} - - # The path to the server's client private key. This key will be used to - # authenticate the server to make git requests to ssh remotes. - client_key_path: {{ .SSH.ClientKeyPath }} - - # The maximum number of seconds a connection can take. - # A value of 0 means no timeout. - max_timeout: {{ .SSH.MaxTimeout }} - - # The number of seconds a connection can be idle before it is closed. - # A value of 0 means no timeout. - idle_timeout: {{ .SSH.IdleTimeout }} - -# The Git daemon configuration. -git: - # The address on which the Git daemon will listen. - listen_addr: "{{ .Git.ListenAddr }}" - - # The public URL of the Git daemon server. - # This is the address that will be used to clone repositories. - public_url: "{{ .Git.PublicURL }}" - - # The maximum number of seconds a connection can take. - # A value of 0 means no timeout. - max_timeout: {{ .Git.MaxTimeout }} - - # The number of seconds a connection can be idle before it is closed. - idle_timeout: {{ .Git.IdleTimeout }} - - # The maximum number of concurrent connections. - max_connections: {{ .Git.MaxConnections }} - -# The HTTP server configuration. -http: - # The address on which the HTTP server will listen. - listen_addr: "{{ .HTTP.ListenAddr }}" - - # The path to the TLS private key. - tls_key_path: {{ .HTTP.TLSKeyPath }} - - # The path to the TLS certificate. - tls_cert_path: {{ .HTTP.TLSCertPath }} - - # The public URL of the HTTP server. - # This is the address that will be used to clone repositories. - # Make sure to use https:// if you are using TLS. - public_url: "{{ .HTTP.PublicURL }}" - -# The stats server configuration. -stats: - # The address on which the stats server will listen. - listen_addr: "{{ .Stats.ListenAddr }}" - -# The database configuration. -db: - # The database driver to use. - # Valid values are "sqlite" and "postgres". - driver: "{{ .DB.Driver }}" - # The database data source name. - # This is driver specific and can be a file path or connection string. - # Make sure foreign key support is enabled when using SQLite. - data_source: "{{ .DB.DataSource }}" - -# Git LFS configuration. -lfs: - # Enable Git LFS. - enabled: {{ .LFS.Enabled }} - # Enable Git SSH transfer. - ssh_enabled: {{ .LFS.SSHEnabled }} - -# Cron job configuration -jobs: - mirror_pull: "{{ .Jobs.MirrorPull }}" - -# Additional admin keys. -#initial_admin_keys: -# - "ssh-rsa AAAAB3NzaC1yc2..." -`)) - -func newConfigFile(cfg *Config) string { - var b bytes.Buffer - configFileTmpl.Execute(&b, cfg) // nolint: errcheck - return b.String() -} diff --git a/server/config/ssh.go b/server/config/ssh.go deleted file mode 100644 index 102b39141..000000000 --- a/server/config/ssh.go +++ /dev/null @@ -1,8 +0,0 @@ -package config - -import "github.com/charmbracelet/keygen" - -// KeyPair returns the server's SSH key pair. -func (c SSHConfig) KeyPair() (*keygen.SSHKeyPair, error) { - return keygen.New(c.KeyPath, keygen.WithKeyType(keygen.Ed25519)) -} diff --git a/server/config/testdata/k1.pub b/server/config/testdata/k1.pub deleted file mode 100644 index d82e29394..000000000 --- a/server/config/testdata/k1.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b diff --git a/server/cron/cron.go b/server/cron/cron.go deleted file mode 100644 index 6fed17b6a..000000000 --- a/server/cron/cron.go +++ /dev/null @@ -1,61 +0,0 @@ -package cron - -import ( - "context" - "time" - - "github.com/charmbracelet/log" - "github.com/robfig/cron/v3" -) - -// Scheduler is a cron-like job scheduler. -type Scheduler struct { - *cron.Cron -} - -// cronLogger is a wrapper around the logger to make it compatible with the -// cron logger. -type cronLogger struct { - logger *log.Logger -} - -// Info logs routine messages about cron's operation. -func (l cronLogger) Info(msg string, keysAndValues ...interface{}) { - l.logger.Debug(msg, keysAndValues...) -} - -// Error logs an error condition. -func (l cronLogger) Error(err error, msg string, keysAndValues ...interface{}) { - l.logger.Error(msg, append(keysAndValues, "err", err)...) -} - -// NewScheduler returns a new Cron. -func NewScheduler(ctx context.Context) *Scheduler { - logger := cronLogger{log.FromContext(ctx).WithPrefix("cron")} - return &Scheduler{ - Cron: cron.New(cron.WithLogger(logger)), - } -} - -// Shutdonw gracefully shuts down the Scheduler. -func (s *Scheduler) Shutdown() { - ctx, cancel := context.WithTimeout(s.Cron.Stop(), 30*time.Second) - defer func() { cancel() }() - <-ctx.Done() -} - -// Start starts the Scheduler. -func (s *Scheduler) Start() { - s.Cron.Start() -} - -// AddFunc adds a job to the Scheduler. -func (s *Scheduler) AddFunc(spec string, fn func()) (int, error) { - id, err := s.Cron.AddFunc(spec, fn) - return int(id), err -} - -// Remove removes a job from the Scheduler. -func (s *Scheduler) Remove(id int) { - s.Cron.Remove(cron.EntryID(id)) -} diff --git a/server/daemon/conn.go b/server/daemon/conn.go deleted file mode 100644 index b4a342309..000000000 --- a/server/daemon/conn.go +++ /dev/null @@ -1,105 +0,0 @@ -package daemon - -import ( - "context" - "errors" - "net" - "sync" - "time" -) - -// connections is a synchronizes access to to a net.Conn pool. -type connections struct { - m map[net.Conn]struct{} - mu sync.Mutex -} - -func (m *connections) Add(c net.Conn) { - m.mu.Lock() - defer m.mu.Unlock() - m.m[c] = struct{}{} -} - -func (m *connections) Close(c net.Conn) error { - m.mu.Lock() - defer m.mu.Unlock() - err := c.Close() - delete(m.m, c) - return err -} - -func (m *connections) Size() int { - m.mu.Lock() - defer m.mu.Unlock() - return len(m.m) -} - -func (m *connections) CloseAll() error { - m.mu.Lock() - defer m.mu.Unlock() - var err error - for c := range m.m { - err = errors.Join(err, c.Close()) - delete(m.m, c) - } - - return err -} - -// serverConn is a wrapper around a net.Conn that closes the connection when -// the one of the timeouts is reached. -type serverConn struct { - net.Conn - - initTimeout time.Duration - idleTimeout time.Duration - maxDeadline time.Time - closeCanceler context.CancelFunc -} - -var _ net.Conn = (*serverConn)(nil) - -func (c *serverConn) Write(p []byte) (n int, err error) { - c.updateDeadline() - n, err = c.Conn.Write(p) - if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil { - c.closeCanceler() - } - return -} - -func (c *serverConn) Read(b []byte) (n int, err error) { - c.updateDeadline() - n, err = c.Conn.Read(b) - if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil { - c.closeCanceler() - } - return -} - -func (c *serverConn) Close() (err error) { - err = c.Conn.Close() - if c.closeCanceler != nil { - c.closeCanceler() - } - return -} - -func (c *serverConn) updateDeadline() { - switch { - case c.initTimeout > 0: - initTimeout := time.Now().Add(c.initTimeout) - c.initTimeout = 0 - if initTimeout.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() { - c.Conn.SetDeadline(initTimeout) // nolint: errcheck - return - } - case c.idleTimeout > 0: - idleDeadline := time.Now().Add(c.idleTimeout) - if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() { - c.Conn.SetDeadline(idleDeadline) // nolint: errcheck - return - } - } - c.Conn.SetDeadline(c.maxDeadline) // nolint: errcheck -} diff --git a/server/daemon/daemon.go b/server/daemon/daemon.go deleted file mode 100644 index 1a6070abc..000000000 --- a/server/daemon/daemon.go +++ /dev/null @@ -1,322 +0,0 @@ -package daemon - -import ( - "bytes" - "context" - "fmt" - "net" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/git" - "github.com/charmbracelet/soft-serve/server/utils" - "github.com/go-git/go-git/v5/plumbing/format/pktline" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" -) - -var ( - uploadPackGitCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "git", - Name: "git_upload_pack_total", - Help: "The total number of git-upload-pack requests", - }, []string{"repo"}) - - uploadArchiveGitCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "git", - Name: "git_upload_archive_total", - Help: "The total number of git-upload-archive requests", - }, []string{"repo"}) -) - -var ( - - // ErrServerClosed indicates that the server has been closed. - ErrServerClosed = fmt.Errorf("git: %w", net.ErrClosed) -) - -// GitDaemon represents a Git daemon. -type GitDaemon struct { - ctx context.Context - listener net.Listener - addr string - finished chan struct{} - conns connections - cfg *config.Config - be *backend.Backend - wg sync.WaitGroup - once sync.Once - logger *log.Logger -} - -// NewDaemon returns a new Git daemon. -func NewGitDaemon(ctx context.Context) (*GitDaemon, error) { - cfg := config.FromContext(ctx) - addr := cfg.Git.ListenAddr - d := &GitDaemon{ - ctx: ctx, - addr: addr, - finished: make(chan struct{}, 1), - cfg: cfg, - be: backend.FromContext(ctx), - conns: connections{m: make(map[net.Conn]struct{})}, - logger: log.FromContext(ctx).WithPrefix("gitdaemon"), - } - listener, err := net.Listen("tcp", d.addr) - if err != nil { - return nil, err - } - d.listener = listener - return d, nil -} - -// Start starts the Git TCP daemon. -func (d *GitDaemon) Start() error { - defer d.listener.Close() // nolint: errcheck - - d.wg.Add(1) - defer d.wg.Done() - - var tempDelay time.Duration - for { - conn, err := d.listener.Accept() - if err != nil { - select { - case <-d.finished: - return ErrServerClosed - default: - d.logger.Debugf("git: error accepting connection: %v", err) - } - if ne, ok := err.(net.Error); ok && ne.Temporary() { // nolint: staticcheck - if tempDelay == 0 { - tempDelay = 5 * time.Millisecond - } else { - tempDelay *= 2 - } - if max := 1 * time.Second; tempDelay > max { - tempDelay = max - } - time.Sleep(tempDelay) - continue - } - return err - } - - // Close connection if there are too many open connections. - if d.conns.Size()+1 >= d.cfg.Git.MaxConnections { - d.logger.Debugf("git: max connections reached, closing %s", conn.RemoteAddr()) - d.fatal(conn, git.ErrMaxConnections) - continue - } - - d.wg.Add(1) - go func() { - d.handleClient(conn) - d.wg.Done() - }() - } -} - -func (d *GitDaemon) fatal(c net.Conn, err error) { - git.WritePktlineErr(c, err) // nolint: errcheck - if err := c.Close(); err != nil { - d.logger.Debugf("git: error closing connection: %v", err) - } -} - -// handleClient handles a git protocol client. -func (d *GitDaemon) handleClient(conn net.Conn) { - ctx, cancel := context.WithCancel(context.Background()) - idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second - c := &serverConn{ - Conn: conn, - idleTimeout: idleTimeout, - closeCanceler: cancel, - } - if d.cfg.Git.MaxTimeout > 0 { - dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second - c.maxDeadline = time.Now().Add(dur) - } - d.conns.Add(c) - defer func() { - d.conns.Close(c) // nolint: errcheck - }() - - readc := make(chan struct{}, 1) - s := pktline.NewScanner(c) - go func() { - if !s.Scan() { - if err := s.Err(); err != nil { - if nerr, ok := err.(net.Error); ok && nerr.Timeout() { - d.fatal(c, git.ErrTimeout) - } else { - d.logger.Debugf("git: error scanning pktline: %v", err) - d.fatal(c, git.ErrSystemMalfunction) - } - } - return - } - readc <- struct{}{} - }() - - select { - case <-ctx.Done(): - if err := ctx.Err(); err != nil { - d.logger.Debugf("git: connection context error: %v", err) - } - return - case <-readc: - line := s.Bytes() - split := bytes.SplitN(line, []byte{' '}, 2) - if len(split) != 2 { - d.fatal(c, git.ErrInvalidRequest) - return - } - - var counter *prometheus.CounterVec - service := git.Service(split[0]) - switch service { - case git.UploadPackService: - counter = uploadPackGitCounter - case git.UploadArchiveService: - counter = uploadArchiveGitCounter - default: - d.fatal(c, git.ErrInvalidRequest) - return - } - - opts := bytes.SplitN(split[1], []byte{0}, 3) - if len(opts) < 2 { - d.fatal(c, git.ErrInvalidRequest) // nolint: errcheck - return - } - - host := strings.TrimPrefix(string(opts[1]), "host=") - extraParams := map[string]string{} - - if len(opts) > 2 { - buf := bytes.TrimPrefix(opts[2], []byte{0}) - for _, o := range bytes.Split(buf, []byte{0}) { - opt := string(o) - if opt == "" { - continue - } - - kv := strings.SplitN(opt, "=", 2) - if len(kv) != 2 { - d.logger.Errorf("git: invalid option %q", opt) - continue - } - - extraParams[kv[0]] = kv[1] - } - - version := extraParams["version"] - if version != "" { - d.logger.Debugf("git: protocol version %s", version) - } - } - - be := d.be - if !be.AllowKeyless(ctx) { - d.fatal(c, git.ErrNotAuthed) - return - } - - name := utils.SanitizeRepo(string(opts[0])) - d.logger.Debugf("git: connect %s %s %s", c.RemoteAddr(), service, name) - defer d.logger.Debugf("git: disconnect %s %s %s", c.RemoteAddr(), service, name) - - // git bare repositories should end in ".git" - // https://git-scm.com/docs/gitrepository-layout - repo := name + ".git" - reposDir := filepath.Join(d.cfg.DataPath, "repos") - if err := git.EnsureWithin(reposDir, repo); err != nil { - d.logger.Debugf("git: error ensuring repo path: %v", err) - d.fatal(c, git.ErrInvalidRepo) - return - } - - if _, err := d.be.Repository(ctx, repo); err != nil { - d.fatal(c, git.ErrInvalidRepo) - return - } - - auth := be.AccessLevel(ctx, name, "") - if auth < access.ReadOnlyAccess { - d.fatal(c, git.ErrNotAuthed) - return - } - - // Environment variables to pass down to git hooks. - envs := []string{ - "SOFT_SERVE_REPO_NAME=" + name, - "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo), - "SOFT_SERVE_HOST=" + host, - "SOFT_SERVE_LOG_PATH=" + filepath.Join(d.cfg.DataPath, "log", "hooks.log"), - } - - // Add git protocol environment variable. - if len(extraParams) > 0 { - var gitProto string - for k, v := range extraParams { - if len(gitProto) > 0 { - gitProto += ":" - } - gitProto += k + "=" + v - } - envs = append(envs, "GIT_PROTOCOL="+gitProto) - } - - envs = append(envs, d.cfg.Environ()...) - - cmd := git.ServiceCommand{ - Stdin: c, - Stdout: c, - Stderr: c, - Env: envs, - Dir: filepath.Join(reposDir, repo), - } - - if err := service.Handler(ctx, cmd); err != nil { - d.logger.Debugf("git: error handling request: %v", err) - d.fatal(c, err) - return - } - - counter.WithLabelValues(name) - } -} - -// Close closes the underlying listener. -func (d *GitDaemon) Close() error { - d.once.Do(func() { close(d.finished) }) - err := d.listener.Close() - d.conns.CloseAll() // nolint: errcheck - return err -} - -// Shutdown gracefully shuts down the daemon. -func (d *GitDaemon) Shutdown(ctx context.Context) error { - d.once.Do(func() { close(d.finished) }) - err := d.listener.Close() - finished := make(chan struct{}, 1) - go func() { - d.wg.Wait() - finished <- struct{}{} - }() - select { - case <-ctx.Done(): - return ctx.Err() - case <-finished: - return err - } -} diff --git a/server/daemon/daemon_test.go b/server/daemon/daemon_test.go deleted file mode 100644 index 0592db2de..000000000 --- a/server/daemon/daemon_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package daemon - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "log" - "net" - "os" - "strings" - "testing" - - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/migrate" - "github.com/charmbracelet/soft-serve/server/git" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/charmbracelet/soft-serve/server/store/database" - "github.com/charmbracelet/soft-serve/server/test" - "github.com/go-git/go-git/v5/plumbing/format/pktline" - _ "modernc.org/sqlite" // sqlite driver -) - -var testDaemon *GitDaemon - -func TestMain(m *testing.M) { - tmp, err := os.MkdirTemp("", "soft-serve-test") - if err != nil { - log.Fatal(err) - } - defer os.RemoveAll(tmp) - ctx := context.TODO() - cfg := config.DefaultConfig() - cfg.DataPath = tmp - cfg.Git.MaxConnections = 3 - cfg.Git.MaxTimeout = 100 - cfg.Git.IdleTimeout = 1 - cfg.Git.ListenAddr = fmt.Sprintf(":%d", test.RandomPort()) - if err := cfg.Validate(); err != nil { - log.Fatal(err) - } - ctx = config.WithContext(ctx, cfg) - dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource) - if err != nil { - log.Fatal(err) - } - defer dbx.Close() // nolint: errcheck - if err := migrate.Migrate(ctx, dbx); err != nil { - log.Fatal(err) - } - datastore := database.New(ctx, dbx) - ctx = store.WithContext(ctx, datastore) - be := backend.New(ctx, cfg, dbx) - ctx = backend.WithContext(ctx, be) - d, err := NewGitDaemon(ctx) - if err != nil { - log.Fatal(err) - } - testDaemon = d - go func() { - if err := d.Start(); err != ErrServerClosed { - log.Fatal(err) - } - }() - code := m.Run() - os.Unsetenv("SOFT_SERVE_DATA_PATH") - os.Unsetenv("SOFT_SERVE_GIT_MAX_CONNECTIONS") - os.Unsetenv("SOFT_SERVE_GIT_MAX_TIMEOUT") - os.Unsetenv("SOFT_SERVE_GIT_IDLE_TIMEOUT") - os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR") - _ = d.Close() - _ = dbx.Close() - os.Exit(code) -} - -func TestIdleTimeout(t *testing.T) { - c, err := net.Dial("tcp", testDaemon.addr) - if err != nil { - t.Fatal(err) - } - out, err := readPktline(c) - if err != nil && !errors.Is(err, io.EOF) { - t.Fatalf("expected nil, got error: %v", err) - } - if out != "ERR "+git.ErrTimeout.Error() && out != "" { - t.Fatalf("expected %q error, got %q", git.ErrTimeout, out) - } -} - -func TestInvalidRepo(t *testing.T) { - c, err := net.Dial("tcp", testDaemon.addr) - if err != nil { - t.Fatal(err) - } - if err := pktline.NewEncoder(c).EncodeString("git-upload-pack /test.git\x00"); err != nil { - t.Fatalf("expected nil, got error: %v", err) - } - out, err := readPktline(c) - if err != nil { - t.Fatalf("expected nil, got error: %v", err) - } - if out != "ERR "+git.ErrInvalidRepo.Error() { - t.Fatalf("expected %q error, got %q", git.ErrInvalidRepo, out) - } -} - -func readPktline(c net.Conn) (string, error) { - buf, err := io.ReadAll(c) - if err != nil { - return "", err - } - pktout := pktline.NewScanner(bytes.NewReader(buf)) - if !pktout.Scan() { - return "", pktout.Err() - } - return strings.TrimSpace(string(pktout.Bytes())), nil -} diff --git a/server/db/context.go b/server/db/context.go deleted file mode 100644 index 5e289d8df..000000000 --- a/server/db/context.go +++ /dev/null @@ -1,19 +0,0 @@ -package db - -import "context" - -// ContextKey is the key used to store the database in the context. -var ContextKey = struct{ string }{"db"} - -// FromContext returns the database from the context. -func FromContext(ctx context.Context) *DB { - if db, ok := ctx.Value(ContextKey).(*DB); ok { - return db - } - return nil -} - -// WithContext returns a new context with the database. -func WithContext(ctx context.Context, db *DB) context.Context { - return context.WithValue(ctx, ContextKey, db) -} diff --git a/server/db/db.go b/server/db/db.go deleted file mode 100644 index ae0bfcaae..000000000 --- a/server/db/db.go +++ /dev/null @@ -1,89 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "errors" - "fmt" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/jmoiron/sqlx" - _ "github.com/lib/pq" // postgres driver - _ "modernc.org/sqlite" // sqlite driver -) - -// DB is the interface for a Soft Serve database. -type DB struct { - *sqlx.DB - logger *log.Logger -} - -// Open opens a database connection. -func Open(ctx context.Context, driverName string, dsn string) (*DB, error) { - db, err := sqlx.ConnectContext(ctx, driverName, dsn) - if err != nil { - return nil, err - } - - d := &DB{ - DB: db, - } - - if config.IsVerbose() { - logger := log.FromContext(ctx).WithPrefix("db") - d.logger = logger - } - - return d, nil -} - -// Close implements db.DB. -func (d *DB) Close() error { - return d.DB.Close() -} - -// Tx is a database transaction. -type Tx struct { - *sqlx.Tx - logger *log.Logger -} - -// Transaction implements db.DB. -func (d *DB) Transaction(fn func(tx *Tx) error) error { - return d.TransactionContext(context.Background(), fn) -} - -// TransactionContext implements db.DB. -func (d *DB) TransactionContext(ctx context.Context, fn func(tx *Tx) error) error { - txx, err := d.DB.BeginTxx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - - tx := &Tx{txx, d.logger} - if err := fn(tx); err != nil { - return rollback(tx, err) - } - - if err := tx.Commit(); err != nil { - if errors.Is(err, sql.ErrTxDone) { - // this is ok because whoever did finish the tx should have also written the error already. - return nil - } - return fmt.Errorf("failed to commit transaction: %w", err) - } - - return nil -} - -func rollback(tx *Tx, err error) error { - if rerr := tx.Rollback(); rerr != nil { - if errors.Is(rerr, sql.ErrTxDone) { - return err - } - return fmt.Errorf("failed to rollback: %s: %w", err.Error(), rerr) - } - - return err -} diff --git a/server/db/errors.go b/server/db/errors.go deleted file mode 100644 index 752835f43..000000000 --- a/server/db/errors.go +++ /dev/null @@ -1,48 +0,0 @@ -package db - -import ( - "database/sql" - "errors" - - "github.com/lib/pq" - sqlite "modernc.org/sqlite" - sqlitelib "modernc.org/sqlite/lib" -) - -var ( - // ErrDuplicateKey is a constraint violation error. - ErrDuplicateKey = errors.New("duplicate key value violates table constraint") - - // ErrRecordNotFound is returned when a record is not found. - ErrRecordNotFound = sql.ErrNoRows -) - -// WrapError is a convenient function that unite various database driver -// errors to consistent errors. -func WrapError(err error) error { - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return ErrRecordNotFound - } - - // Handle sqlite constraint error. - if liteErr, ok := err.(*sqlite.Error); ok { - code := liteErr.Code() - if code == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY || - code == sqlitelib.SQLITE_CONSTRAINT_FOREIGNKEY || - code == sqlitelib.SQLITE_CONSTRAINT_UNIQUE { - return ErrDuplicateKey - } - } - - // Handle postgres constraint error. - if pgErr, ok := err.(*pq.Error); ok { - if pgErr.Code == "23505" || - pgErr.Code == "23503" || - pgErr.Code == "23514" { - return ErrDuplicateKey - } - } - } - return err -} diff --git a/server/db/handler.go b/server/db/handler.go deleted file mode 100644 index 981cadf21..000000000 --- a/server/db/handler.go +++ /dev/null @@ -1,25 +0,0 @@ -package db - -import ( - "context" - "database/sql" - - "github.com/jmoiron/sqlx" -) - -// Handler is a database handler. -type Handler interface { - Rebind(string) string - - Select(interface{}, string, ...interface{}) error - Get(interface{}, string, ...interface{}) error - Queryx(string, ...interface{}) (*sqlx.Rows, error) - QueryRowx(string, ...interface{}) *sqlx.Row - Exec(string, ...interface{}) (sql.Result, error) - - SelectContext(context.Context, interface{}, string, ...interface{}) error - GetContext(context.Context, interface{}, string, ...interface{}) error - QueryxContext(context.Context, string, ...interface{}) (*sqlx.Rows, error) - QueryRowxContext(context.Context, string, ...interface{}) *sqlx.Row - ExecContext(context.Context, string, ...interface{}) (sql.Result, error) -} diff --git a/server/db/logger.go b/server/db/logger.go deleted file mode 100644 index 821576d11..000000000 --- a/server/db/logger.go +++ /dev/null @@ -1,139 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "strings" - - "github.com/charmbracelet/log" - "github.com/jmoiron/sqlx" -) - -func trace(l *log.Logger, query string, args ...interface{}) { - if l != nil { - // Remove newlines and tabs - query = strings.ReplaceAll(query, "\t", "") - query = strings.TrimSpace(query) - l.Debug("trace", "query", query, "args", args) - } -} - -// Select is a wrapper around sqlx.Select that logs the query and arguments. -func (d *DB) Select(dest interface{}, query string, args ...interface{}) error { - trace(d.logger, query, args...) - return d.DB.Select(dest, query, args...) -} - -// Get is a wrapper around sqlx.Get that logs the query and arguments. -func (d *DB) Get(dest interface{}, query string, args ...interface{}) error { - trace(d.logger, query, args...) - return d.DB.Get(dest, query, args...) -} - -// Queryx is a wrapper around sqlx.Queryx that logs the query and arguments. -func (d *DB) Queryx(query string, args ...interface{}) (*sqlx.Rows, error) { - trace(d.logger, query, args...) - return d.DB.Queryx(query, args...) -} - -// QueryRowx is a wrapper around sqlx.QueryRowx that logs the query and arguments. -func (d *DB) QueryRowx(query string, args ...interface{}) *sqlx.Row { - trace(d.logger, query, args...) - return d.DB.QueryRowx(query, args...) -} - -// Exec is a wrapper around sqlx.Exec that logs the query and arguments. -func (d *DB) Exec(query string, args ...interface{}) (sql.Result, error) { - trace(d.logger, query, args...) - return d.DB.Exec(query, args...) -} - -// SelectContext is a wrapper around sqlx.SelectContext that logs the query and arguments. -func (d *DB) SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error { - trace(d.logger, query, args...) - return d.DB.SelectContext(ctx, dest, query, args...) -} - -// GetContext is a wrapper around sqlx.GetContext that logs the query and arguments. -func (d *DB) GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error { - trace(d.logger, query, args...) - return d.DB.GetContext(ctx, dest, query, args...) -} - -// QueryxContext is a wrapper around sqlx.QueryxContext that logs the query and arguments. -func (d *DB) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { - trace(d.logger, query, args...) - return d.DB.QueryxContext(ctx, query, args...) -} - -// QueryRowxContext is a wrapper around sqlx.QueryRowxContext that logs the query and arguments. -func (d *DB) QueryRowxContext(ctx context.Context, query string, args ...interface{}) *sqlx.Row { - trace(d.logger, query, args...) - return d.DB.QueryRowxContext(ctx, query, args...) -} - -// ExecContext is a wrapper around sqlx.ExecContext that logs the query and arguments. -func (d *DB) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { - trace(d.logger, query, args...) - return d.DB.ExecContext(ctx, query, args...) -} - -// Select is a wrapper around sqlx.Select that logs the query and arguments. -func (t *Tx) Select(dest interface{}, query string, args ...interface{}) error { - trace(t.logger, query, args...) - return t.Tx.Select(dest, query, args...) -} - -// Get is a wrapper around sqlx.Get that logs the query and arguments. -func (t *Tx) Get(dest interface{}, query string, args ...interface{}) error { - trace(t.logger, query, args...) - return t.Tx.Get(dest, query, args...) -} - -// Queryx is a wrapper around sqlx.Queryx that logs the query and arguments. -func (t *Tx) Queryx(query string, args ...interface{}) (*sqlx.Rows, error) { - trace(t.logger, query, args...) - return t.Tx.Queryx(query, args...) -} - -// QueryRowx is a wrapper around sqlx.QueryRowx that logs the query and arguments. -func (t *Tx) QueryRowx(query string, args ...interface{}) *sqlx.Row { - trace(t.logger, query, args...) - return t.Tx.QueryRowx(query, args...) -} - -// Exec is a wrapper around sqlx.Exec that logs the query and arguments. -func (t *Tx) Exec(query string, args ...interface{}) (sql.Result, error) { - trace(t.logger, query, args...) - return t.Tx.Exec(query, args...) -} - -// SelectContext is a wrapper around sqlx.SelectContext that logs the query and arguments. -func (t *Tx) SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error { - trace(t.logger, query, args...) - return t.Tx.SelectContext(ctx, dest, query, args...) -} - -// GetContext is a wrapper around sqlx.GetContext that logs the query and arguments. -func (t *Tx) GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error { - trace(t.logger, query, args...) - return t.Tx.GetContext(ctx, dest, query, args...) -} - -// QueryxContext is a wrapper around sqlx.QueryxContext that logs the query and arguments. -func (t *Tx) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { - trace(t.logger, query, args...) - return t.Tx.QueryxContext(ctx, query, args...) -} - -// QueryRowxContext is a wrapper around sqlx.QueryRowxContext that logs the query and arguments. -func (t *Tx) QueryRowxContext(ctx context.Context, query string, args ...interface{}) *sqlx.Row { - trace(t.logger, query, args...) - return t.Tx.QueryRowxContext(ctx, query, args...) -} - -// ExecContext is a wrapper around sqlx.ExecContext that logs the query and arguments. -func (t *Tx) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { - trace(t.logger, query, args...) - return t.Tx.ExecContext(ctx, query, args...) -} diff --git a/server/db/migrate/0001_create_tables.go b/server/db/migrate/0001_create_tables.go deleted file mode 100644 index d1cadd4d4..000000000 --- a/server/db/migrate/0001_create_tables.go +++ /dev/null @@ -1,180 +0,0 @@ -package migrate - -import ( - "context" - "errors" - "fmt" - "strconv" - - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/sshutils" -) - -const ( - createTablesName = "create tables" - createTablesVersion = 1 -) - -var createTables = Migration{ - Version: createTablesVersion, - Name: createTablesName, - Migrate: func(ctx context.Context, tx *db.Tx) error { - cfg := config.FromContext(ctx) - - insert := "INSERT " - - // Alter old tables (if exist) - // This is to support prior versions of Soft Serve v0.6 - switch tx.DriverName() { - case "sqlite3", "sqlite": - insert += "OR IGNORE " - - hasUserTable := hasTable(tx, "user") - if hasUserTable { - if _, err := tx.ExecContext(ctx, "ALTER TABLE user RENAME TO user_old"); err != nil { - return err - } - } - - if hasTable(tx, "public_key") { - if _, err := tx.ExecContext(ctx, "ALTER TABLE public_key RENAME TO public_key_old"); err != nil { - return err - } - } - - if hasTable(tx, "collab") { - if _, err := tx.ExecContext(ctx, "ALTER TABLE collab RENAME TO collab_old"); err != nil { - return err - } - } - - if hasTable(tx, "repo") { - if _, err := tx.ExecContext(ctx, "ALTER TABLE repo RENAME TO repo_old"); err != nil { - return err - } - } - } - - if err := migrateUp(ctx, tx, createTablesVersion, createTablesName); err != nil { - return err - } - - switch tx.DriverName() { - case "sqlite3", "sqlite": - - if _, err := tx.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil { - return err - } - - if hasTable(tx, "user_old") { - sqlm := ` - INSERT INTO users (id, username, admin, updated_at) - SELECT id, username, admin, updated_at FROM user_old; - ` - if _, err := tx.ExecContext(ctx, sqlm); err != nil { - return err - } - } - - if hasTable(tx, "public_key_old") { - // Check duplicate keys - pks := []struct { - ID string `db:"id"` - PublicKey string `db:"public_key"` - }{} - if err := tx.SelectContext(ctx, &pks, "SELECT id, public_key FROM public_key_old"); err != nil { - return err - } - - pkss := map[string]struct{}{} - for _, pk := range pks { - if _, ok := pkss[pk.PublicKey]; ok { - return fmt.Errorf("duplicate public key: %q, please remove the duplicate key and try again", pk.PublicKey) - } - pkss[pk.PublicKey] = struct{}{} - } - - sqlm := ` - INSERT INTO public_keys (id, user_id, public_key, created_at, updated_at) - SELECT id, user_id, public_key, created_at, updated_at FROM public_key_old; - ` - if _, err := tx.ExecContext(ctx, sqlm); err != nil { - return err - } - } - - if hasTable(tx, "repo_old") { - sqlm := ` - INSERT INTO repos (id, name, project_name, description, private,mirror, hidden, created_at, updated_at, user_id) - SELECT id, name, project_name, description, private, mirror, hidden, created_at, updated_at, ( - SELECT id FROM users WHERE admin = true ORDER BY id LIMIT 1 - ) FROM repo_old; - ` - if _, err := tx.ExecContext(ctx, sqlm); err != nil { - return err - } - } - - if hasTable(tx, "collab_old") { - sqlm := ` - INSERT INTO collabs (id, user_id, repo_id, access_level, created_at, updated_at) - SELECT id, user_id, repo_id, ` + strconv.Itoa(int(access.ReadWriteAccess)) + `, created_at, updated_at FROM collab_old; - ` - if _, err := tx.ExecContext(ctx, sqlm); err != nil { - return err - } - } - - if _, err := tx.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { - return err - } - } - - // Insert default user - insertUser := tx.Rebind(insert + "INTO users (username, admin, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)") - if _, err := tx.ExecContext(ctx, insertUser, "admin", true); err != nil { - return err - } - - for _, k := range cfg.AdminKeys() { - query := insert + "INTO public_keys (user_id, public_key, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)" - if tx.DriverName() == "postgres" { - query += " ON CONFLICT DO NOTHING" - } - - query = tx.Rebind(query) - ak := sshutils.MarshalAuthorizedKey(k) - if _, err := tx.ExecContext(ctx, query, 1, ak); err != nil { - if errors.Is(db.WrapError(err), db.ErrDuplicateKey) { - continue - } - return err - } - } - - // Insert default settings - insertSettings := insert + "INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)" - insertSettings = tx.Rebind(insertSettings) - settings := []struct { - Key string - Value string - }{ - {"allow_keyless", "true"}, - {"anon_access", access.ReadOnlyAccess.String()}, - {"init", "true"}, - } - - for _, s := range settings { - if _, err := tx.ExecContext(ctx, insertSettings, s.Key, s.Value); err != nil { - return fmt.Errorf("inserting default settings %q: %w", s.Key, err) - } - } - - return nil - }, - Rollback: func(ctx context.Context, tx *db.Tx) error { - return migrateDown(ctx, tx, createTablesVersion, createTablesName) - }, -} diff --git a/server/db/migrate/0001_create_tables_postgres.down.sql b/server/db/migrate/0001_create_tables_postgres.down.sql deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/db/migrate/0001_create_tables_postgres.up.sql b/server/db/migrate/0001_create_tables_postgres.up.sql deleted file mode 100644 index 7e2d9275d..000000000 --- a/server/db/migrate/0001_create_tables_postgres.up.sql +++ /dev/null @@ -1,110 +0,0 @@ -CREATE TABLE IF NOT EXISTS settings ( - id SERIAL PRIMARY KEY, - key TEXT NOT NULL UNIQUE, - value TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL -); - -CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - admin BOOLEAN NOT NULL, - password TEXT, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL -); - -CREATE TABLE IF NOT EXISTS public_keys ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL, - public_key TEXT NOT NULL UNIQUE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL, - CONSTRAINT user_id_fk - FOREIGN KEY(user_id) REFERENCES users(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); - -CREATE TABLE IF NOT EXISTS repos ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - project_name TEXT NOT NULL, - description TEXT NOT NULL, - private BOOLEAN NOT NULL, - mirror BOOLEAN NOT NULL, - hidden BOOLEAN NOT NULL, - user_id INTEGER NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL, - CONSTRAINT user_id_fk - FOREIGN KEY(user_id) REFERENCES users(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); - -CREATE TABLE IF NOT EXISTS collabs ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL, - repo_id INTEGER NOT NULL, - access_level INTEGER NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL, - UNIQUE (user_id, repo_id), - CONSTRAINT user_id_fk - FOREIGN KEY(user_id) REFERENCES users(id) - ON DELETE CASCADE - ON UPDATE CASCADE, - CONSTRAINT repo_id_fk - FOREIGN KEY(repo_id) REFERENCES repos(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); - -CREATE TABLE IF NOT EXISTS lfs_objects ( - id SERIAL PRIMARY KEY, - oid TEXT NOT NULL, - size INTEGER NOT NULL, - repo_id INTEGER NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL, - UNIQUE (oid, repo_id), - CONSTRAINT repo_id_fk - FOREIGN KEY(repo_id) REFERENCES repos(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); - -CREATE TABLE IF NOT EXISTS lfs_locks ( - id SERIAL PRIMARY KEY, - repo_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - path TEXT NOT NULL, - refname TEXT, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL, - UNIQUE (repo_id, path), - CONSTRAINT repo_id_fk - FOREIGN KEY(repo_id) REFERENCES repos(id) - ON DELETE CASCADE - ON UPDATE CASCADE, - CONSTRAINT user_id_fk - FOREIGN KEY(user_id) REFERENCES users(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); - -CREATE TABLE IF NOT EXISTS access_tokens ( - id SERIAL PRIMARY KEY, - name text NOT NULL, - token TEXT NOT NULL UNIQUE, - user_id INTEGER NOT NULL, - expires_at TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL, - CONSTRAINT user_id_fk - FOREIGN KEY (user_id) REFERENCES users(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); diff --git a/server/db/migrate/0001_create_tables_sqlite.down.sql b/server/db/migrate/0001_create_tables_sqlite.down.sql deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/db/migrate/0001_create_tables_sqlite.up.sql b/server/db/migrate/0001_create_tables_sqlite.up.sql deleted file mode 100644 index 9a1b15fb1..000000000 --- a/server/db/migrate/0001_create_tables_sqlite.up.sql +++ /dev/null @@ -1,110 +0,0 @@ -CREATE TABLE IF NOT EXISTS settings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - key TEXT NOT NULL UNIQUE, - value TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL -); - -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - admin BOOLEAN NOT NULL, - password TEXT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL -); - -CREATE TABLE IF NOT EXISTS public_keys ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - public_key TEXT NOT NULL UNIQUE, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL, - CONSTRAINT user_id_fk - FOREIGN KEY(user_id) REFERENCES users(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); - -CREATE TABLE IF NOT EXISTS repos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - project_name TEXT NOT NULL, - description TEXT NOT NULL, - private BOOLEAN NOT NULL, - mirror BOOLEAN NOT NULL, - hidden BOOLEAN NOT NULL, - user_id INTEGER NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL, - CONSTRAINT user_id_fk - FOREIGN KEY(user_id) REFERENCES users(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); - -CREATE TABLE IF NOT EXISTS collabs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - repo_id INTEGER NOT NULL, - access_level INTEGER NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL, - UNIQUE (user_id, repo_id), - CONSTRAINT user_id_fk - FOREIGN KEY(user_id) REFERENCES users(id) - ON DELETE CASCADE - ON UPDATE CASCADE, - CONSTRAINT repo_id_fk - FOREIGN KEY(repo_id) REFERENCES repos(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); - -CREATE TABLE IF NOT EXISTS lfs_objects ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - oid TEXT NOT NULL, - size INTEGER NOT NULL, - repo_id INTEGER NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL, - UNIQUE (oid, repo_id), - CONSTRAINT repo_id_fk - FOREIGN KEY(repo_id) REFERENCES repos(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); - -CREATE TABLE IF NOT EXISTS lfs_locks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - repo_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - path TEXT NOT NULL, - refname TEXT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL, - UNIQUE (repo_id, path), - CONSTRAINT repo_id_fk - FOREIGN KEY(repo_id) REFERENCES repos(id) - ON DELETE CASCADE - ON UPDATE CASCADE, - CONSTRAINT user_id_fk - FOREIGN KEY(user_id) REFERENCES users(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); - -CREATE TABLE IF NOT EXISTS access_tokens ( - id INTEGER primary key autoincrement, - token text NOT NULL UNIQUE, - name text NOT NULL, - user_id INTEGER NOT NULL, - expires_at DATETIME, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL, - CONSTRAINT user_id_fk - FOREIGN KEY (user_id) REFERENCES users(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); diff --git a/server/db/migrate/0002_webhooks.go b/server/db/migrate/0002_webhooks.go deleted file mode 100644 index 7ad37be61..000000000 --- a/server/db/migrate/0002_webhooks.go +++ /dev/null @@ -1,23 +0,0 @@ -package migrate - -import ( - "context" - - "github.com/charmbracelet/soft-serve/server/db" -) - -const ( - webhooksName = "webhooks" - webhooksVersion = 2 -) - -var webhooks = Migration{ - Name: webhooksName, - Version: webhooksVersion, - Migrate: func(ctx context.Context, tx *db.Tx) error { - return migrateUp(ctx, tx, webhooksVersion, webhooksName) - }, - Rollback: func(ctx context.Context, tx *db.Tx) error { - return migrateDown(ctx, tx, webhooksVersion, webhooksName) - }, -} diff --git a/server/db/migrate/0002_webhooks_postgres.down.sql b/server/db/migrate/0002_webhooks_postgres.down.sql deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/db/migrate/0002_webhooks_postgres.up.sql b/server/db/migrate/0002_webhooks_postgres.up.sql deleted file mode 100644 index dee09f703..000000000 --- a/server/db/migrate/0002_webhooks_postgres.up.sql +++ /dev/null @@ -1,46 +0,0 @@ -CREATE TABLE IF NOT EXISTS webhooks ( - id SERIAL PRIMARY KEY, - repo_id INTEGER NOT NULL, - url TEXT NOT NULL, - secret TEXT NOT NULL, - content_type INTEGER NOT NULL, - active BOOLEAN NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL, - UNIQUE (repo_id, url), - CONSTRAINT repo_id_fk - FOREIGN KEY(repo_id) REFERENCES repos(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); - -CREATE TABLE IF NOT EXISTS webhook_events ( - id SERIAL PRIMARY KEY, - webhook_id INTEGER NOT NULL, - event INTEGER NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE (webhook_id, event), - CONSTRAINT webhook_id_fk - FOREIGN KEY(webhook_id) REFERENCES webhooks(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); - -CREATE TABLE IF NOT EXISTS webhook_deliveries ( - id TEXT PRIMARY KEY, - webhook_id INTEGER NOT NULL, - event INTEGER NOT NULL, - request_url TEXT NOT NULL, - request_method TEXT NOT NULL, - request_error TEXT, - request_headers TEXT NOT NULL, - request_body TEXT NOT NULL, - response_status INTEGER NOT NULL, - response_headers TEXT NOT NULL, - response_body TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT webhook_id_fk - FOREIGN KEY(webhook_id) REFERENCES webhooks(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); diff --git a/server/db/migrate/0002_webhooks_sqlite.down.sql b/server/db/migrate/0002_webhooks_sqlite.down.sql deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/db/migrate/0002_webhooks_sqlite.up.sql b/server/db/migrate/0002_webhooks_sqlite.up.sql deleted file mode 100644 index 5f2139c30..000000000 --- a/server/db/migrate/0002_webhooks_sqlite.up.sql +++ /dev/null @@ -1,46 +0,0 @@ -CREATE TABLE IF NOT EXISTS webhooks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - repo_id INTEGER NOT NULL, - url TEXT NOT NULL, - secret TEXT NOT NULL, - content_type INTEGER NOT NULL, - active BOOLEAN NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL, - UNIQUE (repo_id, url), - CONSTRAINT repo_id_fk - FOREIGN KEY(repo_id) REFERENCES repos(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); - -CREATE TABLE IF NOT EXISTS webhook_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - webhook_id INTEGER NOT NULL, - event INTEGER NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE (webhook_id, event), - CONSTRAINT webhook_id_fk - FOREIGN KEY(webhook_id) REFERENCES webhooks(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); - -CREATE TABLE IF NOT EXISTS webhook_deliveries ( - id TEXT PRIMARY KEY, - webhook_id INTEGER NOT NULL, - event INTEGER NOT NULL, - request_url TEXT NOT NULL, - request_method TEXT NOT NULL, - request_error TEXT, - request_headers TEXT NOT NULL, - request_body TEXT NOT NULL, - response_status INTEGER NOT NULL, - response_headers TEXT NOT NULL, - response_body TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT webhook_id_fk - FOREIGN KEY(webhook_id) REFERENCES webhooks(id) - ON DELETE CASCADE - ON UPDATE CASCADE -); diff --git a/server/db/migrate/migrate.go b/server/db/migrate/migrate.go deleted file mode 100644 index 23242a302..000000000 --- a/server/db/migrate/migrate.go +++ /dev/null @@ -1,142 +0,0 @@ -package migrate - -import ( - "context" - "database/sql" - "errors" - "fmt" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/db" -) - -// MigrateFunc is a function that executes a migration. -type MigrateFunc func(ctx context.Context, tx *db.Tx) error // nolint:revive - -// Migration is a struct that contains the name of the migration and the -// function to execute it. -type Migration struct { - Version int64 - Name string - Migrate MigrateFunc - Rollback MigrateFunc -} - -// Migrations is a database model to store migrations. -type Migrations struct { - ID int64 `db:"id"` - Name string `db:"name"` - Version int64 `db:"version"` -} - -func (Migrations) schema(driverName string) string { - switch driverName { - case "sqlite3", "sqlite": - return `CREATE TABLE IF NOT EXISTS migrations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - version INTEGER NOT NULL UNIQUE - ); - ` - case "postgres": - return `CREATE TABLE IF NOT EXISTS migrations ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - version INTEGER NOT NULL UNIQUE - ); - ` - case "mysql": - return `CREATE TABLE IF NOT EXISTS migrations ( - id INT NOT NULL AUTO_INCREMENT, - name TEXT NOT NULL, - version INT NOT NULL, - UNIQUE (version), - PRIMARY KEY (id) - ); - ` - default: - panic("unknown driver") - } -} - -// Migrate runs the migrations. -func Migrate(ctx context.Context, dbx *db.DB) error { - logger := log.FromContext(ctx).WithPrefix("migrate") - return dbx.TransactionContext(ctx, func(tx *db.Tx) error { - if !hasTable(tx, "migrations") { - if _, err := tx.Exec(Migrations{}.schema(tx.DriverName())); err != nil { - return err - } - } - - var migrs Migrations - if err := tx.Get(&migrs, tx.Rebind("SELECT * FROM migrations ORDER BY version DESC LIMIT 1")); err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return err - } - } - - for _, m := range migrations { - if m.Version <= migrs.Version { - continue - } - - logger.Infof("running migration %d. %s", m.Version, m.Name) - if err := m.Migrate(ctx, tx); err != nil { - return err - } - - if _, err := tx.Exec(tx.Rebind("INSERT INTO migrations (name, version) VALUES (?, ?)"), m.Name, m.Version); err != nil { - return err - } - } - - return nil - }) -} - -// Rollback rolls back a migration. -func Rollback(ctx context.Context, dbx *db.DB) error { - logger := log.FromContext(ctx).WithPrefix("migrate") - return dbx.TransactionContext(ctx, func(tx *db.Tx) error { - var migrs Migrations - if err := tx.Get(&migrs, tx.Rebind("SELECT * FROM migrations ORDER BY version DESC LIMIT 1")); err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("there are no migrations to rollback: %w", err) - } - } - - if migrs.Version == 0 || len(migrations) < int(migrs.Version) { - return fmt.Errorf("there are no migrations to rollback") - } - - m := migrations[migrs.Version-1] - logger.Infof("rolling back migration %d. %s", m.Version, m.Name) - if err := m.Rollback(ctx, tx); err != nil { - return err - } - - if _, err := tx.Exec(tx.Rebind("DELETE FROM migrations WHERE version = ?"), migrs.Version); err != nil { - return err - } - - return nil - }) -} - -func hasTable(tx *db.Tx, tableName string) bool { - var query string - switch tx.DriverName() { - case "sqlite3", "sqlite": - query = "SELECT name FROM sqlite_master WHERE type='table' AND name=?" - case "postgres": - fallthrough - case "mysql": - query = "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?" - } - - query = tx.Rebind(query) - var name string - err := tx.Get(&name, query, tableName) - return err == nil -} diff --git a/server/db/migrate/migrations.go b/server/db/migrate/migrations.go deleted file mode 100644 index 890454ab1..000000000 --- a/server/db/migrate/migrations.go +++ /dev/null @@ -1,63 +0,0 @@ -package migrate - -import ( - "context" - "embed" - "fmt" - "regexp" - "strings" - - "github.com/charmbracelet/soft-serve/server/db" -) - -//go:embed *.sql -var sqls embed.FS - -// Keep this in order of execution, oldest to newest. -var migrations = []Migration{ - createTables, - webhooks, -} - -func execMigration(ctx context.Context, tx *db.Tx, version int, name string, down bool) error { - direction := "up" - if down { - direction = "down" - } - - driverName := tx.DriverName() - if driverName == "sqlite3" { - driverName = "sqlite" - } - - fn := fmt.Sprintf("%04d_%s_%s.%s.sql", version, toSnakeCase(name), driverName, direction) - sqlstr, err := sqls.ReadFile(fn) - if err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, string(sqlstr)); err != nil { - return err - } - - return nil -} - -func migrateUp(ctx context.Context, tx *db.Tx, version int, name string) error { - return execMigration(ctx, tx, version, name, false) -} - -func migrateDown(ctx context.Context, tx *db.Tx, version int, name string) error { - return execMigration(ctx, tx, version, name, true) -} - -var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") -var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])") - -func toSnakeCase(str string) string { - str = strings.ReplaceAll(str, "-", "_") - str = strings.ReplaceAll(str, " ", "_") - snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}") - snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}") - return strings.ToLower(snake) -} diff --git a/server/db/models/access_token.go b/server/db/models/access_token.go deleted file mode 100644 index babedef40..000000000 --- a/server/db/models/access_token.go +++ /dev/null @@ -1,17 +0,0 @@ -package models - -import ( - "database/sql" - "time" -) - -// AccessToken represents an access token. -type AccessToken struct { - ID int64 `db:"id"` - Name string `db:"name"` - UserID int64 `db:"user_id"` - Token string `db:"token"` - ExpiresAt sql.NullTime `db:"expires_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} diff --git a/server/db/models/collab.go b/server/db/models/collab.go deleted file mode 100644 index 7efc44b9d..000000000 --- a/server/db/models/collab.go +++ /dev/null @@ -1,17 +0,0 @@ -package models - -import ( - "time" - - "github.com/charmbracelet/soft-serve/server/access" -) - -// Collab represents a repository collaborator. -type Collab struct { - ID int64 `db:"id"` - RepoID int64 `db:"repo_id"` - UserID int64 `db:"user_id"` - AccessLevel access.AccessLevel `db:"access_level"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} diff --git a/server/db/models/lfs.go b/server/db/models/lfs.go deleted file mode 100644 index f93ea55ba..000000000 --- a/server/db/models/lfs.go +++ /dev/null @@ -1,24 +0,0 @@ -package models - -import "time" - -// LFSObject is a Git LFS object. -type LFSObject struct { - ID int64 `db:"id"` - Oid string `db:"oid"` - Size int64 `db:"size"` - RepoID int64 `db:"repo_id"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -// LFSLock is a Git LFS lock. -type LFSLock struct { - ID int64 `db:"id"` - Path string `db:"path"` - UserID int64 `db:"user_id"` - RepoID int64 `db:"repo_id"` - Refname string `db:"refname"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} diff --git a/server/db/models/public_key.go b/server/db/models/public_key.go deleted file mode 100644 index a8551f46c..000000000 --- a/server/db/models/public_key.go +++ /dev/null @@ -1,10 +0,0 @@ -package models - -// PublicKey represents a public key. -type PublicKey struct { - ID int64 `db:"id"` - UserID int64 `db:"user_id"` - PublicKey string `db:"public_key"` - CreatedAt string `db:"created_at"` - UpdatedAt string `db:"updated_at"` -} diff --git a/server/db/models/repo.go b/server/db/models/repo.go deleted file mode 100644 index 88300bd3e..000000000 --- a/server/db/models/repo.go +++ /dev/null @@ -1,20 +0,0 @@ -package models - -import ( - "database/sql" - "time" -) - -// Repo is a database model for a repository. -type Repo struct { - ID int64 `db:"id"` - Name string `db:"name"` - ProjectName string `db:"project_name"` - Description string `db:"description"` - Private bool `db:"private"` - Mirror bool `db:"mirror"` - Hidden bool `db:"hidden"` - UserID sql.NullInt64 `db:"user_id"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} diff --git a/server/db/models/settings.go b/server/db/models/settings.go deleted file mode 100644 index c30bbb9e1..000000000 --- a/server/db/models/settings.go +++ /dev/null @@ -1,10 +0,0 @@ -package models - -// Settings represents a settings record. -type Settings struct { - ID int64 `db:"id"` - Key string `db:"key"` - Value string `db:"value"` - CreatedAt string `db:"created_at"` - UpdatedAt string `db:"updated_at"` -} diff --git a/server/db/models/user.go b/server/db/models/user.go deleted file mode 100644 index 5ca0d3d9f..000000000 --- a/server/db/models/user.go +++ /dev/null @@ -1,16 +0,0 @@ -package models - -import ( - "database/sql" - "time" -) - -// User represents a user. -type User struct { - ID int64 `db:"id"` - Username string `db:"username"` - Admin bool `db:"admin"` - Password sql.NullString `db:"password"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} diff --git a/server/db/models/webhook.go b/server/db/models/webhook.go deleted file mode 100644 index 85667ceb8..000000000 --- a/server/db/models/webhook.go +++ /dev/null @@ -1,44 +0,0 @@ -package models - -import ( - "database/sql" - "time" - - "github.com/google/uuid" -) - -// Webhook is a repository webhook. -type Webhook struct { - ID int64 `db:"id"` - RepoID int64 `db:"repo_id"` - URL string `db:"url"` - Secret string `db:"secret"` - ContentType int `db:"content_type"` - Active bool `db:"active"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -// WebhookEvent is a webhook event. -type WebhookEvent struct { - ID int64 `db:"id"` - WebhookID int64 `db:"webhook_id"` - Event int `db:"event"` - CreatedAt time.Time `db:"created_at"` -} - -// WebhookDelivery is a webhook delivery. -type WebhookDelivery struct { - ID uuid.UUID `db:"id"` - WebhookID int64 `db:"webhook_id"` - Event int `db:"event"` - RequestURL string `db:"request_url"` - RequestMethod string `db:"request_method"` - RequestError sql.NullString `db:"request_error"` - RequestHeaders string `db:"request_headers"` - RequestBody string `db:"request_body"` - ResponseStatus int `db:"response_status"` - ResponseHeaders string `db:"response_headers"` - ResponseBody string `db:"response_body"` - CreatedAt time.Time `db:"created_at"` -} diff --git a/server/git/errors.go b/server/git/errors.go deleted file mode 100644 index fa4a8bda2..000000000 --- a/server/git/errors.go +++ /dev/null @@ -1,23 +0,0 @@ -package git - -import "errors" - -var ( - // ErrNotAuthed represents unauthorized access. - ErrNotAuthed = errors.New("you are not authorized to do this") - - // ErrSystemMalfunction represents a general system error returned to clients. - ErrSystemMalfunction = errors.New("something went wrong") - - // ErrInvalidRepo represents an attempt to access a non-existent repo. - ErrInvalidRepo = errors.New("invalid repo") - - // ErrInvalidRequest represents an invalid request. - ErrInvalidRequest = errors.New("invalid request") - - // ErrMaxConnections represents a maximum connection limit being reached. - ErrMaxConnections = errors.New("too many connections, try again later") - - // ErrTimeout is returned when the maximum read timeout is exceeded. - ErrTimeout = errors.New("I/O timeout reached") -) diff --git a/server/git/git.go b/server/git/git.go deleted file mode 100644 index 8baa54a32..000000000 --- a/server/git/git.go +++ /dev/null @@ -1,96 +0,0 @@ -package git - -import ( - "context" - "fmt" - "io" - "path/filepath" - "strings" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/git" - "github.com/go-git/go-git/v5/plumbing/format/pktline" - gitm "github.com/gogs/git-module" -) - -// WritePktline encodes and writes a pktline to the given writer. -func WritePktline(w io.Writer, v ...interface{}) error { - msg := fmt.Sprintln(v...) - pkt := pktline.NewEncoder(w) - if err := pkt.EncodeString(msg); err != nil { - return fmt.Errorf("git: error writing pkt-line message: %w", err) - } - if err := pkt.Flush(); err != nil { - return fmt.Errorf("git: error flushing pkt-line message: %w", err) - } - - return nil -} - -// WritePktlineErr writes an error pktline to the given writer. -func WritePktlineErr(w io.Writer, err error) error { - return WritePktline(w, "ERR", err.Error()) -} - -// EnsureWithin ensures the given repo is within the repos directory. -func EnsureWithin(reposDir string, repo string) error { - repoDir := filepath.Join(reposDir, repo) - absRepos, err := filepath.Abs(reposDir) - if err != nil { - log.Debugf("failed to get absolute path for repo: %s", err) - return ErrSystemMalfunction - } - absRepo, err := filepath.Abs(repoDir) - if err != nil { - log.Debugf("failed to get absolute path for repos: %s", err) - return ErrSystemMalfunction - } - - // ensure the repo is within the repos directory - if !strings.HasPrefix(absRepo, absRepos) { - log.Debugf("repo path is outside of repos directory: %s", absRepo) - return ErrInvalidRepo - } - - return nil -} - -// EnsureDefaultBranch ensures the repo has a default branch. -// It will prefer choosing "main" or "master" if available. -func EnsureDefaultBranch(ctx context.Context, scmd ServiceCommand) error { - r, err := git.Open(scmd.Dir) - if err != nil { - return err - } - brs, err := r.Branches() - if err != nil { - return err - } - if len(brs) == 0 { - return fmt.Errorf("no branches found") - } - // Rename the default branch to the first branch available - _, err = r.HEAD() - if err == git.ErrReferenceNotExist { - branch := brs[0] - // Prefer "main" or "master" as the default branch - for _, b := range brs { - if b == "main" || b == "master" { - branch = b - break - } - } - - if _, err := r.SymbolicRef(git.HEAD, git.RefsHeads+branch, gitm.SymbolicRefOptions{ - CommandOptions: gitm.CommandOptions{ - Context: ctx, - }, - }); err != nil { - return err - } - } - if err != nil && err != git.ErrReferenceNotExist { - return err - } - return nil -} diff --git a/server/git/git_test.go b/server/git/git_test.go deleted file mode 100644 index d95cb6497..000000000 --- a/server/git/git_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package git - -import ( - "bytes" - "fmt" - "testing" -) - -func TestPktline(t *testing.T) { - cases := []struct { - name string - in []byte - err error - out []byte - }{ - { - name: "empty", - in: []byte{}, - out: []byte("0005\n0000"), - }, - { - name: "simple", - in: []byte("hello"), - out: []byte("000ahello\n0000"), - }, - { - name: "newline", - in: []byte("hello\n"), - out: []byte("000bhello\n\n0000"), - }, - { - name: "error", - err: fmt.Errorf("foobar"), - out: []byte("000fERR foobar\n0000"), - }, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - var out bytes.Buffer - if c.err == nil { - if err := WritePktline(&out, string(c.in)); err != nil { - t.Fatal(err) - } - } else { - if err := WritePktlineErr(&out, c.err); err != nil { - t.Fatal(err) - } - } - - if !bytes.Equal(out.Bytes(), c.out) { - t.Errorf("expected %q, got %q", c.out, out.Bytes()) - } - }) - } -} diff --git a/server/git/lfs.go b/server/git/lfs.go deleted file mode 100644 index 9e6851f19..000000000 --- a/server/git/lfs.go +++ /dev/null @@ -1,503 +0,0 @@ -package git - -import ( - "context" - "crypto/rand" - "errors" - "fmt" - "io" - "io/fs" - "path" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/charmbracelet/git-lfs-transfer/transfer" - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "github.com/charmbracelet/soft-serve/server/lfs" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/storage" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/rubyist/tracerx" -) - -func init() { - // git-lfs-transfer uses tracerx for logging. - // use a custom key to avoid conflicts - // SOFT_SERVE_TRACE=1 to enable tracing git-lfs-transfer in soft-serve - tracerx.DefaultKey = "SOFT_SERVE" - tracerx.Prefix = "trace soft-serve-lfs-transfer: " -} - -// lfsTransfer implements transfer.Backend. -type lfsTransfer struct { - ctx context.Context - cfg *config.Config - dbx *db.DB - store store.Store - logger *log.Logger - storage storage.Storage - repo proto.Repository -} - -var _ transfer.Backend = &lfsTransfer{} - -// LFSTransfer is a Git LFS transfer service handler. -// ctx is expected to have proto.User, *backend.Backend, *log.Logger, -// *config.Config, *db.DB, and store.Store. -// The first arg in cmd.Args should be the repo path. -// The second arg in cmd.Args should be the LFS operation (download or upload). -func LFSTransfer(ctx context.Context, cmd ServiceCommand) error { - if len(cmd.Args) < 2 { - return errors.New("missing args") - } - - op := cmd.Args[1] - if op != lfs.OperationDownload && op != lfs.OperationUpload { - return errors.New("invalid operation") - } - - logger := log.FromContext(ctx).WithPrefix("lfs-transfer") - handler := transfer.NewPktline(cmd.Stdin, cmd.Stdout) - repo := proto.RepositoryFromContext(ctx) - if repo == nil { - logger.Error("no repository in context") - return proto.ErrRepoNotFound - } - - // Advertise capabilities. - for _, cap := range []string{ - "version=1", - "locking", - } { - if err := handler.WritePacketText(cap); err != nil { - logger.Errorf("error sending capability: %s: %v", cap, err) - return err - } - } - - if err := handler.WriteFlush(); err != nil { - logger.Error("error sending flush", "err", err) - return err - } - - repoID := strconv.FormatInt(repo.ID(), 10) - cfg := config.FromContext(ctx) - processor := transfer.NewProcessor(handler, &lfsTransfer{ - ctx: ctx, - cfg: cfg, - dbx: db.FromContext(ctx), - store: store.FromContext(ctx), - logger: logger, - storage: storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)), - repo: repo, - }) - - return processor.ProcessCommands(op) -} - -// Batch implements transfer.Backend. -func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer, _ map[string]string) ([]transfer.BatchItem, error) { - items := make([]transfer.BatchItem, 0) - for _, p := range pointers { - obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), p.Oid) - if err != nil && !errors.Is(err, db.ErrRecordNotFound) { - return items, db.WrapError(err) - } - - exist, err := t.storage.Exists(path.Join("objects", p.RelativePath())) - if err != nil { - return items, err - } - - if exist && obj.ID == 0 { - if err := t.store.CreateLFSObject(t.ctx, t.dbx, t.repo.ID(), p.Oid, p.Size); err != nil { - return items, db.WrapError(err) - } - } - - item := transfer.BatchItem{ - Pointer: p, - Present: exist, - } - items = append(items, item) - } - - return items, nil -} - -// Download implements transfer.Backend. -func (t *lfsTransfer) Download(oid string, _ map[string]string) (fs.File, error) { - cfg := config.FromContext(t.ctx) - repoID := strconv.FormatInt(t.repo.ID(), 10) - strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)) - pointer := transfer.Pointer{Oid: oid} - return strg.Open(path.Join("objects", pointer.RelativePath())) -} - -type uploadObject struct { - oid string - size int64 - object storage.Object -} - -// StartUpload implements transfer.Backend. -func (t *lfsTransfer) StartUpload(oid string, r io.Reader, _ map[string]string) (interface{}, error) { - if r == nil { - return nil, fmt.Errorf("no reader: %w", transfer.ErrMissingData) - } - - tempDir := "incomplete" - randBytes := make([]byte, 12) - if _, err := rand.Read(randBytes); err != nil { - return nil, err - } - - tempName := fmt.Sprintf("%s%x", oid, randBytes) - tempName = path.Join(tempDir, tempName) - - written, err := t.storage.Put(tempName, r) - if err != nil { - t.logger.Errorf("error putting object: %v", err) - return nil, err - } - - obj, err := t.storage.Open(tempName) - if err != nil { - t.logger.Errorf("error opening object: %v", err) - return nil, err - } - - t.logger.Infof("Object name: %s", obj.Name()) - - return uploadObject{ - oid: oid, - size: written, - object: obj, - }, nil -} - -// FinishUpload implements transfer.Backend. -func (t *lfsTransfer) FinishUpload(state interface{}, args map[string]string) error { - upl, ok := state.(uploadObject) - if !ok { - return errors.New("invalid state") - } - - var size int64 - for _, arg := range args { - if strings.HasPrefix(arg, "size=") { - size, _ = strconv.ParseInt(strings.TrimPrefix(arg, "size="), 10, 64) - break - } - } - - pointer := transfer.Pointer{ - Oid: upl.oid, - } - if size > 0 { - pointer.Size = size - } else { - pointer.Size = upl.size - } - - if err := t.store.CreateLFSObject(t.ctx, t.dbx, t.repo.ID(), pointer.Oid, pointer.Size); err != nil { - return db.WrapError(err) - } - - expectedPath := path.Join("objects", pointer.RelativePath()) - if err := t.storage.Rename(upl.object.Name(), expectedPath); err != nil { - t.logger.Errorf("error renaming object: %v", err) - _ = t.store.DeleteLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), pointer.Oid) - return err - } - - return nil -} - -// Verify implements transfer.Backend. -func (t *lfsTransfer) Verify(oid string, args map[string]string) (transfer.Status, error) { - var expectedSize int64 - var err error - size, ok := args[transfer.SizeKey] - if !ok { - return transfer.NewFailureStatus(transfer.StatusBadRequest, "missing size"), nil - } - - expectedSize, err = strconv.ParseInt(size, 10, 64) - if err != nil { - t.logger.Errorf("invalid size argument: %v", err) - return transfer.NewFailureStatus(transfer.StatusBadRequest, "invalid size argument"), nil - } - - obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), oid) - if err != nil { - if errors.Is(err, db.ErrRecordNotFound) { - return transfer.NewFailureStatus(transfer.StatusNotFound, "object not found"), nil - } - t.logger.Errorf("error getting object: %v", err) - return nil, err - } - - if obj.Size != expectedSize { - t.logger.Errorf("size mismatch: %d != %d", obj.Size, expectedSize) - return transfer.NewFailureStatus(transfer.StatusConflict, "size mismatch"), nil - } - - return transfer.SuccessStatus(), nil -} - -type lfsLockBackend struct { - *lfsTransfer - args map[string]string - user proto.User -} - -var _ transfer.LockBackend = (*lfsLockBackend)(nil) - -// LockBackend implements transfer.Backend. -func (t *lfsTransfer) LockBackend(args map[string]string) transfer.LockBackend { - user := proto.UserFromContext(t.ctx) - if user == nil { - t.logger.Errorf("no user in context while creating lock backend, repo %s", t.repo.Name()) - return nil - } - - return &lfsLockBackend{t, args, user} -} - -// Create implements transfer.LockBackend. -func (l *lfsLockBackend) Create(path string, refname string) (transfer.Lock, error) { - var lock LFSLock - if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { - if err := l.store.CreateLFSLockForUser(l.ctx, tx, l.repo.ID(), l.user.ID(), path, refname); err != nil { - return db.WrapError(err) - } - - var err error - lock.lock, err = l.store.GetLFSLockForUserPath(l.ctx, tx, l.repo.ID(), l.user.ID(), path) - if err != nil { - return db.WrapError(err) - } - - lock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID) - return db.WrapError(err) - }); err != nil { - // Return conflict (409) if the lock already exists. - if errors.Is(err, db.ErrDuplicateKey) { - return nil, transfer.ErrConflict - } - l.logger.Errorf("error creating lock: %v", err) - return nil, err - } - - lock.backend = l - - return &lock, nil -} - -// FromID implements transfer.LockBackend. -func (l *lfsLockBackend) FromID(id string) (transfer.Lock, error) { - var lock LFSLock - iid, err := strconv.ParseInt(id, 10, 64) - if err != nil { - return nil, err - } - - if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { - var err error - lock.lock, err = l.store.GetLFSLockForUserByID(l.ctx, tx, l.repo.ID(), l.user.ID(), iid) - if err != nil { - return db.WrapError(err) - } - - lock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID) - return db.WrapError(err) - }); err != nil { - if errors.Is(err, db.ErrRecordNotFound) { - return nil, transfer.ErrNotFound - } - l.logger.Errorf("error getting lock: %v", err) - return nil, err - } - - lock.backend = l - - return &lock, nil -} - -// FromPath implements transfer.LockBackend. -func (l *lfsLockBackend) FromPath(path string) (transfer.Lock, error) { - var lock LFSLock - - if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { - var err error - lock.lock, err = l.store.GetLFSLockForUserPath(l.ctx, tx, l.repo.ID(), l.user.ID(), path) - if err != nil { - return db.WrapError(err) - } - - lock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID) - return db.WrapError(err) - }); err != nil { - if errors.Is(err, db.ErrRecordNotFound) { - return nil, transfer.ErrNotFound - } - l.logger.Errorf("error getting lock: %v", err) - return nil, err - } - - lock.backend = l - - return &lock, nil -} - -// Range implements transfer.LockBackend. -func (l *lfsLockBackend) Range(cursor string, limit int, fn func(transfer.Lock) error) (string, error) { - var nextCursor string - var locks []*LFSLock - - page, _ := strconv.Atoi(cursor) - if page <= 0 { - page = 1 - } - - if limit <= 0 { - limit = lfs.DefaultLocksLimit - } else if limit > 100 { - limit = 100 - } - - if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { - l.logger.Debug("getting locks", "limit", limit, "page", page) - mlocks, err := l.store.GetLFSLocks(l.ctx, tx, l.repo.ID(), page, limit) - if err != nil { - return db.WrapError(err) - } - - if len(mlocks) == limit { - nextCursor = strconv.Itoa(page + 1) - } - - users := make(map[int64]models.User, 0) - for _, mlock := range mlocks { - owner, ok := users[mlock.UserID] - if !ok { - owner, err = l.store.GetUserByID(l.ctx, tx, mlock.UserID) - if err != nil { - return db.WrapError(err) - } - - users[mlock.UserID] = owner - } - - locks = append(locks, &LFSLock{lock: mlock, owner: owner, backend: l}) - } - - return nil - }); err != nil { - return "", err - } - - for _, lock := range locks { - if err := fn(lock); err != nil { - return "", err - } - } - - return nextCursor, nil -} - -// Unlock implements transfer.LockBackend. -func (l *lfsLockBackend) Unlock(lock transfer.Lock) error { - id, err := strconv.ParseInt(lock.ID(), 10, 64) - if err != nil { - return err - } - - err = l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { - return db.WrapError( - l.store.DeleteLFSLockForUserByID(l.ctx, tx, l.repo.ID(), l.user.ID(), id), - ) - }) - if err != nil { - if errors.Is(err, db.ErrRecordNotFound) { - return transfer.ErrNotFound - } - l.logger.Error("error unlocking lock", "err", err) - return err - } - - return nil -} - -// LFSLock is a Git LFS lock object. -// It implements transfer.Lock. -type LFSLock struct { - lock models.LFSLock - owner models.User - backend *lfsLockBackend -} - -var _ transfer.Lock = (*LFSLock)(nil) - -// AsArguments implements transfer.Lock. -func (l *LFSLock) AsArguments() []string { - return []string{ - fmt.Sprintf("id=%s", l.ID()), - fmt.Sprintf("path=%s", l.Path()), - fmt.Sprintf("locked-at=%s", l.FormattedTimestamp()), - fmt.Sprintf("ownername=%s", l.OwnerName()), - } -} - -// AsLockSpec implements transfer.Lock. -func (l *LFSLock) AsLockSpec(ownerID bool) ([]string, error) { - id := l.ID() - spec := []string{ - fmt.Sprintf("lock %s", id), - fmt.Sprintf("path %s %s", id, l.Path()), - fmt.Sprintf("locked-at %s %s", id, l.FormattedTimestamp()), - fmt.Sprintf("ownername %s %s", id, l.OwnerName()), - } - - if ownerID { - who := "theirs" - if l.lock.UserID == l.owner.ID { - who = "ours" - } - - spec = append(spec, fmt.Sprintf("owner %s %s", id, who)) - } - - return spec, nil -} - -// FormattedTimestamp implements transfer.Lock. -func (l *LFSLock) FormattedTimestamp() string { - return l.lock.CreatedAt.Format(time.RFC3339) -} - -// ID implements transfer.Lock. -func (l *LFSLock) ID() string { - return strconv.FormatInt(l.lock.ID, 10) -} - -// OwnerName implements transfer.Lock. -func (l *LFSLock) OwnerName() string { - return l.owner.Username -} - -// Path implements transfer.Lock. -func (l *LFSLock) Path() string { - return l.lock.Path -} - -// Unlock implements transfer.Lock. -func (l *LFSLock) Unlock() error { - return l.backend.Unlock(l) -} diff --git a/server/git/lfs_auth.go b/server/git/lfs_auth.go deleted file mode 100644 index 0568d2907..000000000 --- a/server/git/lfs_auth.go +++ /dev/null @@ -1,85 +0,0 @@ -package git - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/jwk" - "github.com/charmbracelet/soft-serve/server/lfs" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/golang-jwt/jwt/v5" -) - -// LFSAuthenticate implements teh Git LFS SSH authentication command. -// Context must have *config.Config, *log.Logger, proto.User. -// cmd.Args should have the repo path and operation as arguments. -func LFSAuthenticate(ctx context.Context, cmd ServiceCommand) error { - if len(cmd.Args) < 2 { - return errors.New("missing args") - } - - logger := log.FromContext(ctx).WithPrefix("ssh.lfs-authenticate") - operation := cmd.Args[1] - if operation != lfs.OperationDownload && operation != lfs.OperationUpload { - logger.Errorf("invalid operation: %s", operation) - return errors.New("invalid operation") - } - - user := proto.UserFromContext(ctx) - if user == nil { - logger.Errorf("missing user") - return proto.ErrUserNotFound - } - - repo := proto.RepositoryFromContext(ctx) - if repo == nil { - logger.Errorf("missing repository") - return proto.ErrRepoNotFound - } - - cfg := config.FromContext(ctx) - kp, err := jwk.NewPair(cfg) - if err != nil { - logger.Error("failed to get JWK pair", "err", err) - return err - } - - now := time.Now() - expiresIn := time.Minute * 5 - expiresAt := now.Add(expiresIn) - claims := jwt.RegisteredClaims{ - Subject: fmt.Sprintf("%s#%d", user.Username(), user.ID()), - ExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour - NotBefore: jwt.NewNumericDate(now), - IssuedAt: jwt.NewNumericDate(now), - Issuer: cfg.HTTP.PublicURL, - Audience: []string{ - repo.Name(), - }, - } - - token := jwt.NewWithClaims(jwk.SigningMethod, claims) - token.Header["kid"] = kp.JWK().KeyID - j, err := token.SignedString(kp.PrivateKey()) - if err != nil { - logger.Error("failed to sign token", "err", err) - return err - } - - href := fmt.Sprintf("%s/%s.git/info/lfs", cfg.HTTP.PublicURL, repo.Name()) - logger.Debug("generated token", "token", j, "href", href, "expires_at", expiresAt) - - return json.NewEncoder(cmd.Stdout).Encode(lfs.AuthenticateResponse{ - Header: map[string]string{ - "Authorization": fmt.Sprintf("Bearer %s", j), - }, - Href: href, - ExpiresAt: expiresAt, - ExpiresIn: expiresIn, - }) -} diff --git a/server/git/service.go b/server/git/service.go deleted file mode 100644 index e0d6877b7..000000000 --- a/server/git/service.go +++ /dev/null @@ -1,184 +0,0 @@ -package git - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "os/exec" - "strings" - - "golang.org/x/sync/errgroup" -) - -// Service is a Git daemon service. -type Service string - -const ( - // UploadPackService is the upload-pack service. - UploadPackService Service = "git-upload-pack" - // UploadArchiveService is the upload-archive service. - UploadArchiveService Service = "git-upload-archive" - // ReceivePackService is the receive-pack service. - ReceivePackService Service = "git-receive-pack" - // LFSTransferService is the LFS transfer service. - LFSTransferService Service = "git-lfs-transfer" - // LFSAuthenticateService is the LFS authenticate service. - LFSAuthenticateService = "git-lfs-authenticate" -) - -// String returns the string representation of the service. -func (s Service) String() string { - return string(s) -} - -// Name returns the name of the service. -func (s Service) Name() string { - return strings.TrimPrefix(s.String(), "git-") -} - -// Handler is the service handler. -func (s Service) Handler(ctx context.Context, cmd ServiceCommand) error { - switch s { - case UploadPackService, UploadArchiveService, ReceivePackService: - return gitServiceHandler(ctx, s, cmd) - case LFSTransferService: - return LFSTransfer(ctx, cmd) - case LFSAuthenticateService: - return LFSAuthenticate(ctx, cmd) - default: - return fmt.Errorf("unsupported service: %s", s) - } -} - -// ServiceHandler is a git service command handler. -type ServiceHandler func(ctx context.Context, cmd ServiceCommand) error - -// gitServiceHandler is the default service handler using the git binary. -func gitServiceHandler(ctx context.Context, svc Service, scmd ServiceCommand) error { - cmd := exec.CommandContext(ctx, "git") - cmd.Dir = scmd.Dir - cmd.Args = append(cmd.Args, []string{ - // Enable partial clones - "-c", "uploadpack.allowFilter=true", - // Enable push options - "-c", "receive.advertisePushOptions=true", - // Disable LFS filters - "-c", "filter.lfs.required=", "-c", "filter.lfs.smudge=", "-c", "filter.lfs.clean=", - svc.Name(), - }...) - if len(scmd.Args) > 0 { - cmd.Args = append(cmd.Args, scmd.Args...) - } - - cmd.Args = append(cmd.Args, ".") - - cmd.Env = os.Environ() - if len(scmd.Env) > 0 { - cmd.Env = append(cmd.Env, scmd.Env...) - } - - if scmd.CmdFunc != nil { - scmd.CmdFunc(cmd) - } - - var ( - err error - stdin io.WriteCloser - stdout io.ReadCloser - stderr io.ReadCloser - ) - - if scmd.Stdin != nil { - stdin, err = cmd.StdinPipe() - if err != nil { - return err - } - } - - if scmd.Stdout != nil { - stdout, err = cmd.StdoutPipe() - if err != nil { - return err - } - } - - if scmd.Stderr != nil { - stderr, err = cmd.StderrPipe() - if err != nil { - return err - } - } - - if err := cmd.Start(); err != nil { - if errors.Is(err, os.ErrNotExist) { - return ErrInvalidRepo - } - return err - } - - errg, _ := errgroup.WithContext(ctx) - - // stdin - if scmd.Stdin != nil { - errg.Go(func() error { - defer stdin.Close() // nolint: errcheck - _, err := io.Copy(stdin, scmd.Stdin) - return err - }) - } - - // stdout - if scmd.Stdout != nil { - errg.Go(func() error { - _, err := io.Copy(scmd.Stdout, stdout) - return err - }) - } - - // stderr - if scmd.Stderr != nil { - errg.Go(func() error { - _, erro := io.Copy(scmd.Stderr, stderr) - return erro - }) - } - - err = errors.Join(errg.Wait(), cmd.Wait()) - if err != nil && errors.Is(err, os.ErrNotExist) { - return ErrInvalidRepo - } else if err != nil { - return err - } - - return nil -} - -// ServiceCommand is used to run a git service command. -type ServiceCommand struct { - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - Dir string - Env []string - Args []string - - // Modifier functions - CmdFunc func(*exec.Cmd) -} - -// UploadPack runs the git upload-pack protocol against the provided repo. -func UploadPack(ctx context.Context, cmd ServiceCommand) error { - return gitServiceHandler(ctx, UploadPackService, cmd) -} - -// UploadArchive runs the git upload-archive protocol against the provided repo. -func UploadArchive(ctx context.Context, cmd ServiceCommand) error { - return gitServiceHandler(ctx, UploadArchiveService, cmd) -} - -// ReceivePack runs the git receive-pack protocol against the provided repo. -func ReceivePack(ctx context.Context, cmd ServiceCommand) error { - return gitServiceHandler(ctx, ReceivePackService, cmd) -} diff --git a/server/hooks/gen.go b/server/hooks/gen.go deleted file mode 100644 index ac23515da..000000000 --- a/server/hooks/gen.go +++ /dev/null @@ -1,143 +0,0 @@ -package hooks - -import ( - "bytes" - "context" - "flag" - "os" - "path/filepath" - "text/template" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/utils" -) - -// The names of git server-side hooks. -const ( - PreReceiveHook = "pre-receive" - UpdateHook = "update" - PostReceiveHook = "post-receive" - PostUpdateHook = "post-update" -) - -// GenerateHooks generates git server-side hooks for a repository. Currently, it supports the following hooks: -// - pre-receive -// - update -// - post-receive -// - post-update -// -// This function should be called by the backend when a repository is created. -// TODO: support context. -func GenerateHooks(_ context.Context, cfg *config.Config, repo string) error { - // TODO: support git hook tests. - if flag.Lookup("test.v") != nil { - log.WithPrefix("backend.hooks").Warn("refusing to set up hooks when in test") - return nil - } - repo = utils.SanitizeRepo(repo) + ".git" - hooksPath := filepath.Join(cfg.DataPath, "repos", repo, "hooks") - if err := os.MkdirAll(hooksPath, os.ModePerm); err != nil { - return err - } - - ex, err := os.Executable() - if err != nil { - return err - } - - // Convert to forward slashes for Windows. - ex = filepath.ToSlash(ex) - - for _, hook := range []string{ - PreReceiveHook, - UpdateHook, - PostReceiveHook, - PostUpdateHook, - } { - var data bytes.Buffer - var args string - - // Hooks script/directory path - hp := filepath.Join(hooksPath, hook) - - // Write the hooks primary script - if err := os.WriteFile(hp, []byte(hookTemplate), os.ModePerm); err != nil { - return err - } - - // Create ${hook}.d directory. - hp += ".d" - if err := os.MkdirAll(hp, os.ModePerm); err != nil { - return err - } - - switch hook { - case UpdateHook: - args = "$1 $2 $3" - case PostUpdateHook: - args = "$@" - } - - if err := hooksTmpl.Execute(&data, struct { - Executable string - Hook string - Args string - }{ - Executable: ex, - Hook: hook, - Args: args, - }); err != nil { - log.WithPrefix("hooks").Error("failed to execute hook template", "err", err) - continue - } - - // Write the soft-serve hook inside ${hook}.d directory. - hp = filepath.Join(hp, "soft-serve") - err = os.WriteFile(hp, data.Bytes(), os.ModePerm) //nolint:gosec - if err != nil { - log.WithPrefix("hooks").Error("failed to write hook", "err", err) - continue - } - } - - return nil -} - -const ( - // hookTemplate allows us to run multiple hooks from a directory. It should - // support every type of git hook, as it proxies both stdin and arguments. - hookTemplate = `#!/usr/bin/env bash -# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY -data=$(cat) -exitcodes="" -hookname=$(basename $0) -GIT_DIR=${GIT_DIR:-$(dirname $0)/..} -for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do - # Avoid running non-executable hooks - test -x "${hook}" && test -f "${hook}" || continue - - # Run the actual hook - echo "${data}" | "${hook}" "$@" - - # Store the exit code for later use - exitcodes="${exitcodes} $?" -done - -# Exit on the first non-zero exit code. -for i in ${exitcodes}; do - [ ${i} -eq 0 ] || exit ${i} -done -` -) - -// hooksTmpl is the soft-serve hook that will be run by the git hooks -// inside the hooks directory. -var hooksTmpl = template.Must(template.New("hooks").Parse(`#!/usr/bin/env bash -# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY -if [ -z "$SOFT_SERVE_REPO_NAME" ]; then - echo "Warning: SOFT_SERVE_REPO_NAME not defined. Skipping hooks." - exit 0 -fi -{{ .Executable }} hook {{ .Hook }} {{ .Args }} -`)) diff --git a/server/hooks/hooks.go b/server/hooks/hooks.go deleted file mode 100644 index 0278050ef..000000000 --- a/server/hooks/hooks.go +++ /dev/null @@ -1,21 +0,0 @@ -package hooks - -import ( - "context" - "io" -) - -// HookArg is an argument to a git hook. -type HookArg struct { - OldSha string - NewSha string - RefName string -} - -// Hooks provides an interface for git server-side hooks. -type Hooks interface { - PreReceive(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args []HookArg) - Update(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, arg HookArg) - PostReceive(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args []HookArg) - PostUpdate(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args ...string) -} diff --git a/server/jobs/jobs.go b/server/jobs/jobs.go deleted file mode 100644 index 505863ea0..000000000 --- a/server/jobs/jobs.go +++ /dev/null @@ -1,37 +0,0 @@ -package jobs - -import ( - "context" - "sync" -) - -// Job is a job that can be registered with the scheduler. -type Job struct { - ID int - Runner Runner -} - -// Runner is a job runner. -type Runner interface { - Spec(context.Context) string - Func(context.Context) func() -} - -var ( - mtx sync.Mutex - jobs = make(map[string]*Job, 0) -) - -// Register registers a job. -func Register(name string, runner Runner) { - mtx.Lock() - defer mtx.Unlock() - jobs[name] = &Job{Runner: runner} -} - -// List returns a map of registered jobs. -func List() map[string]*Job { - mtx.Lock() - defer mtx.Unlock() - return jobs -} diff --git a/server/jobs/mirror.go b/server/jobs/mirror.go deleted file mode 100644 index 4a8f08053..000000000 --- a/server/jobs/mirror.go +++ /dev/null @@ -1,114 +0,0 @@ -package jobs - -import ( - "context" - "fmt" - "path/filepath" - "runtime" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/lfs" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/charmbracelet/soft-serve/server/sync" -) - -func init() { - Register("mirror-pull", mirrorPull{}) -} - -type mirrorPull struct{} - -// Spec derives the spec used for pull mirrors and implements Runner. -func (m mirrorPull) Spec(ctx context.Context) string { - cfg := config.FromContext(ctx) - if cfg.Jobs.MirrorPull != "" { - return cfg.Jobs.MirrorPull - } - return "@every 10m" -} - -// Func runs the (pull) mirror job task and implements Runner. -func (m mirrorPull) Func(ctx context.Context) func() { - cfg := config.FromContext(ctx) - logger := log.FromContext(ctx).WithPrefix("jobs.mirror") - b := backend.FromContext(ctx) - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - return func() { - repos, err := b.Repositories(ctx) - if err != nil { - logger.Error("error getting repositories", "err", err) - return - } - - // Divide the work up among the number of CPUs. - wq := sync.NewWorkPool(ctx, runtime.GOMAXPROCS(0), - sync.WithWorkPoolLogger(logger.Errorf), - ) - - logger.Debug("updating mirror repos") - for _, repo := range repos { - if repo.IsMirror() { - r, err := repo.Open() - if err != nil { - logger.Error("error opening repository", "repo", repo.Name(), "err", err) - continue - } - - name := repo.Name() - wq.Add(name, func() { - repo := repo - cmd := git.NewCommand("remote", "update", "--prune").WithContext(ctx) - cmd.AddEnvs( - fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`, - filepath.Join(cfg.DataPath, "ssh", "known_hosts"), - cfg.SSH.ClientKeyPath, - ), - ) - - if _, err := cmd.RunInDir(r.Path); err != nil { - logger.Error("error running git remote update", "repo", name, "err", err) - } - - if cfg.LFS.Enabled { - rcfg, err := r.Config() - if err != nil { - logger.Error("error getting git config", "repo", name, "err", err) - return - } - - lfsEndpoint := rcfg.Section("lfs").Option("url") - if lfsEndpoint == "" { - // If there is no LFS url defined, means the repo - // doesn't use LFS and we can skip it. - return - } - - ep, err := lfs.NewEndpoint(lfsEndpoint) - if err != nil { - logger.Error("error creating LFS endpoint", "repo", name, "err", err) - return - } - - client := lfs.NewClient(ep) - if client == nil { - logger.Errorf("failed to create lfs client: unsupported endpoint %s", lfsEndpoint) - return - } - - if err := backend.StoreRepoMissingLFSObjects(ctx, repo, dbx, datastore, client); err != nil { - logger.Error("failed to store missing lfs objects", "err", err, "path", r.Path) - return - } - } - }) - } - } - - wq.Run() - } -} diff --git a/server/jwk/jwk.go b/server/jwk/jwk.go deleted file mode 100644 index 51ea86be8..000000000 --- a/server/jwk/jwk.go +++ /dev/null @@ -1,49 +0,0 @@ -package jwk - -import ( - "crypto" - "crypto/sha256" - "fmt" - - "github.com/charmbracelet/soft-serve/server/config" - "github.com/go-jose/go-jose/v3" - "github.com/golang-jwt/jwt/v5" -) - -// SigningMethod is a JSON Web Token signing method. It uses Ed25519 keys to -// sign and verify tokens. -var SigningMethod = &jwt.SigningMethodEd25519{} - -// Pair is a JSON Web Key pair. -type Pair struct { - privateKey crypto.PrivateKey - jwk jose.JSONWebKey -} - -// PrivateKey returns the private key. -func (p Pair) PrivateKey() crypto.PrivateKey { - return p.privateKey -} - -// JWK returns the JSON Web Key. -func (p Pair) JWK() jose.JSONWebKey { - return p.jwk -} - -// NewPair creates a new JSON Web Key pair. -func NewPair(cfg *config.Config) (Pair, error) { - kp, err := cfg.SSH.KeyPair() - if err != nil { - return Pair{}, err - } - - sum := sha256.Sum256(kp.RawPrivateKey()) - kid := fmt.Sprintf("%x", sum) - jwk := jose.JSONWebKey{ - Key: kp.CryptoPublicKey(), - KeyID: kid, - Algorithm: SigningMethod.Alg(), - } - - return Pair{privateKey: kp.PrivateKey(), jwk: jwk}, nil -} diff --git a/server/lfs/basic_transfer.go b/server/lfs/basic_transfer.go deleted file mode 100644 index 609197c10..000000000 --- a/server/lfs/basic_transfer.go +++ /dev/null @@ -1,124 +0,0 @@ -package lfs - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - - "github.com/charmbracelet/log" -) - -// BasicTransferAdapter implements the "basic" adapter -type BasicTransferAdapter struct { - client *http.Client -} - -// Name returns the name of the adapter -func (a *BasicTransferAdapter) Name() string { - return "basic" -} - -// Download reads the download location and downloads the data -func (a *BasicTransferAdapter) Download(ctx context.Context, _ Pointer, l *Link) (io.ReadCloser, error) { - resp, err := a.performRequest(ctx, "GET", l, nil, nil) - if err != nil { - return nil, err - } - return resp.Body, nil -} - -// Upload sends the content to the LFS server -func (a *BasicTransferAdapter) Upload(ctx context.Context, p Pointer, r io.Reader, l *Link) error { - res, err := a.performRequest(ctx, "PUT", l, r, func(req *http.Request) { - if len(req.Header.Get("Content-Type")) == 0 { - req.Header.Set("Content-Type", "application/octet-stream") - } - - if req.Header.Get("Transfer-Encoding") == "chunked" { - req.TransferEncoding = []string{"chunked"} - } - - req.ContentLength = p.Size - }) - if err != nil { - return err - } - return res.Body.Close() -} - -// Verify calls the verify handler on the LFS server -func (a *BasicTransferAdapter) Verify(ctx context.Context, p Pointer, l *Link) error { - logger := log.FromContext(ctx).WithPrefix("lfs") - b, err := json.Marshal(p) - if err != nil { - logger.Errorf("Error encoding json: %v", err) - return err - } - - res, err := a.performRequest(ctx, "POST", l, bytes.NewReader(b), func(req *http.Request) { - req.Header.Set("Content-Type", MediaType) - }) - if err != nil { - return err - } - return res.Body.Close() -} - -func (a *BasicTransferAdapter) performRequest(ctx context.Context, method string, l *Link, body io.Reader, callback func(*http.Request)) (*http.Response, error) { - logger := log.FromContext(ctx).WithPrefix("lfs") - logger.Debugf("Calling: %s %s", method, l.Href) - - req, err := http.NewRequestWithContext(ctx, method, l.Href, body) - if err != nil { - logger.Errorf("Error creating request: %v", err) - return nil, err - } - for key, value := range l.Header { - req.Header.Set(key, value) - } - req.Header.Set("Accept", MediaType) - - if callback != nil { - callback(req) - } - - res, err := a.client.Do(req) - if err != nil { - select { - case <-ctx.Done(): - return res, ctx.Err() - default: - } - logger.Errorf("Error while processing request: %v", err) - return res, err - } - - if res.StatusCode != http.StatusOK { - return res, handleErrorResponse(res) - } - - return res, nil -} - -func handleErrorResponse(resp *http.Response) error { - defer resp.Body.Close() // nolint: errcheck - - er, err := decodeResponseError(resp.Body) - if err != nil { - return fmt.Errorf("Request failed with status %s", resp.Status) - } - return errors.New(er.Message) -} - -func decodeResponseError(r io.Reader) (ErrorResponse, error) { - var er ErrorResponse - err := json.NewDecoder(r).Decode(&er) - if err != nil { - log.Error("Error decoding json: %v", err) - } - return er, err -} diff --git a/server/lfs/client.go b/server/lfs/client.go deleted file mode 100644 index 9cc9da0a1..000000000 --- a/server/lfs/client.go +++ /dev/null @@ -1,27 +0,0 @@ -package lfs - -import ( - "context" - "io" -) - -// DownloadCallback gets called for every requested LFS object to process its content -type DownloadCallback func(p Pointer, content io.ReadCloser, objectError error) error - -// UploadCallback gets called for every requested LFS object to provide its content -type UploadCallback func(p Pointer, objectError error) (io.ReadCloser, error) - -// Client is a Git LFS client to communicate with a LFS source API. -type Client interface { - Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error - Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error -} - -// NewClient returns a new Git LFS client. -func NewClient(e Endpoint) Client { - if e.Scheme == "http" || e.Scheme == "https" { - return newHTTPClient(e) - } - // TODO: support ssh client - return nil -} diff --git a/server/lfs/common.go b/server/lfs/common.go deleted file mode 100644 index aa98c6527..000000000 --- a/server/lfs/common.go +++ /dev/null @@ -1,163 +0,0 @@ -package lfs - -import ( - "time" -) - -const ( - // MediaType contains the media type for LFS server requests. - MediaType = "application/vnd.git-lfs+json" - - // OperationDownload is the operation name for a download request. - OperationDownload = "download" - - // OperationUpload is the operation name for an upload request. - OperationUpload = "upload" - - // ActionDownload is the action name for a download request. - ActionDownload = OperationDownload - - // ActionUpload is the action name for an upload request. - ActionUpload = OperationUpload - - // ActionVerify is the action name for a verify request. - ActionVerify = "verify" - - // DefaultLocksLimit is the default number of locks to return in a single - // request. - DefaultLocksLimit = 20 -) - -// Pointer contains LFS pointer data -type Pointer struct { - Oid string `json:"oid"` - Size int64 `json:"size"` -} - -// PointerBlob associates a Git blob with a Pointer. -type PointerBlob struct { - Hash string - Pointer -} - -// ErrorResponse describes the error to the client. -type ErrorResponse struct { - Message string `json:"message,omitempty"` - DocumentationURL string `json:"documentation_url,omitempty"` - RequestID string `json:"request_id,omitempty"` -} - -// BatchResponse contains multiple object metadata Representation structures -// for use with the batch API. -// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#successful-responses -type BatchResponse struct { - Transfer string `json:"transfer,omitempty"` - Objects []*ObjectResponse `json:"objects"` - HashAlgo string `json:"hash_algo,omitempty"` -} - -// ObjectResponse is object metadata as seen by clients of the LFS server. -type ObjectResponse struct { - Pointer - Actions map[string]*Link `json:"actions,omitempty"` - Error *ObjectError `json:"error,omitempty"` -} - -// Link provides a structure with information about how to access a object. -type Link struct { - Href string `json:"href"` - Header map[string]string `json:"header,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - ExpiresIn *time.Duration `json:"expires_in,omitempty"` -} - -// ObjectError defines the JSON structure returned to the client in case of an error. -type ObjectError struct { - Code int `json:"code"` - Message string `json:"message"` -} - -// BatchRequest contains multiple requests processed in one batch operation. -// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#requests -type BatchRequest struct { - Operation string `json:"operation"` - Transfers []string `json:"transfers,omitempty"` - Ref *Reference `json:"ref,omitempty"` - Objects []Pointer `json:"objects"` - HashAlgo string `json:"hash_algo,omitempty"` -} - -// Reference contains a git reference. -// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#ref-property -type Reference struct { - Name string `json:"name"` -} - -// AuthenticateResponse is the git-lfs-authenticate JSON response object. -type AuthenticateResponse struct { - Header map[string]string `json:"header"` - Href string `json:"href"` - ExpiresIn time.Duration `json:"expires_in"` - ExpiresAt time.Time `json:"expires_at"` -} - -// LockCreateRequest contains the request data for creating a lock. -// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md -// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-create-request-schema.json -type LockCreateRequest struct { - Path string `json:"path"` - Ref Reference `json:"ref,omitempty"` -} - -// Owner contains the owner data for a lock. -type Owner struct { - Name string `json:"name"` -} - -// Lock contains the response data for creating a lock. -// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md -// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-create-response-schema.json -type Lock struct { - ID string `json:"id"` - Path string `json:"path"` - LockedAt time.Time `json:"locked_at"` - Owner Owner `json:"owner,omitempty"` -} - -// LockDeleteRequest contains the request data for deleting a lock. -// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md -// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-delete-request-schema.json -type LockDeleteRequest struct { - Force bool `json:"force,omitempty"` - Ref Reference `json:"ref,omitempty"` -} - -// LockListResponse contains the response data for listing locks. -// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md -// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-list-response-schema.json -type LockListResponse struct { - Locks []Lock `json:"locks"` - NextCursor string `json:"next_cursor,omitempty"` -} - -// LockVerifyRequest contains the request data for verifying a lock. -type LockVerifyRequest struct { - Ref Reference `json:"ref,omitempty"` - Cursor string `json:"cursor,omitempty"` - Limit int `json:"limit,omitempty"` -} - -// LockVerifyResponse contains the response data for verifying a lock. -// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md -// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-verify-response-schema.json -type LockVerifyResponse struct { - Ours []Lock `json:"ours"` - Theirs []Lock `json:"theirs"` - NextCursor string `json:"next_cursor,omitempty"` -} - -// LockResponse contains the response data for a lock. -type LockResponse struct { - Lock Lock `json:"lock"` - ErrorResponse -} diff --git a/server/lfs/endpoint.go b/server/lfs/endpoint.go deleted file mode 100644 index 53e89e170..000000000 --- a/server/lfs/endpoint.go +++ /dev/null @@ -1,70 +0,0 @@ -package lfs - -import ( - "fmt" - "net/url" - "strings" -) - -// Endpoint is a Git LFS endpoint. -type Endpoint = *url.URL - -// NewEndpoint returns a new Git LFS endpoint. -func NewEndpoint(rawurl string) (Endpoint, error) { - u, err := url.Parse(rawurl) - if err != nil { - e, err := endpointFromBareSSH(rawurl) - if err != nil { - return nil, err - } - u = e - } - - u.Path = strings.TrimSuffix(u.Path, "/") - - switch u.Scheme { - case "git": - // Use https for git:// URLs and strip the port if it exists. - u.Scheme = "https" - if u.Port() != "" { - u.Host = u.Hostname() - } - fallthrough - case "http", "https": - if strings.HasSuffix(u.Path, ".git") { - u.Path += "/info/lfs" - } else { - u.Path += ".git/info/lfs" - } - case "ssh", "git+ssh", "ssh+git": - default: - return nil, fmt.Errorf("unknown url: %s", rawurl) - } - - return u, nil -} - -// endpointFromBareSSH creates a new endpoint from a bare ssh repo. -// -// user@host.com:path/to/repo.git or -// [user@host.com:port]:path/to/repo.git -func endpointFromBareSSH(rawurl string) (*url.URL, error) { - parts := strings.Split(rawurl, ":") - partsLen := len(parts) - if partsLen < 2 { - return url.Parse(rawurl) - } - - // Treat presence of ':' as a bare URL - var newPath string - if len(parts) > 2 { // port included; really should only ever be 3 parts - // Correctly handle [host:port]:path URLs - parts[0] = strings.TrimPrefix(parts[0], "[") - parts[1] = strings.TrimSuffix(parts[1], "]") - newPath = fmt.Sprintf("%v:%v", parts[0], strings.Join(parts[1:], "/")) - } else { - newPath = strings.Join(parts, "/") - } - newrawurl := fmt.Sprintf("ssh://%v", newPath) - return url.Parse(newrawurl) -} diff --git a/server/lfs/http_client.go b/server/lfs/http_client.go deleted file mode 100644 index a8b55031f..000000000 --- a/server/lfs/http_client.go +++ /dev/null @@ -1,196 +0,0 @@ -package lfs - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - - "github.com/charmbracelet/log" -) - -// httpClient is a Git LFS client to communicate with a LFS source API. -type httpClient struct { - client *http.Client - endpoint Endpoint - transfers map[string]TransferAdapter -} - -var _ Client = (*httpClient)(nil) - -// newHTTPClient returns a new Git LFS client. -func newHTTPClient(endpoint Endpoint) *httpClient { - return &httpClient{ - client: http.DefaultClient, - endpoint: endpoint, - transfers: map[string]TransferAdapter{ - TransferBasic: &BasicTransferAdapter{http.DefaultClient}, - }, - } -} - -// Download implements Client. -func (c *httpClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error { - return c.performOperation(ctx, objects, callback, nil) -} - -// Upload implements Client. -func (c *httpClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error { - return c.performOperation(ctx, objects, nil, callback) -} - -func (c *httpClient) transferNames() []string { - names := make([]string, len(c.transfers)) - i := 0 - for name := range c.transfers { - names[i] = name - i++ - } - return names -} - -// batch performs a batch request to the LFS server. -func (c *httpClient) batch(ctx context.Context, operation string, objects []Pointer) (*BatchResponse, error) { - logger := log.FromContext(ctx).WithPrefix("lfs") - url := fmt.Sprintf("%s/objects/batch", c.endpoint.String()) - - // TODO: support ref - request := &BatchRequest{operation, c.transferNames(), nil, objects, HashAlgorithmSHA256} - - payload := new(bytes.Buffer) - err := json.NewEncoder(payload).Encode(request) - if err != nil { - logger.Errorf("Error encoding json: %v", err) - return nil, err - } - - logger.Debugf("Calling: %s", url) - - req, err := http.NewRequestWithContext(ctx, "POST", url, payload) - if err != nil { - logger.Errorf("Error creating request: %v", err) - return nil, err - } - req.Header.Set("Content-type", MediaType) - req.Header.Set("Accept", MediaType) - - res, err := c.client.Do(req) - if err != nil { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - logger.Errorf("Error while processing request: %v", err) - return nil, err - } - defer res.Body.Close() // nolint: errcheck - - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("Unexpected server response: %s", res.Status) - } - - var response BatchResponse - err = json.NewDecoder(res.Body).Decode(&response) - if err != nil { - logger.Errorf("Error decoding json: %v", err) - return nil, err - } - - if len(response.Transfer) == 0 { - response.Transfer = TransferBasic - } - - return &response, nil -} - -func (c *httpClient) performOperation(ctx context.Context, objects []Pointer, dc DownloadCallback, uc UploadCallback) error { - logger := log.FromContext(ctx).WithPrefix("lfs") - if len(objects) == 0 { - return nil - } - - operation := OperationDownload - if uc != nil { - operation = OperationUpload - } - - result, err := c.batch(ctx, operation, objects) - if err != nil { - return err - } - - transferAdapter, ok := c.transfers[result.Transfer] - if !ok { - return fmt.Errorf("TransferAdapter not found: %s", result.Transfer) - } - - for _, object := range result.Objects { - if object.Error != nil { - objectError := errors.New(object.Error.Message) - logger.Debugf("Error on object %v: %v", object.Pointer, objectError) - if uc != nil { - if _, err := uc(object.Pointer, objectError); err != nil { - return err - } - } else { - if err := dc(object.Pointer, nil, objectError); err != nil { - return err - } - } - continue - } - - if uc != nil { - if len(object.Actions) == 0 { - logger.Debugf("%v already present on server", object.Pointer) - continue - } - - link, ok := object.Actions[ActionUpload] - if !ok { - logger.Debugf("%+v", object) - return errors.New("Missing action 'upload'") - } - - content, err := uc(object.Pointer, nil) - if err != nil { - return err - } - - err = transferAdapter.Upload(ctx, object.Pointer, content, link) - - content.Close() // nolint: errcheck - - if err != nil { - return err - } - - link, ok = object.Actions[ActionVerify] - if ok { - if err := transferAdapter.Verify(ctx, object.Pointer, link); err != nil { - return err - } - } - } else { - link, ok := object.Actions[ActionDownload] - if !ok { - logger.Debugf("%+v", object) - return errors.New("Missing action 'download'") - } - - content, err := transferAdapter.Download(ctx, object.Pointer, link) - if err != nil { - return err - } - - if err := dc(object.Pointer, content, nil); err != nil { - return err - } - } - } - - return nil -} diff --git a/server/lfs/pointer.go b/server/lfs/pointer.go deleted file mode 100644 index b38d04ce5..000000000 --- a/server/lfs/pointer.go +++ /dev/null @@ -1,122 +0,0 @@ -package lfs - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "path" - "regexp" - "strconv" - "strings" -) - -const ( - blobSizeCutoff = 1024 - - // HashAlgorithmSHA256 is the hash algorithm used for Git LFS. - HashAlgorithmSHA256 = "sha256" - - // MetaFileIdentifier is the string appearing at the first line of LFS pointer files. - // https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md - MetaFileIdentifier = "version https://git-lfs.github.com/spec/v1" - - // MetaFileOidPrefix appears in LFS pointer files on a line before the sha256 hash. - MetaFileOidPrefix = "oid " + HashAlgorithmSHA256 + ":" -) - -var ( - // ErrMissingPrefix occurs if the content lacks the LFS prefix - ErrMissingPrefix = errors.New("Content lacks the LFS prefix") - - // ErrInvalidStructure occurs if the content has an invalid structure - ErrInvalidStructure = errors.New("Content has an invalid structure") - - // ErrInvalidOIDFormat occurs if the oid has an invalid format - ErrInvalidOIDFormat = errors.New("OID has an invalid format") -) - -// ReadPointer tries to read LFS pointer data from the reader -func ReadPointer(reader io.Reader) (Pointer, error) { - buf := make([]byte, blobSizeCutoff) - n, err := io.ReadFull(reader, buf) - if err != nil && err != io.ErrUnexpectedEOF { - return Pointer{}, err - } - buf = buf[:n] - - return ReadPointerFromBuffer(buf) -} - -var oidPattern = regexp.MustCompile(`^[a-f\d]{64}$`) - -// ReadPointerFromBuffer will return a pointer if the provided byte slice is a pointer file or an error otherwise. -func ReadPointerFromBuffer(buf []byte) (Pointer, error) { - var p Pointer - - headString := string(buf) - if !strings.HasPrefix(headString, MetaFileIdentifier) { - return p, ErrMissingPrefix - } - - splitLines := strings.Split(headString, "\n") - if len(splitLines) < 3 { - return p, ErrInvalidStructure - } - - oid := strings.TrimPrefix(splitLines[1], MetaFileOidPrefix) - if len(oid) != 64 || !oidPattern.MatchString(oid) { - return p, ErrInvalidOIDFormat - } - size, err := strconv.ParseInt(strings.TrimPrefix(splitLines[2], "size "), 10, 64) - if err != nil { - return p, err - } - - p.Oid = oid - p.Size = size - - return p, nil -} - -// IsValid checks if the pointer has a valid structure. -// It doesn't check if the pointed-to-content exists. -func (p Pointer) IsValid() bool { - if len(p.Oid) != 64 { - return false - } - if !oidPattern.MatchString(p.Oid) { - return false - } - if p.Size < 0 { - return false - } - return true -} - -// String returns the string representation of the pointer -// https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md#the-pointer -func (p Pointer) String() string { - return fmt.Sprintf("%s\n%s%s\nsize %d\n", MetaFileIdentifier, MetaFileOidPrefix, p.Oid, p.Size) -} - -// RelativePath returns the relative storage path of the pointer -func (p Pointer) RelativePath() string { - if len(p.Oid) < 5 { - return p.Oid - } - - return path.Join(p.Oid[0:2], p.Oid[2:4], p.Oid[4:]) -} - -// GeneratePointer generates a pointer for arbitrary content -func GeneratePointer(content io.Reader) (Pointer, error) { - h := sha256.New() - c, err := io.Copy(h, content) - if err != nil { - return Pointer{}, err - } - sum := h.Sum(nil) - return Pointer{Oid: hex.EncodeToString(sum), Size: c}, nil -} diff --git a/server/lfs/pointer_test.go b/server/lfs/pointer_test.go deleted file mode 100644 index df2f2daa2..000000000 --- a/server/lfs/pointer_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package lfs - -import ( - "errors" - "strconv" - "strings" - "testing" -) - -func TestReadPointer(t *testing.T) { - cases := []struct { - name string - content string - want Pointer - wantErr error - wantErrp interface{} - }{ - { - name: "valid pointer", - content: `version https://git-lfs.github.com/spec/v1 -oid sha256:1234567890123456789012345678901234567890123456789012345678901234 -size 1234 -`, - want: Pointer{ - Oid: "1234567890123456789012345678901234567890123456789012345678901234", - Size: 1234, - }, - }, - { - name: "invalid prefix", - content: `version https://foobar/spec/v2 -oid sha256:1234567890123456789012345678901234567890123456789012345678901234 -size 1234 -`, - wantErr: ErrMissingPrefix, - }, - { - name: "invalid oid", - content: `version https://git-lfs.github.com/spec/v1 -oid sha256:&2345a78$012345678901234567890123456789012345678901234567890123 -size 1234 -`, - wantErr: ErrInvalidOIDFormat, - }, - { - name: "invalid size", - content: `version https://git-lfs.github.com/spec/v1 -oid sha256:1234567890123456789012345678901234567890123456789012345678901234 -size abc -`, - wantErrp: &strconv.NumError{}, - }, - { - name: "invalid structure", - content: `version https://git-lfs.github.com/spec/v1 -`, - wantErr: ErrInvalidStructure, - }, - { - name: "empty pointer", - wantErr: ErrMissingPrefix, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - p, err := ReadPointerFromBuffer([]byte(tc.content)) - if err != tc.wantErr && !errors.As(err, &tc.wantErrp) { - t.Errorf("ReadPointerFromBuffer() error = %v(%T), wantErr %v(%T)", err, err, tc.wantErr, tc.wantErr) - return - } - if err != nil { - return - } - - if err == nil { - if !p.IsValid() { - t.Errorf("Expected a valid pointer") - return - } - if p.Oid != strings.ReplaceAll(p.RelativePath(), "/", "") { - t.Errorf("Expected oid to be the relative path without slashes") - return - } - } - - if p.Oid != tc.want.Oid { - t.Errorf("ReadPointerFromBuffer() oid = %v, want %v", p.Oid, tc.want.Oid) - } - if p.Size != tc.want.Size { - t.Errorf("ReadPointerFromBuffer() size = %v, want %v", p.Size, tc.want.Size) - } - }) - } -} diff --git a/server/lfs/scanner.go b/server/lfs/scanner.go deleted file mode 100644 index da155203a..000000000 --- a/server/lfs/scanner.go +++ /dev/null @@ -1,216 +0,0 @@ -package lfs - -import ( - "bufio" - "bytes" - "context" - "fmt" - "io" - "strconv" - "strings" - "sync" - - "github.com/charmbracelet/soft-serve/git" - gitm "github.com/gogs/git-module" -) - -// SearchPointerBlobs scans the whole repository for LFS pointer files -func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob, errChan chan<- error) { - basePath := repo.Path - - catFileCheckReader, catFileCheckWriter := io.Pipe() - shasToBatchReader, shasToBatchWriter := io.Pipe() - catFileBatchReader, catFileBatchWriter := io.Pipe() - - wg := sync.WaitGroup{} - wg.Add(6) - - // Create the go-routines in reverse order. - - // 4. Take the output of cat-file --batch and check if each file in turn - // to see if they're pointers to files in the LFS store - go createPointerResultsFromCatFileBatch(ctx, catFileBatchReader, &wg, pointerChan) - - // 3. Take the shas of the blobs and batch read them - go catFileBatch(ctx, shasToBatchReader, catFileBatchWriter, &wg, basePath) - - // 2. From the provided objects restrict to blobs <=1k - go blobsLessThan1024FromCatFileBatchCheck(catFileCheckReader, shasToBatchWriter, &wg) - - // 1. Run batch-check on all objects in the repository - revListReader, revListWriter := io.Pipe() - shasToCheckReader, shasToCheckWriter := io.Pipe() - go catFileBatchCheck(ctx, shasToCheckReader, catFileCheckWriter, &wg, basePath) - go blobsFromRevListObjects(revListReader, shasToCheckWriter, &wg) - go revListAllObjects(ctx, revListWriter, &wg, basePath, errChan) - wg.Wait() - - close(pointerChan) - close(errChan) -} - -func createPointerResultsFromCatFileBatch(ctx context.Context, catFileBatchReader *io.PipeReader, wg *sync.WaitGroup, pointerChan chan<- PointerBlob) { - defer wg.Done() - defer catFileBatchReader.Close() // nolint: errcheck - - bufferedReader := bufio.NewReader(catFileBatchReader) - buf := make([]byte, 1025) - -loop: - for { - select { - case <-ctx.Done(): - break loop - default: - } - - // File descriptor line: sha - sha, err := bufferedReader.ReadString(' ') - if err != nil { - _ = catFileBatchReader.CloseWithError(err) - break - } - sha = strings.TrimSpace(sha) - // Throw away the blob - if _, err := bufferedReader.ReadString(' '); err != nil { - _ = catFileBatchReader.CloseWithError(err) - break - } - sizeStr, err := bufferedReader.ReadString('\n') - if err != nil { - _ = catFileBatchReader.CloseWithError(err) - break - } - size, err := strconv.Atoi(sizeStr[:len(sizeStr)-1]) - if err != nil { - _ = catFileBatchReader.CloseWithError(err) - break - } - pointerBuf := buf[:size+1] - if _, err := io.ReadFull(bufferedReader, pointerBuf); err != nil { - _ = catFileBatchReader.CloseWithError(err) - break - } - pointerBuf = pointerBuf[:size] - // Now we need to check if the pointerBuf is an LFS pointer - pointer, _ := ReadPointerFromBuffer(pointerBuf) - if !pointer.IsValid() { - continue - } - - pointerChan <- PointerBlob{Hash: sha, Pointer: pointer} - } -} - -func catFileBatch(ctx context.Context, shasToBatchReader *io.PipeReader, catFileBatchWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string) { - defer wg.Done() - defer shasToBatchReader.Close() // nolint: errcheck - defer catFileBatchWriter.Close() // nolint: errcheck - - stderr := new(bytes.Buffer) - var errbuf strings.Builder - if err := gitm.NewCommandWithContext(ctx, "cat-file", "--batch"). - WithTimeout(-1). - RunInDirWithOptions(basePath, gitm.RunInDirOptions{ - Stdout: catFileBatchWriter, - Stdin: shasToBatchReader, - Stderr: stderr, - }); err != nil { - _ = shasToBatchReader.CloseWithError(fmt.Errorf("git rev-list [%s]: %w - %s", basePath, err, errbuf.String())) - } -} - -func blobsLessThan1024FromCatFileBatchCheck(catFileCheckReader *io.PipeReader, shasToBatchWriter *io.PipeWriter, wg *sync.WaitGroup) { - defer wg.Done() - defer catFileCheckReader.Close() // nolint: errcheck - scanner := bufio.NewScanner(catFileCheckReader) - defer func() { - _ = shasToBatchWriter.CloseWithError(scanner.Err()) - }() - for scanner.Scan() { - line := scanner.Text() - if len(line) == 0 { - continue - } - fields := strings.Split(line, " ") - if len(fields) < 3 || fields[1] != "blob" { - continue - } - size, _ := strconv.Atoi(fields[2]) - if size > 1024 { - continue - } - toWrite := []byte(fields[0] + "\n") - for len(toWrite) > 0 { - n, err := shasToBatchWriter.Write(toWrite) - if err != nil { - _ = catFileCheckReader.CloseWithError(err) - break - } - toWrite = toWrite[n:] - } - } -} - -func catFileBatchCheck(ctx context.Context, shasToCheckReader *io.PipeReader, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string) { - defer wg.Done() - defer shasToCheckReader.Close() // nolint: errcheck - defer catFileCheckWriter.Close() // nolint: errcheck - - stderr := new(bytes.Buffer) - var errbuf strings.Builder - if err := gitm.NewCommandWithContext(ctx, "cat-file", "--batch-check"). - WithTimeout(-1). - RunInDirWithOptions(basePath, gitm.RunInDirOptions{ - Stdout: catFileCheckWriter, - Stdin: shasToCheckReader, - Stderr: stderr, - }); err != nil { - _ = shasToCheckReader.CloseWithError(fmt.Errorf("git rev-list [%s]: %w - %s", basePath, err, errbuf.String())) - } -} - -func blobsFromRevListObjects(revListReader *io.PipeReader, shasToCheckWriter *io.PipeWriter, wg *sync.WaitGroup) { - defer wg.Done() - defer revListReader.Close() // nolint: errcheck - scanner := bufio.NewScanner(revListReader) - defer func() { - _ = shasToCheckWriter.CloseWithError(scanner.Err()) - }() - - for scanner.Scan() { - line := scanner.Text() - if len(line) == 0 { - continue - } - fields := strings.Split(line, " ") - if len(fields) < 2 || len(fields[1]) == 0 { - continue - } - toWrite := []byte(fields[0] + "\n") - for len(toWrite) > 0 { - n, err := shasToCheckWriter.Write(toWrite) - if err != nil { - _ = revListReader.CloseWithError(err) - break - } - toWrite = toWrite[n:] - } - } -} - -func revListAllObjects(ctx context.Context, revListWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string, errChan chan<- error) { - defer wg.Done() - defer revListWriter.Close() // nolint: errcheck - - stderr := new(bytes.Buffer) - var errbuf strings.Builder - if err := gitm.NewCommandWithContext(ctx, "rev-list", "--objects", "--all"). - WithTimeout(-1). - RunInDirWithOptions(basePath, gitm.RunInDirOptions{ - Stdout: revListWriter, - Stderr: stderr, - }); err != nil { - errChan <- fmt.Errorf("git rev-list [%s]: %w - %s", basePath, err, errbuf.String()) - } -} diff --git a/server/lfs/ssh_client.go b/server/lfs/ssh_client.go deleted file mode 100644 index ba3e24710..000000000 --- a/server/lfs/ssh_client.go +++ /dev/null @@ -1,3 +0,0 @@ -package lfs - -// TODO: implement Git LFS SSH client. diff --git a/server/lfs/transfer.go b/server/lfs/transfer.go deleted file mode 100644 index 478568836..000000000 --- a/server/lfs/transfer.go +++ /dev/null @@ -1,17 +0,0 @@ -package lfs - -import ( - "context" - "io" -) - -// TransferBasic is the name of the Git LFS basic transfer protocol. -const TransferBasic = "basic" - -// TransferAdapter represents an adapter for downloading/uploading LFS objects -type TransferAdapter interface { - Name() string - Download(ctx context.Context, p Pointer, l *Link) (io.ReadCloser, error) - Upload(ctx context.Context, p Pointer, r io.Reader, l *Link) error - Verify(ctx context.Context, p Pointer, l *Link) error -} diff --git a/server/log/log.go b/server/log/log.go deleted file mode 100644 index 3162d8768..000000000 --- a/server/log/log.go +++ /dev/null @@ -1,48 +0,0 @@ -package log - -import ( - "os" - "strings" - "time" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/config" -) - -// NewLogger returns a new logger with default settings. -func NewLogger(cfg *config.Config) (*log.Logger, *os.File, error) { - logger := log.NewWithOptions(os.Stderr, log.Options{ - ReportTimestamp: true, - TimeFormat: time.DateOnly, - }) - - switch { - case config.IsVerbose(): - logger.SetReportCaller(true) - fallthrough - case config.IsDebug(): - logger.SetLevel(log.DebugLevel) - } - - logger.SetTimeFormat(cfg.Log.TimeFormat) - - switch strings.ToLower(cfg.Log.Format) { - case "json": - logger.SetFormatter(log.JSONFormatter) - case "logfmt": - logger.SetFormatter(log.LogfmtFormatter) - case "text": - logger.SetFormatter(log.TextFormatter) - } - - var f *os.File - if cfg.Log.Path != "" { - f, err := os.OpenFile(cfg.Log.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return nil, nil, err - } - logger.SetOutput(f) - } - - return logger, f, nil -} diff --git a/server/proto/access_token.go b/server/proto/access_token.go deleted file mode 100644 index 2876c3613..000000000 --- a/server/proto/access_token.go +++ /dev/null @@ -1,13 +0,0 @@ -package proto - -import "time" - -// AccessToken represents an access token. -type AccessToken struct { - ID int64 - Name string - UserID int64 - TokenHash string - ExpiresAt time.Time - CreatedAt time.Time -} diff --git a/server/proto/context.go b/server/proto/context.go deleted file mode 100644 index cd022dcac..000000000 --- a/server/proto/context.go +++ /dev/null @@ -1,35 +0,0 @@ -package proto - -import "context" - -// ContextKeyRepository is the context key for the repository. -var ContextKeyRepository = &struct{ string }{"repository"} - -// ContextKeyUser is the context key for the user. -var ContextKeyUser = &struct{ string }{"user"} - -// RepositoryFromContext returns the repository from the context. -func RepositoryFromContext(ctx context.Context) Repository { - if r, ok := ctx.Value(ContextKeyRepository).(Repository); ok { - return r - } - return nil -} - -// UserFromContext returns the user from the context. -func UserFromContext(ctx context.Context) User { - if u, ok := ctx.Value(ContextKeyUser).(User); ok { - return u - } - return nil -} - -// WithRepositoryContext returns a new context with the repository. -func WithRepositoryContext(ctx context.Context, r Repository) context.Context { - return context.WithValue(ctx, ContextKeyRepository, r) -} - -// WithUserContext returns a new context with the user. -func WithUserContext(ctx context.Context, u User) context.Context { - return context.WithValue(ctx, ContextKeyUser, u) -} diff --git a/server/proto/errors.go b/server/proto/errors.go deleted file mode 100644 index cc48b6f78..000000000 --- a/server/proto/errors.go +++ /dev/null @@ -1,24 +0,0 @@ -package proto - -import ( - "errors" -) - -var ( - // ErrUnauthorized is returned when the user is not authorized to perform action. - ErrUnauthorized = errors.New("unauthorized") - // ErrFileNotFound is returned when the file is not found. - ErrFileNotFound = errors.New("file not found") - // ErrRepoNotFound is returned when a repository is not found. - ErrRepoNotFound = errors.New("repository not found") - // ErrRepoExist is returned when a repository already exists. - ErrRepoExist = errors.New("repository already exists") - // ErrUserNotFound is returned when a user is not found. - ErrUserNotFound = errors.New("user not found") - // ErrTokenNotFound is returned when a token is not found. - ErrTokenNotFound = errors.New("token not found") - // ErrTokenExpired is returned when a token is expired. - ErrTokenExpired = errors.New("token expired") - // ErrCollaboratorNotFound is returned when a collaborator is not found. - ErrCollaboratorNotFound = errors.New("collaborator not found") -) diff --git a/server/proto/repo.go b/server/proto/repo.go deleted file mode 100644 index a4b80db98..000000000 --- a/server/proto/repo.go +++ /dev/null @@ -1,61 +0,0 @@ -package proto - -import ( - "time" - - "github.com/charmbracelet/soft-serve/git" -) - -// Repository is a Git repository interface. -type Repository interface { - // ID returns the repository's ID. - ID() int64 - // Name returns the repository's name. - Name() string - // ProjectName returns the repository's project name. - ProjectName() string - // Description returns the repository's description. - Description() string - // IsPrivate returns whether the repository is private. - IsPrivate() bool - // IsMirror returns whether the repository is a mirror. - IsMirror() bool - // IsHidden returns whether the repository is hidden. - IsHidden() bool - // UserID returns the ID of the user who owns the repository. - // It returns 0 if the repository is not owned by a user. - UserID() int64 - // CreatedAt returns the time the repository was created. - CreatedAt() time.Time - // UpdatedAt returns the time the repository was last updated. - // If the repository has never been updated, it returns the time it was created. - UpdatedAt() time.Time - // Open returns the underlying git.Repository. - Open() (*git.Repository, error) -} - -// RepositoryOptions are options for creating a new repository. -type RepositoryOptions struct { - Private bool - Description string - ProjectName string - Mirror bool - Hidden bool - LFS bool - LFSEndpoint string -} - -// RepositoryDefaultBranch returns the default branch of a repository. -func RepositoryDefaultBranch(repo Repository) (string, error) { - r, err := repo.Open() - if err != nil { - return "", err - } - - ref, err := r.HEAD() - if err != nil { - return "", err - } - - return ref.Name().Short(), nil -} diff --git a/server/proto/user.go b/server/proto/user.go deleted file mode 100644 index 7b334122d..000000000 --- a/server/proto/user.go +++ /dev/null @@ -1,25 +0,0 @@ -package proto - -import "golang.org/x/crypto/ssh" - -// User is an interface representing a user. -type User interface { - // ID returns the user's ID. - ID() int64 - // Username returns the user's username. - Username() string - // IsAdmin returns whether the user is an admin. - IsAdmin() bool - // PublicKeys returns the user's public keys. - PublicKeys() []ssh.PublicKey - // Password returns the user's password hash. - Password() string -} - -// UserOptions are options for creating a user. -type UserOptions struct { - // Admin is whether the user is an admin. - Admin bool - // PublicKeys are the user's public keys. - PublicKeys []ssh.PublicKey -} diff --git a/server/ssh/cmd/blob.go b/server/ssh/cmd/blob.go deleted file mode 100644 index 06034d94a..000000000 --- a/server/ssh/cmd/blob.go +++ /dev/null @@ -1,108 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/charmbracelet/soft-serve/server/ui/styles" - "github.com/spf13/cobra" -) - -// blobCommand returns a command that prints the contents of a file. -func blobCommand() *cobra.Command { - var linenumber bool - var color bool - var raw bool - - styles := styles.DefaultStyles() - cmd := &cobra.Command{ - Use: "blob REPOSITORY [REFERENCE] [PATH]", - Aliases: []string{"cat", "show"}, - Short: "Print out the contents of file at path", - Args: cobra.RangeArgs(1, 3), - PersistentPreRunE: checkIfReadable, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - rn := args[0] - ref := "" - fp := "" - switch len(args) { - case 2: - fp = args[1] - case 3: - ref = args[1] - fp = args[2] - } - - repo, err := be.Repository(ctx, rn) - if err != nil { - return err - } - - r, err := repo.Open() - if err != nil { - return err - } - - if ref == "" { - head, err := r.HEAD() - if err != nil { - return err - } - ref = head.ID - } - - tree, err := r.LsTree(ref) - if err != nil { - return err - } - - te, err := tree.TreeEntry(fp) - if err != nil { - return err - } - - if te.Type() != "blob" { - return git.ErrFileNotFound - } - - bts, err := te.Contents() - if err != nil { - return err - } - - c := string(bts) - isBin, _ := te.File().IsBinary() - if isBin { - if raw { - cmd.Println(c) - } else { - return fmt.Errorf("binary file: use --raw to print") - } - } else { - if color { - c, err = common.FormatHighlight(fp, c) - if err != nil { - return err - } - } - - if linenumber { - c, _ = common.FormatLineNumber(styles, c, color) - } - - cmd.Println(c) - } - return nil - }, - } - - cmd.Flags().BoolVarP(&raw, "raw", "r", false, "Print raw contents") - cmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers") - cmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output") - - return cmd -} diff --git a/server/ssh/cmd/branch.go b/server/ssh/cmd/branch.go deleted file mode 100644 index 051bc9c16..000000000 --- a/server/ssh/cmd/branch.go +++ /dev/null @@ -1,208 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/webhook" - gitm "github.com/gogs/git-module" - "github.com/spf13/cobra" -) - -func branchCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "branch", - Short: "Manage repository branches", - } - - cmd.AddCommand( - branchListCommand(), - branchDefaultCommand(), - branchDeleteCommand(), - ) - - return cmd -} - -func branchListCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "list REPOSITORY", - Short: "List repository branches", - Args: cobra.ExactArgs(1), - PersistentPreRunE: checkIfReadable, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - rn := strings.TrimSuffix(args[0], ".git") - rr, err := be.Repository(ctx, rn) - if err != nil { - return err - } - - r, err := rr.Open() - if err != nil { - return err - } - - branches, _ := r.Branches() - for _, b := range branches { - cmd.Println(b) - } - - return nil - }, - } - - return cmd -} - -func branchDefaultCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "default REPOSITORY [BRANCH]", - Short: "Set or get the default branch", - Args: cobra.RangeArgs(1, 2), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - rn := strings.TrimSuffix(args[0], ".git") - switch len(args) { - case 1: - if err := checkIfReadable(cmd, args); err != nil { - return err - } - rr, err := be.Repository(ctx, rn) - if err != nil { - return err - } - - r, err := rr.Open() - if err != nil { - return err - } - - head, err := r.HEAD() - if err != nil { - return err - } - - cmd.Println(head.Name().Short()) - case 2: - if err := checkIfCollab(cmd, args); err != nil { - return err - } - - rr, err := be.Repository(ctx, rn) - if err != nil { - return err - } - - r, err := rr.Open() - if err != nil { - return err - } - - branch := args[1] - branches, _ := r.Branches() - var exists bool - for _, b := range branches { - if branch == b { - exists = true - break - } - } - - if !exists { - return git.ErrReferenceNotExist - } - - if _, err := r.SymbolicRef(git.HEAD, gitm.RefsHeads+branch, gitm.SymbolicRefOptions{ - CommandOptions: gitm.CommandOptions{ - Context: ctx, - }, - }); err != nil { - return err - } - - // TODO: move this to backend? - user := proto.UserFromContext(ctx) - wh, err := webhook.NewRepositoryEvent(ctx, user, rr, webhook.RepositoryEventActionDefaultBranchChange) - if err != nil { - return err - } - - return webhook.SendEvent(ctx, wh) - } - - return nil - }, - } - - return cmd -} - -func branchDeleteCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "delete REPOSITORY BRANCH", - Aliases: []string{"remove", "rm", "del"}, - Short: "Delete a branch", - PersistentPreRunE: checkIfCollab, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - rn := strings.TrimSuffix(args[0], ".git") - rr, err := be.Repository(ctx, rn) - if err != nil { - return err - } - - r, err := rr.Open() - if err != nil { - return err - } - - branch := args[1] - branches, _ := r.Branches() - var exists bool - for _, b := range branches { - if branch == b { - exists = true - break - } - } - - if !exists { - return git.ErrReferenceNotExist - } - - head, err := r.HEAD() - if err != nil { - return err - } - - if head.Name().Short() == branch { - return fmt.Errorf("cannot delete the default branch") - } - - branchCommit, err := r.BranchCommit(branch) - if err != nil { - return err - } - - if err := r.DeleteBranch(branch, gitm.DeleteBranchOptions{Force: true}); err != nil { - return err - } - - wh, err := webhook.NewBranchTagEvent(ctx, proto.UserFromContext(ctx), rr, git.RefsHeads+branch, branchCommit.ID.String(), git.ZeroID) - if err != nil { - return err - } - - return webhook.SendEvent(ctx, wh) - }, - } - - return cmd -} diff --git a/server/ssh/cmd/cmd.go b/server/ssh/cmd/cmd.go deleted file mode 100644 index d543c3ad1..000000000 --- a/server/ssh/cmd/cmd.go +++ /dev/null @@ -1,187 +0,0 @@ -package cmd - -import ( - "fmt" - "net/url" - "strings" - "text/template" - "unicode" - - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/sshutils" - "github.com/charmbracelet/soft-serve/server/utils" - "github.com/charmbracelet/ssh" - "github.com/spf13/cobra" -) - -var templateFuncs = template.FuncMap{ - "trim": strings.TrimSpace, - "trimRightSpace": trimRightSpace, - "trimTrailingWhitespaces": trimRightSpace, - "rpad": rpad, - "gt": cobra.Gt, - "eq": cobra.Eq, -} - -const ( - // UsageTemplate is the template used for the help output. - UsageTemplate = `Usage:{{if .Runnable}} - {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} - {{.SSHCommand}}{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} - -Aliases: - {{.NameAndAliases}}{{end}}{{if .HasExample}} - -Examples: -{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} - -Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} - {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} - -{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} - {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} - -Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} - {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} - -Flags: -{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} - -Global Flags: -{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} - -Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} - {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} - -Use "{{.SSHCommand}}{{.CommandPath}} [command] --help" for more information about a command.{{end}} -` -) - -// UsageFunc is a function that can be used as a cobra.Command's -// UsageFunc to render the help output. -func UsageFunc(c *cobra.Command) error { - ctx := c.Context() - cfg := config.FromContext(ctx) - hostname := "localhost" - port := "23231" - url, err := url.Parse(cfg.SSH.PublicURL) - if err == nil { - hostname = url.Hostname() - port = url.Port() - } - - sshCmd := "ssh" - if port != "" && port != "22" { - sshCmd += " -p " + port - } - - sshCmd += " " + hostname - t := template.New("usage") - t.Funcs(templateFuncs) - template.Must(t.Parse(c.UsageTemplate())) - return t.Execute(c.OutOrStderr(), struct { - *cobra.Command - SSHCommand string - }{ - Command: c, - SSHCommand: sshCmd, - }) -} - -func trimRightSpace(s string) string { - return strings.TrimRightFunc(s, unicode.IsSpace) -} - -// rpad adds padding to the right of a string. -func rpad(s string, padding int) string { - template := fmt.Sprintf("%%-%ds", padding) - return fmt.Sprintf(template, s) -} - -// CommandName returns the name of the command from the args. -func CommandName(args []string) string { - if len(args) == 0 { - return "" - } - return args[0] -} - -func checkIfReadable(cmd *cobra.Command, args []string) error { - var repo string - if len(args) > 0 { - repo = args[0] - } - - ctx := cmd.Context() - be := backend.FromContext(ctx) - rn := utils.SanitizeRepo(repo) - user := proto.UserFromContext(ctx) - auth := be.AccessLevelForUser(cmd.Context(), rn, user) - if auth < access.ReadOnlyAccess { - return proto.ErrUnauthorized - } - return nil -} - -// IsPublicKeyAdmin returns true if the given public key is an admin key from -// the initial_admin_keys config or environment field. -func IsPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool { - for _, k := range cfg.AdminKeys() { - if sshutils.KeysEqual(pk, k) { - return true - } - } - return false -} - -func checkIfAdmin(cmd *cobra.Command, args []string) error { - var repo string - if len(args) > 0 { - repo = args[0] - } - - ctx := cmd.Context() - cfg := config.FromContext(ctx) - be := backend.FromContext(ctx) - rn := utils.SanitizeRepo(repo) - pk := sshutils.PublicKeyFromContext(ctx) - if IsPublicKeyAdmin(cfg, pk) { - return nil - } - - user := proto.UserFromContext(ctx) - if user == nil { - return proto.ErrUnauthorized - } - - if user.IsAdmin() { - return nil - } - - auth := be.AccessLevelForUser(cmd.Context(), rn, user) - if auth >= access.AdminAccess { - return nil - } - - return proto.ErrUnauthorized -} - -func checkIfCollab(cmd *cobra.Command, args []string) error { - var repo string - if len(args) > 0 { - repo = args[0] - } - - ctx := cmd.Context() - be := backend.FromContext(ctx) - rn := utils.SanitizeRepo(repo) - user := proto.UserFromContext(ctx) - auth := be.AccessLevelForUser(cmd.Context(), rn, user) - if auth < access.ReadWriteAccess { - return proto.ErrUnauthorized - } - return nil -} diff --git a/server/ssh/cmd/collab.go b/server/ssh/cmd/collab.go deleted file mode 100644 index 7f45939a9..000000000 --- a/server/ssh/cmd/collab.go +++ /dev/null @@ -1,95 +0,0 @@ -package cmd - -import ( - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/spf13/cobra" -) - -func collabCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "collab", - Aliases: []string{"collabs", "collaborator", "collaborators"}, - Short: "Manage collaborators", - } - - cmd.AddCommand( - collabAddCommand(), - collabRemoveCommand(), - collabListCommand(), - ) - - return cmd -} - -func collabAddCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "add REPOSITORY USERNAME [LEVEL]", - Short: "Add a collaborator to a repo", - Long: "Add a collaborator to a repo. LEVEL can be one of: no-access, read-only, read-write, or admin-access. Defaults to read-write.", - Args: cobra.RangeArgs(2, 3), - PersistentPreRunE: checkIfCollab, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - repo := args[0] - username := args[1] - level := access.ReadWriteAccess - if len(args) > 2 { - level = access.ParseAccessLevel(args[2]) - if level < 0 { - return access.ErrInvalidAccessLevel - } - } - - return be.AddCollaborator(ctx, repo, username, level) - }, - } - - return cmd -} - -func collabRemoveCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "remove REPOSITORY USERNAME", - Args: cobra.ExactArgs(2), - Short: "Remove a collaborator from a repo", - PersistentPreRunE: checkIfCollab, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - repo := args[0] - username := args[1] - - return be.RemoveCollaborator(ctx, repo, username) - }, - } - - return cmd -} - -func collabListCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "list REPOSITORY", - Short: "List collaborators for a repo", - Args: cobra.ExactArgs(1), - PersistentPreRunE: checkIfCollab, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - repo := args[0] - collabs, err := be.Collaborators(ctx, repo) - if err != nil { - return err - } - - for _, c := range collabs { - cmd.Println(c) - } - - return nil - }, - } - - return cmd -} diff --git a/server/ssh/cmd/commit.go b/server/ssh/cmd/commit.go deleted file mode 100644 index 75f02efbd..000000000 --- a/server/ssh/cmd/commit.go +++ /dev/null @@ -1,165 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - "time" - - gansi "github.com/charmbracelet/glamour/ansi" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/charmbracelet/soft-serve/server/ui/styles" - "github.com/muesli/termenv" - "github.com/spf13/cobra" -) - -// commitCommand returns a command that prints the contents of a commit. -func commitCommand() *cobra.Command { - var color bool - var patchOnly bool - - cmd := &cobra.Command{ - Use: "commit SHA", - Short: "Print out the contents of a diff", - Args: cobra.ExactArgs(2), - PersistentPreRunE: checkIfReadable, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - repoName := args[0] - commitSHA := args[1] - - rr, err := be.Repository(ctx, repoName) - if err != nil { - return err - } - - r, err := rr.Open() - if err != nil { - return err - } - - commit, err := r.CommitByRevision(commitSHA) - if err != nil { - return err - } - - patch, err := r.Patch(commit) - if err != nil { - return err - } - - diff, err := r.Diff(commit) - if err != nil { - return err - } - - commonStyle := styles.DefaultStyles() - style := commonStyle.Log - - s := strings.Builder{} - commitLine := "commit " + commitSHA - authorLine := "Author: " + commit.Author.Name - dateLine := "Date: " + commit.Committer.When.UTC().Format(time.UnixDate) - msgLine := strings.ReplaceAll(commit.Message, "\r\n", "\n") - statsLine := renderStats(diff, commonStyle, color) - diffLine := renderDiff(patch, color) - - if patchOnly { - cmd.Println( - diffLine, - ) - return nil - } - - if color { - s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n", - style.CommitHash.Render(commitLine), - style.CommitAuthor.Render(authorLine), - style.CommitDate.Render(dateLine), - style.CommitBody.Render(msgLine), - )) - } else { - s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n", - commitLine, - authorLine, - dateLine, - msgLine, - )) - } - - s.WriteString(fmt.Sprintf("\n%s\n%s", - statsLine, - diffLine, - )) - - cmd.Println( - s.String(), - ) - - return nil - }, - } - - cmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output") - cmd.Flags().BoolVarP(&patchOnly, "patch", "p", false, "Output patch only") - - return cmd -} - -func renderCtx() gansi.RenderContext { - return gansi.NewRenderContext(gansi.Options{ - ColorProfile: termenv.TrueColor, - Styles: common.StyleConfig(), - }) -} - -func renderDiff(patch string, color bool) string { - c := patch - - if color { - var s strings.Builder - var pr strings.Builder - - diffChroma := &gansi.CodeBlockElement{ - Code: patch, - Language: "diff", - } - - err := diffChroma.Render(&pr, renderCtx()) - - if err != nil { - s.WriteString(fmt.Sprintf("\n%s", err.Error())) - } else { - s.WriteString(fmt.Sprintf("\n%s", pr.String())) - } - - c = s.String() - } - - return c -} - -func renderStats(diff *git.Diff, commonStyle *styles.Styles, color bool) string { - style := commonStyle.Log - c := diff.Stats().String() - - if color { - s := strings.Split(c, "\n") - - for i, line := range s { - ch := strings.Split(line, "|") - if len(ch) > 1 { - adddel := ch[len(ch)-1] - adddel = strings.ReplaceAll(adddel, "+", style.CommitStatsAdd.Render("+")) - adddel = strings.ReplaceAll(adddel, "-", style.CommitStatsDel.Render("-")) - s[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel - } - } - - return strings.Join(s, "\n") - } - - return c -} diff --git a/server/ssh/cmd/create.go b/server/ssh/cmd/create.go deleted file mode 100644 index 545470ff5..000000000 --- a/server/ssh/cmd/create.go +++ /dev/null @@ -1,54 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/spf13/cobra" -) - -// createCommand is the command for creating a new repository. -func createCommand() *cobra.Command { - var private bool - var description string - var projectName string - var hidden bool - - cmd := &cobra.Command{ - Use: "create REPOSITORY", - Short: "Create a new repository", - Args: cobra.ExactArgs(1), - PersistentPreRunE: checkIfCollab, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - cfg := config.FromContext(ctx) - be := backend.FromContext(ctx) - user := proto.UserFromContext(ctx) - name := args[0] - r, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{ - Private: private, - Description: description, - ProjectName: projectName, - Hidden: hidden, - }) - if err != nil { - return err - } - - cloneurl := fmt.Sprintf("%s/%s.git", cfg.SSH.PublicURL, r.Name()) - cmd.PrintErrf("Created repository %s\n", r.Name()) - cmd.Println(cloneurl) - - return nil - }, - } - - cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private") - cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description") - cmd.Flags().StringVarP(&projectName, "name", "n", "", "set the project name") - cmd.Flags().BoolVarP(&hidden, "hidden", "H", false, "hide the repository from the UI") - - return cmd -} diff --git a/server/ssh/cmd/delete.go b/server/ssh/cmd/delete.go deleted file mode 100644 index 7dad3d3f1..000000000 --- a/server/ssh/cmd/delete.go +++ /dev/null @@ -1,25 +0,0 @@ -package cmd - -import ( - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/spf13/cobra" -) - -func deleteCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "delete REPOSITORY", - Aliases: []string{"del", "remove", "rm"}, - Short: "Delete a repository", - Args: cobra.ExactArgs(1), - PersistentPreRunE: checkIfCollab, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - name := args[0] - - return be.DeleteRepository(ctx, name) - }, - } - - return cmd -} diff --git a/server/ssh/cmd/description.go b/server/ssh/cmd/description.go deleted file mode 100644 index e80f3ff56..000000000 --- a/server/ssh/cmd/description.go +++ /dev/null @@ -1,46 +0,0 @@ -package cmd - -import ( - "strings" - - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/spf13/cobra" -) - -func descriptionCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "description REPOSITORY [DESCRIPTION]", - Aliases: []string{"desc"}, - Short: "Set or get the description for a repository", - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - rn := strings.TrimSuffix(args[0], ".git") - switch len(args) { - case 1: - if err := checkIfReadable(cmd, args); err != nil { - return err - } - - desc, err := be.Description(ctx, rn) - if err != nil { - return err - } - - cmd.Println(desc) - default: - if err := checkIfCollab(cmd, args); err != nil { - return err - } - if err := be.SetDescription(ctx, rn, strings.Join(args[1:], " ")); err != nil { - return err - } - } - - return nil - }, - } - - return cmd -} diff --git a/server/ssh/cmd/git.go b/server/ssh/cmd/git.go deleted file mode 100644 index 56831f272..000000000 --- a/server/ssh/cmd/git.go +++ /dev/null @@ -1,337 +0,0 @@ -package cmd - -import ( - "errors" - "path/filepath" - "time" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/git" - "github.com/charmbracelet/soft-serve/server/lfs" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/sshutils" - "github.com/charmbracelet/soft-serve/server/utils" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/spf13/cobra" -) - -var ( - uploadPackCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "git", - Name: "upload_pack_total", - Help: "The total number of git-upload-pack requests", - }, []string{"repo"}) - - receivePackCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "git", - Name: "receive_pack_total", - Help: "The total number of git-receive-pack requests", - }, []string{"repo"}) - - uploadArchiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "git", - Name: "upload_archive_total", - Help: "The total number of git-upload-archive requests", - }, []string{"repo"}) - - lfsAuthenticateCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "git", - Name: "lfs_authenticate_total", - Help: "The total number of git-lfs-authenticate requests", - }, []string{"repo", "operation"}) - - lfsTransferCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "git", - Name: "lfs_transfer_total", - Help: "The total number of git-lfs-transfer requests", - }, []string{"repo", "operation"}) - - uploadPackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "git", - Name: "upload_pack_seconds_total", - Help: "The total time spent on git-upload-pack requests", - }, []string{"repo"}) - - receivePackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "git", - Name: "receive_pack_seconds_total", - Help: "The total time spent on git-receive-pack requests", - }, []string{"repo"}) - - uploadArchiveSeconds = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "git", - Name: "upload_archive_seconds_total", - Help: "The total time spent on git-upload-archive requests", - }, []string{"repo"}) - - lfsAuthenticateSeconds = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "git", - Name: "lfs_authenticate_seconds_total", - Help: "The total time spent on git-lfs-authenticate requests", - }, []string{"repo", "operation"}) - - lfsTransferSeconds = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "git", - Name: "lfs_transfer_seconds_total", - Help: "The total time spent on git-lfs-transfer requests", - }, []string{"repo", "operation"}) - - createRepoCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "ssh", - Name: "create_repo_total", - Help: "The total number of create repo requests", - }, []string{"repo"}) -) - -// GitUploadPackCommand returns a cobra command for git-upload-pack. -func GitUploadPackCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "git-upload-pack REPO", - Short: "Git upload pack", - Args: cobra.ExactArgs(1), - Hidden: true, - RunE: gitRunE, - } - - return cmd -} - -// GitUploadArchiveCommand returns a cobra command for git-upload-archive. -func GitUploadArchiveCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "git-upload-archive REPO", - Short: "Git upload archive", - Args: cobra.ExactArgs(1), - Hidden: true, - RunE: gitRunE, - } - - return cmd -} - -// GitReceivePackCommand returns a cobra command for git-receive-pack. -func GitReceivePackCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "git-receive-pack REPO", - Short: "Git receive pack", - Args: cobra.ExactArgs(1), - Hidden: true, - RunE: gitRunE, - } - - return cmd -} - -// GitLFSAuthenticateCommand returns a cobra command for git-lfs-authenticate. -func GitLFSAuthenticateCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "git-lfs-authenticate REPO OPERATION", - Short: "Git LFS authenticate", - Args: cobra.ExactArgs(2), - Hidden: true, - RunE: gitRunE, - } - - return cmd -} - -// GitLFSTransfer returns a cobra command for git-lfs-transfer. -func GitLFSTransfer() *cobra.Command { - cmd := &cobra.Command{ - Use: "git-lfs-transfer REPO OPERATION", - Short: "Git LFS transfer", - Args: cobra.ExactArgs(2), - Hidden: true, - RunE: gitRunE, - } - - return cmd -} - -func gitRunE(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - cfg := config.FromContext(ctx) - be := backend.FromContext(ctx) - logger := log.FromContext(ctx) - start := time.Now() - - // repo should be in the form of "repo.git" - name := utils.SanitizeRepo(args[0]) - pk := sshutils.PublicKeyFromContext(ctx) - ak := sshutils.MarshalAuthorizedKey(pk) - user := proto.UserFromContext(ctx) - accessLevel := be.AccessLevelForUser(ctx, name, user) - // git bare repositories should end in ".git" - // https://git-scm.com/docs/gitrepository-layout - repoDir := name + ".git" - reposDir := filepath.Join(cfg.DataPath, "repos") - if err := git.EnsureWithin(reposDir, repoDir); err != nil { - return err - } - - // Set repo in context - repo, _ := be.Repository(ctx, name) - ctx = proto.WithRepositoryContext(ctx, repo) - - // Environment variables to pass down to git hooks. - envs := []string{ - "SOFT_SERVE_REPO_NAME=" + name, - "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repoDir), - "SOFT_SERVE_PUBLIC_KEY=" + ak, - "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"), - } - - if user != nil { - envs = append(envs, - "SOFT_SERVE_USERNAME="+user.Username(), - ) - } - - // Add ssh session & config environ - s := sshutils.SessionFromContext(ctx) - envs = append(envs, s.Environ()...) - envs = append(envs, cfg.Environ()...) - - repoPath := filepath.Join(reposDir, repoDir) - service := git.Service(cmd.Name()) - stdin := cmd.InOrStdin() - stdout := cmd.OutOrStdout() - stderr := cmd.ErrOrStderr() - scmd := git.ServiceCommand{ - Stdin: stdin, - Stdout: stdout, - Stderr: stderr, - Env: envs, - Dir: repoPath, - } - - switch service { - case git.ReceivePackService: - receivePackCounter.WithLabelValues(name).Inc() - defer func() { - receivePackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds()) - }() - if accessLevel < access.ReadWriteAccess { - return git.ErrNotAuthed - } - if repo == nil { - if _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{Private: false}); err != nil { - log.Errorf("failed to create repo: %s", err) - return err - } - createRepoCounter.WithLabelValues(name).Inc() - } - - if err := service.Handler(ctx, scmd); err != nil { - logger.Error("failed to handle git service", "service", service, "err", err, "repo", name) - defer func() { - if repo == nil { - // If the repo was created, but the request failed, delete it. - be.DeleteRepository(ctx, name) // nolint: errcheck - } - }() - - return git.ErrSystemMalfunction - } - - if err := git.EnsureDefaultBranch(ctx, scmd); err != nil { - logger.Error("failed to ensure default branch", "err", err, "repo", name) - return git.ErrSystemMalfunction - } - - receivePackCounter.WithLabelValues(name).Inc() - - return nil - case git.UploadPackService, git.UploadArchiveService: - if accessLevel < access.ReadOnlyAccess { - return git.ErrNotAuthed - } - - if repo == nil { - return git.ErrInvalidRepo - } - - switch service { - case git.UploadArchiveService: - uploadArchiveCounter.WithLabelValues(name).Inc() - defer func() { - uploadArchiveSeconds.WithLabelValues(name).Add(time.Since(start).Seconds()) - }() - default: - uploadPackCounter.WithLabelValues(name).Inc() - defer func() { - uploadPackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds()) - }() - } - - err := service.Handler(ctx, scmd) - if errors.Is(err, git.ErrInvalidRepo) { - return git.ErrInvalidRepo - } else if err != nil { - logger.Error("failed to handle git service", "service", service, "err", err, "repo", name) - return git.ErrSystemMalfunction - } - - return nil - case git.LFSTransferService, git.LFSAuthenticateService: - operation := args[1] - switch operation { - case lfs.OperationDownload: - if accessLevel < access.ReadOnlyAccess { - return git.ErrNotAuthed - } - case lfs.OperationUpload: - if accessLevel < access.ReadWriteAccess { - return git.ErrNotAuthed - } - default: - return git.ErrInvalidRequest - } - - if repo == nil { - return git.ErrInvalidRepo - } - - scmd.Args = []string{ - name, - args[1], - } - - switch service { - case git.LFSTransferService: - lfsTransferCounter.WithLabelValues(name, operation).Inc() - defer func() { - lfsTransferSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds()) - }() - default: - lfsAuthenticateCounter.WithLabelValues(name, operation).Inc() - defer func() { - lfsAuthenticateSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds()) - }() - } - - if err := service.Handler(ctx, scmd); err != nil { - logger.Error("failed to handle lfs service", "service", service, "err", err, "repo", name) - return git.ErrSystemMalfunction - } - - return nil - } - - return errors.New("unsupported git service") -} diff --git a/server/ssh/cmd/hidden.go b/server/ssh/cmd/hidden.go deleted file mode 100644 index 2e2f4f486..000000000 --- a/server/ssh/cmd/hidden.go +++ /dev/null @@ -1,46 +0,0 @@ -package cmd - -import ( - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/spf13/cobra" -) - -func hiddenCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "hidden REPOSITORY [TRUE|FALSE]", - Short: "Hide or unhide a repository", - Aliases: []string{"hide"}, - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - repo := args[0] - switch len(args) { - case 1: - if err := checkIfReadable(cmd, args); err != nil { - return err - } - - hidden, err := be.IsHidden(ctx, repo) - if err != nil { - return err - } - - cmd.Println(hidden) - case 2: - if err := checkIfCollab(cmd, args); err != nil { - return err - } - - hidden := args[1] == "true" - if err := be.SetHidden(ctx, repo, hidden); err != nil { - return err - } - } - - return nil - }, - } - - return cmd -} diff --git a/server/ssh/cmd/import.go b/server/ssh/cmd/import.go deleted file mode 100644 index 85cb2fb8f..000000000 --- a/server/ssh/cmd/import.go +++ /dev/null @@ -1,62 +0,0 @@ -package cmd - -import ( - "errors" - - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/task" - "github.com/spf13/cobra" -) - -// importCommand is the command for creating a new repository. -func importCommand() *cobra.Command { - var private bool - var description string - var projectName string - var mirror bool - var hidden bool - var lfs bool - var lfsEndpoint string - - cmd := &cobra.Command{ - Use: "import REPOSITORY REMOTE", - Short: "Import a new repository from remote", - Args: cobra.ExactArgs(2), - PersistentPreRunE: checkIfCollab, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - user := proto.UserFromContext(ctx) - name := args[0] - remote := args[1] - if _, err := be.ImportRepository(ctx, name, user, remote, proto.RepositoryOptions{ - Private: private, - Description: description, - ProjectName: projectName, - Mirror: mirror, - Hidden: hidden, - LFS: lfs, - LFSEndpoint: lfsEndpoint, - }); err != nil { - if errors.Is(err, task.ErrAlreadyStarted) { - return errors.New("import already in progress") - } - - return err - } - - return nil - }, - } - - cmd.Flags().BoolVarP(&lfs, "lfs", "", false, "pull Git LFS objects") - cmd.Flags().StringVarP(&lfsEndpoint, "lfs-endpoint", "", "", "set the Git LFS endpoint") - cmd.Flags().BoolVarP(&mirror, "mirror", "m", false, "mirror the repository") - cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private") - cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description") - cmd.Flags().StringVarP(&projectName, "name", "n", "", "set the project name") - cmd.Flags().BoolVarP(&hidden, "hidden", "H", false, "hide the repository from the UI") - - return cmd -} diff --git a/server/ssh/cmd/info.go b/server/ssh/cmd/info.go deleted file mode 100644 index 43f0c15ec..000000000 --- a/server/ssh/cmd/info.go +++ /dev/null @@ -1,35 +0,0 @@ -package cmd - -import ( - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/sshutils" - "github.com/spf13/cobra" -) - -// InfoCommand returns a command that shows the user's info -func InfoCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "info", - Short: "Show your info", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - pk := sshutils.PublicKeyFromContext(ctx) - user, err := be.UserByPublicKey(ctx, pk) - if err != nil { - return err - } - - cmd.Printf("Username: %s\n", user.Username()) - cmd.Printf("Admin: %t\n", user.IsAdmin()) - cmd.Printf("Public keys:\n") - for _, pk := range user.PublicKeys() { - cmd.Printf(" %s\n", sshutils.MarshalAuthorizedKey(pk)) - } - return nil - }, - } - - return cmd -} diff --git a/server/ssh/cmd/jwt.go b/server/ssh/cmd/jwt.go deleted file mode 100644 index f5dec4cbf..000000000 --- a/server/ssh/cmd/jwt.go +++ /dev/null @@ -1,57 +0,0 @@ -package cmd - -import ( - "fmt" - "time" - - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/jwk" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/golang-jwt/jwt/v5" - "github.com/spf13/cobra" -) - -// JWTCommand returns a command that generates a JSON Web Token. -func JWTCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "jwt [repository1 repository2...]", - Short: "Generate a JSON Web Token", - Args: cobra.MinimumNArgs(0), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - cfg := config.FromContext(ctx) - kp, err := jwk.NewPair(cfg) - if err != nil { - return err - } - - user := proto.UserFromContext(ctx) - if user == nil { - return proto.ErrUserNotFound - } - - now := time.Now() - expiresAt := now.Add(time.Hour) - claims := jwt.RegisteredClaims{ - Subject: fmt.Sprintf("%s#%d", user.Username(), user.ID()), - ExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour - NotBefore: jwt.NewNumericDate(now), - IssuedAt: jwt.NewNumericDate(now), - Issuer: cfg.HTTP.PublicURL, - Audience: args, - } - - token := jwt.NewWithClaims(jwk.SigningMethod, claims) - token.Header["kid"] = kp.JWK().KeyID - j, err := token.SignedString(kp.PrivateKey()) - if err != nil { - return err - } - - cmd.Println(j) - return nil - }, - } - - return cmd -} diff --git a/server/ssh/cmd/list.go b/server/ssh/cmd/list.go deleted file mode 100644 index 539a4dfbc..000000000 --- a/server/ssh/cmd/list.go +++ /dev/null @@ -1,41 +0,0 @@ -package cmd - -import ( - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/sshutils" - "github.com/spf13/cobra" -) - -// listCommand returns a command that list file or directory at path. -func listCommand() *cobra.Command { - var all bool - - listCmd := &cobra.Command{ - Use: "list", - Aliases: []string{"ls"}, - Short: "List repositories", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - pk := sshutils.PublicKeyFromContext(ctx) - repos, err := be.Repositories(ctx) - if err != nil { - return err - } - for _, r := range repos { - if be.AccessLevelByPublicKey(ctx, r.Name(), pk) >= access.ReadOnlyAccess { - if !r.IsHidden() || all { - cmd.Println(r.Name()) - } - } - } - return nil - }, - } - - listCmd.Flags().BoolVarP(&all, "all", "a", false, "List all repositories") - - return listCmd -} diff --git a/server/ssh/cmd/mirror.go b/server/ssh/cmd/mirror.go deleted file mode 100644 index d2e641c23..000000000 --- a/server/ssh/cmd/mirror.go +++ /dev/null @@ -1,30 +0,0 @@ -package cmd - -import ( - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/spf13/cobra" -) - -func mirrorCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "is-mirror REPOSITORY", - Short: "Whether a repository is a mirror", - Args: cobra.ExactArgs(1), - PersistentPreRunE: checkIfReadable, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - rn := args[0] - rr, err := be.Repository(ctx, rn) - if err != nil { - return err - } - - isMirror := rr.IsMirror() - cmd.Println(isMirror) - return nil - }, - } - - return cmd -} diff --git a/server/ssh/cmd/private.go b/server/ssh/cmd/private.go deleted file mode 100644 index f835c804a..000000000 --- a/server/ssh/cmd/private.go +++ /dev/null @@ -1,50 +0,0 @@ -package cmd - -import ( - "strconv" - "strings" - - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/spf13/cobra" -) - -func privateCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "private REPOSITORY [true|false]", - Short: "Set or get a repository private property", - Args: cobra.RangeArgs(1, 2), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - rn := strings.TrimSuffix(args[0], ".git") - - switch len(args) { - case 1: - if err := checkIfReadable(cmd, args); err != nil { - return err - } - - isPrivate, err := be.IsPrivate(ctx, rn) - if err != nil { - return err - } - - cmd.Println(isPrivate) - case 2: - isPrivate, err := strconv.ParseBool(args[1]) - if err != nil { - return err - } - if err := checkIfCollab(cmd, args); err != nil { - return err - } - if err := be.SetPrivate(ctx, rn, isPrivate); err != nil { - return err - } - } - return nil - }, - } - - return cmd -} diff --git a/server/ssh/cmd/project_name.go b/server/ssh/cmd/project_name.go deleted file mode 100644 index 8eb9b05ad..000000000 --- a/server/ssh/cmd/project_name.go +++ /dev/null @@ -1,46 +0,0 @@ -package cmd - -import ( - "strings" - - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/spf13/cobra" -) - -func projectName() *cobra.Command { - cmd := &cobra.Command{ - Use: "project-name REPOSITORY [NAME]", - Aliases: []string{"project"}, - Short: "Set or get the project name for a repository", - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - rn := strings.TrimSuffix(args[0], ".git") - switch len(args) { - case 1: - if err := checkIfReadable(cmd, args); err != nil { - return err - } - - pn, err := be.ProjectName(ctx, rn) - if err != nil { - return err - } - - cmd.Println(pn) - default: - if err := checkIfCollab(cmd, args); err != nil { - return err - } - if err := be.SetProjectName(ctx, rn, strings.Join(args[1:], " ")); err != nil { - return err - } - } - - return nil - }, - } - - return cmd -} diff --git a/server/ssh/cmd/pubkey.go b/server/ssh/cmd/pubkey.go deleted file mode 100644 index 11edd386d..000000000 --- a/server/ssh/cmd/pubkey.go +++ /dev/null @@ -1,93 +0,0 @@ -package cmd - -import ( - "strings" - - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/sshutils" - "github.com/spf13/cobra" -) - -// PubkeyCommand returns a command that manages user public keys. -func PubkeyCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "pubkey", - Aliases: []string{"pubkeys", "publickey", "publickeys"}, - Short: "Manage your public keys", - } - - pubkeyAddCommand := &cobra.Command{ - Use: "add AUTHORIZED_KEY", - Short: "Add a public key", - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - pk := sshutils.PublicKeyFromContext(ctx) - user, err := be.UserByPublicKey(ctx, pk) - if err != nil { - return err - } - - apk, _, err := sshutils.ParseAuthorizedKey(strings.Join(args, " ")) - if err != nil { - return err - } - - return be.AddPublicKey(ctx, user.Username(), apk) - }, - } - - pubkeyRemoveCommand := &cobra.Command{ - Use: "remove AUTHORIZED_KEY", - Args: cobra.MinimumNArgs(1), - Short: "Remove a public key", - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - pk := sshutils.PublicKeyFromContext(ctx) - user, err := be.UserByPublicKey(ctx, pk) - if err != nil { - return err - } - - apk, _, err := sshutils.ParseAuthorizedKey(strings.Join(args, " ")) - if err != nil { - return err - } - - return be.RemovePublicKey(ctx, user.Username(), apk) - }, - } - - pubkeyListCommand := &cobra.Command{ - Use: "list", - Aliases: []string{"ls"}, - Short: "List public keys", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - pk := sshutils.PublicKeyFromContext(ctx) - user, err := be.UserByPublicKey(ctx, pk) - if err != nil { - return err - } - - pks := user.PublicKeys() - for _, pk := range pks { - cmd.Println(sshutils.MarshalAuthorizedKey(pk)) - } - - return nil - }, - } - - cmd.AddCommand( - pubkeyAddCommand, - pubkeyRemoveCommand, - pubkeyListCommand, - ) - - return cmd -} diff --git a/server/ssh/cmd/rename.go b/server/ssh/cmd/rename.go deleted file mode 100644 index 2e92907b0..000000000 --- a/server/ssh/cmd/rename.go +++ /dev/null @@ -1,26 +0,0 @@ -package cmd - -import ( - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/spf13/cobra" -) - -func renameCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "rename REPOSITORY NEW_NAME", - Aliases: []string{"mv", "move"}, - Short: "Rename an existing repository", - Args: cobra.ExactArgs(2), - PersistentPreRunE: checkIfCollab, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - oldName := args[0] - newName := args[1] - - return be.RenameRepository(ctx, oldName, newName) - }, - } - - return cmd -} diff --git a/server/ssh/cmd/repo.go b/server/ssh/cmd/repo.go deleted file mode 100644 index 0579a12ac..000000000 --- a/server/ssh/cmd/repo.go +++ /dev/null @@ -1,107 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/spf13/cobra" -) - -// RepoCommand returns a command for managing repositories. -func RepoCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "repo", - Aliases: []string{"repos", "repository", "repositories"}, - Short: "Manage repositories", - } - - cmd.AddCommand( - blobCommand(), - branchCommand(), - collabCommand(), - commitCommand(), - createCommand(), - deleteCommand(), - descriptionCommand(), - hiddenCommand(), - importCommand(), - listCommand(), - mirrorCommand(), - privateCommand(), - projectName(), - renameCommand(), - tagCommand(), - treeCommand(), - webhookCommand(), - ) - - cmd.AddCommand( - &cobra.Command{ - Use: "info REPOSITORY", - Short: "Get information about a repository", - Args: cobra.ExactArgs(1), - PersistentPreRunE: checkIfReadable, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - rn := args[0] - rr, err := be.Repository(ctx, rn) - if err != nil { - return err - } - - r, err := rr.Open() - if err != nil { - return err - } - - head, err := r.HEAD() - if err != nil { - return err - } - - var owner proto.User - if rr.UserID() > 0 { - owner, err = be.UserByID(ctx, rr.UserID()) - if err != nil { - return err - } - } - - branches, _ := r.Branches() - tags, _ := r.Tags() - - // project name and description are optional, handle trailing - // whitespace to avoid breaking tests. - cmd.Println(strings.TrimSpace(fmt.Sprint("Project Name: ", rr.ProjectName()))) - cmd.Println("Repository:", rr.Name()) - cmd.Println(strings.TrimSpace(fmt.Sprint("Description: ", rr.Description()))) - cmd.Println("Private:", rr.IsPrivate()) - cmd.Println("Hidden:", rr.IsHidden()) - cmd.Println("Mirror:", rr.IsMirror()) - if owner != nil { - cmd.Println(strings.TrimSpace(fmt.Sprint("Owner: ", owner.Username()))) - } - cmd.Println("Default Branch:", head.Name().Short()) - if len(branches) > 0 { - cmd.Println("Branches:") - for _, b := range branches { - cmd.Println(" -", b) - } - } - if len(tags) > 0 { - cmd.Println("Tags:") - for _, t := range tags { - cmd.Println(" -", t) - } - } - - return nil - }, - }, - ) - - return cmd -} diff --git a/server/ssh/cmd/set_username.go b/server/ssh/cmd/set_username.go deleted file mode 100644 index c3841f82e..000000000 --- a/server/ssh/cmd/set_username.go +++ /dev/null @@ -1,29 +0,0 @@ -package cmd - -import ( - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/sshutils" - "github.com/spf13/cobra" -) - -// SetUsernameCommand returns a command that sets the user's username. -func SetUsernameCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "set-username USERNAME", - Short: "Set your username", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - pk := sshutils.PublicKeyFromContext(ctx) - user, err := be.UserByPublicKey(ctx, pk) - if err != nil { - return err - } - - return be.SetUsername(ctx, user.Username(), args[0]) - }, - } - - return cmd -} diff --git a/server/ssh/cmd/settings.go b/server/ssh/cmd/settings.go deleted file mode 100644 index 4131fb2b1..000000000 --- a/server/ssh/cmd/settings.go +++ /dev/null @@ -1,73 +0,0 @@ -package cmd - -import ( - "fmt" - "strconv" - - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/spf13/cobra" -) - -// SettingsCommand returns a command that manages server settings. -func SettingsCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "settings", - Short: "Manage server settings", - } - - cmd.AddCommand( - &cobra.Command{ - Use: "allow-keyless [true|false]", - Short: "Set or get allow keyless access to repositories", - Args: cobra.RangeArgs(0, 1), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - switch len(args) { - case 0: - cmd.Println(be.AllowKeyless(ctx)) - case 1: - v, _ := strconv.ParseBool(args[0]) - if err := be.SetAllowKeyless(ctx, v); err != nil { - return err - } - } - - return nil - }, - }, - ) - - als := []string{access.NoAccess.String(), access.ReadOnlyAccess.String(), access.ReadWriteAccess.String(), access.AdminAccess.String()} - cmd.AddCommand( - &cobra.Command{ - Use: "anon-access [ACCESS_LEVEL]", - Short: "Set or get the default access level for anonymous users", - Args: cobra.RangeArgs(0, 1), - ValidArgs: als, - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - switch len(args) { - case 0: - cmd.Println(be.AnonAccess(ctx)) - case 1: - al := access.ParseAccessLevel(args[0]) - if al < 0 { - return fmt.Errorf("invalid access level: %s. Please choose one of the following: %s", args[0], als) - } - if err := be.SetAnonAccess(ctx, al); err != nil { - return err - } - } - - return nil - }, - }, - ) - - return cmd -} diff --git a/server/ssh/cmd/tag.go b/server/ssh/cmd/tag.go deleted file mode 100644 index c15ce09f6..000000000 --- a/server/ssh/cmd/tag.go +++ /dev/null @@ -1,120 +0,0 @@ -package cmd - -import ( - "strings" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/webhook" - "github.com/spf13/cobra" -) - -func tagCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "tag", - Short: "Manage repository tags", - } - - cmd.AddCommand( - tagListCommand(), - tagDeleteCommand(), - ) - - return cmd -} - -func tagListCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "list REPOSITORY", - Aliases: []string{"ls"}, - Short: "List repository tags", - Args: cobra.ExactArgs(1), - PersistentPreRunE: checkIfReadable, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - rn := strings.TrimSuffix(args[0], ".git") - rr, err := be.Repository(ctx, rn) - if err != nil { - return err - } - - r, err := rr.Open() - if err != nil { - return err - } - - tags, _ := r.Tags() - for _, t := range tags { - cmd.Println(t) - } - - return nil - }, - } - - return cmd -} - -func tagDeleteCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "delete REPOSITORY TAG", - Aliases: []string{"remove", "rm", "del"}, - Short: "Delete a tag", - Args: cobra.ExactArgs(2), - PersistentPreRunE: checkIfCollab, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - rn := strings.TrimSuffix(args[0], ".git") - rr, err := be.Repository(ctx, rn) - if err != nil { - return err - } - - r, err := rr.Open() - if err != nil { - log.Errorf("failed to open repo: %s", err) - return err - } - - tag := args[1] - tags, _ := r.Tags() - var exists bool - for _, t := range tags { - if tag == t { - exists = true - break - } - } - - if !exists { - log.Errorf("failed to get tag: tag %s does not exist", tag) - return git.ErrReferenceNotExist - } - - tagCommit, err := r.TagCommit(tag) - if err != nil { - log.Errorf("failed to get tag commit: %s", err) - return err - } - - if err := r.DeleteTag(tag); err != nil { - log.Errorf("failed to delete tag: %s", err) - return err - } - - wh, err := webhook.NewBranchTagEvent(ctx, proto.UserFromContext(ctx), rr, git.RefsTags+tag, tagCommit.ID.String(), git.ZeroID) - if err != nil { - log.Error("failed to create branch_tag webhook", "err", err) - return err - } - - return webhook.SendEvent(ctx, wh) - }, - } - - return cmd -} diff --git a/server/ssh/cmd/token.go b/server/ssh/cmd/token.go deleted file mode 100644 index 3fefaa8cd..000000000 --- a/server/ssh/cmd/token.go +++ /dev/null @@ -1,155 +0,0 @@ -package cmd - -import ( - "strconv" - "strings" - "time" - - "github.com/caarlos0/duration" - "github.com/caarlos0/tablewriter" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/dustin/go-humanize" - "github.com/spf13/cobra" -) - -// TokenCommand returns a command that manages user access tokens. -func TokenCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "token", - Aliases: []string{"access-token"}, - Short: "Manage access tokens", - } - - var createExpiresIn string - createCmd := &cobra.Command{ - Use: "create NAME", - Short: "Create a new access token", - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - name := strings.Join(args, " ") - - user := proto.UserFromContext(ctx) - if user == nil { - return proto.ErrUserNotFound - } - - var expiresAt time.Time - var expiresIn time.Duration - if createExpiresIn != "" { - d, err := duration.Parse(createExpiresIn) - if err != nil { - return err - } - - expiresIn = d - expiresAt = time.Now().Add(d) - } - - token, err := be.CreateAccessToken(ctx, user, name, expiresAt) - if err != nil { - return err - } - - notice := "Access token created" - if expiresIn != 0 { - notice += " (expires in " + humanize.Time(expiresAt) + ")" - } - - cmd.PrintErrln(notice) - cmd.Println(token) - - return nil - }, - } - - createCmd.Flags().StringVar(&createExpiresIn, "expires-in", "", "Token expiration time (e.g. 1y, 3mo, 2w, 5d4h, 1h30m)") - - listCmd := &cobra.Command{ - Use: "list", - Aliases: []string{"ls"}, - Short: "List access tokens", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - - user := proto.UserFromContext(ctx) - if user == nil { - return proto.ErrUserNotFound - } - - tokens, err := be.ListAccessTokens(ctx, user) - if err != nil { - return err - } - - if len(tokens) == 0 { - cmd.Println("No tokens found") - return nil - } - - now := time.Now() - return tablewriter.Render( - cmd.OutOrStdout(), - tokens, - []string{"ID", "Name", "Created At", "Expires In"}, - func(t proto.AccessToken) ([]string, error) { - expiresAt := "-" - if !t.ExpiresAt.IsZero() { - if now.After(t.ExpiresAt) { - expiresAt = "expired" - } else { - expiresAt = humanize.Time(t.ExpiresAt) - } - } - - return []string{ - strconv.FormatInt(t.ID, 10), - t.Name, - humanize.Time(t.CreatedAt), - expiresAt, - }, nil - }, - ) - }, - } - - deleteCmd := &cobra.Command{ - Use: "delete ID", - Aliases: []string{"rm", "remove"}, - Short: "Delete an access token", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - - user := proto.UserFromContext(ctx) - if user == nil { - return proto.ErrUserNotFound - } - - id, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return err - } - - if err := be.DeleteAccessToken(ctx, user, id); err != nil { - return err - } - - cmd.PrintErrln("Access token deleted") - return nil - }, - } - - cmd.AddCommand( - createCmd, - listCmd, - deleteCmd, - ) - - return cmd -} diff --git a/server/ssh/cmd/tree.go b/server/ssh/cmd/tree.go deleted file mode 100644 index e380a348e..000000000 --- a/server/ssh/cmd/tree.go +++ /dev/null @@ -1,102 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/dustin/go-humanize" - "github.com/spf13/cobra" -) - -// treeCommand returns a command that list file or directory at path. -func treeCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "tree REPOSITORY [REFERENCE] [PATH]", - Short: "Print repository tree at path", - Args: cobra.RangeArgs(1, 3), - PersistentPreRunE: checkIfReadable, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - rn := args[0] - path := "" - ref := "" - switch len(args) { - case 2: - path = args[1] - case 3: - ref = args[1] - path = args[2] - } - rr, err := be.Repository(ctx, rn) - if err != nil { - return err - } - - r, err := rr.Open() - if err != nil { - return err - } - - if ref == "" { - head, err := r.HEAD() - if err != nil { - if bs, err := r.Branches(); err != nil && len(bs) == 0 { - return fmt.Errorf("repository is empty") - } - return err - } - - ref = head.ID - } - - tree, err := r.LsTree(ref) - if err != nil { - return err - } - - ents := git.Entries{} - if path != "" && path != "/" { - te, err := tree.TreeEntry(path) - if err == git.ErrRevisionNotExist { - return proto.ErrFileNotFound - } - if err != nil { - return err - } - if te.Type() == "tree" { - tree, err = tree.SubTree(path) - if err != nil { - return err - } - ents, err = tree.Entries() - if err != nil { - return err - } - } else { - ents = append(ents, te) - } - } else { - ents, err = tree.Entries() - if err != nil { - return err - } - } - ents.Sort() - for _, ent := range ents { - size := ent.Size() - ssize := "" - if size == 0 { - ssize = "-" - } else { - ssize = humanize.Bytes(uint64(size)) - } - cmd.Printf("%s\t%s\t %s\n", ent.Mode(), ssize, ent.Name()) - } - return nil - }, - } - return cmd -} diff --git a/server/ssh/cmd/user.go b/server/ssh/cmd/user.go deleted file mode 100644 index f8b0a986d..000000000 --- a/server/ssh/cmd/user.go +++ /dev/null @@ -1,200 +0,0 @@ -package cmd - -import ( - "sort" - "strings" - - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/sshutils" - "github.com/spf13/cobra" - "golang.org/x/crypto/ssh" -) - -// UserCommand returns the user subcommand. -func UserCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "user", - Aliases: []string{"users"}, - Short: "Manage users", - } - - var admin bool - var key string - userCreateCommand := &cobra.Command{ - Use: "create USERNAME", - Short: "Create a new user", - Args: cobra.ExactArgs(1), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - var pubkeys []ssh.PublicKey - ctx := cmd.Context() - be := backend.FromContext(ctx) - username := args[0] - if key != "" { - pk, _, err := sshutils.ParseAuthorizedKey(key) - if err != nil { - return err - } - - pubkeys = []ssh.PublicKey{pk} - } - - opts := proto.UserOptions{ - Admin: admin, - PublicKeys: pubkeys, - } - - _, err := be.CreateUser(ctx, username, opts) - return err - }, - } - - userCreateCommand.Flags().BoolVarP(&admin, "admin", "a", false, "make the user an admin") - userCreateCommand.Flags().StringVarP(&key, "key", "k", "", "add a public key to the user") - - userDeleteCommand := &cobra.Command{ - Use: "delete USERNAME", - Short: "Delete a user", - Args: cobra.ExactArgs(1), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - username := args[0] - - return be.DeleteUser(ctx, username) - }, - } - - userListCommand := &cobra.Command{ - Use: "list", - Aliases: []string{"ls"}, - Short: "List users", - Args: cobra.NoArgs, - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - users, err := be.Users(ctx) - if err != nil { - return err - } - - sort.Strings(users) - for _, u := range users { - cmd.Println(u) - } - - return nil - }, - } - - userAddPubkeyCommand := &cobra.Command{ - Use: "add-pubkey USERNAME AUTHORIZED_KEY", - Short: "Add a public key to a user", - Args: cobra.MinimumNArgs(2), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - username := args[0] - pubkey := strings.Join(args[1:], " ") - pk, _, err := sshutils.ParseAuthorizedKey(pubkey) - if err != nil { - return err - } - - return be.AddPublicKey(ctx, username, pk) - }, - } - - userRemovePubkeyCommand := &cobra.Command{ - Use: "remove-pubkey USERNAME AUTHORIZED_KEY", - Short: "Remove a public key from a user", - Args: cobra.MinimumNArgs(2), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - username := args[0] - pubkey := strings.Join(args[1:], " ") - pk, _, err := sshutils.ParseAuthorizedKey(pubkey) - if err != nil { - return err - } - - return be.RemovePublicKey(ctx, username, pk) - }, - } - - userSetAdminCommand := &cobra.Command{ - Use: "set-admin USERNAME [true|false]", - Short: "Make a user an admin", - Args: cobra.ExactArgs(2), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - username := args[0] - - return be.SetAdmin(ctx, username, args[1] == "true") - }, - } - - userInfoCommand := &cobra.Command{ - Use: "info USERNAME", - Short: "Show information about a user", - Args: cobra.ExactArgs(1), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - username := args[0] - - user, err := be.User(ctx, username) - if err != nil { - return err - } - - isAdmin := user.IsAdmin() - - cmd.Printf("Username: %s\n", user.Username()) - cmd.Printf("Admin: %t\n", isAdmin) - cmd.Printf("Public keys:\n") - for _, pk := range user.PublicKeys() { - cmd.Printf(" %s\n", sshutils.MarshalAuthorizedKey(pk)) - } - - return nil - }, - } - - userSetUsernameCommand := &cobra.Command{ - Use: "set-username USERNAME NEW_USERNAME", - Short: "Change a user's username", - Args: cobra.ExactArgs(2), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - username := args[0] - newUsername := args[1] - - return be.SetUsername(ctx, username, newUsername) - }, - } - - cmd.AddCommand( - userCreateCommand, - userAddPubkeyCommand, - userInfoCommand, - userListCommand, - userDeleteCommand, - userRemovePubkeyCommand, - userSetAdminCommand, - userSetUsernameCommand, - ) - - return cmd -} diff --git a/server/ssh/cmd/webhooks.go b/server/ssh/cmd/webhooks.go deleted file mode 100644 index 53ee877f8..000000000 --- a/server/ssh/cmd/webhooks.go +++ /dev/null @@ -1,406 +0,0 @@ -package cmd - -import ( - "fmt" - "strconv" - "strings" - - "github.com/caarlos0/tablewriter" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/webhook" - "github.com/dustin/go-humanize" - "github.com/google/uuid" - "github.com/spf13/cobra" -) - -func webhookCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "webhook", - Aliases: []string{"webhooks"}, - Short: "Manage repository webhooks", - } - - cmd.AddCommand( - webhookListCommand(), - webhookCreateCommand(), - webhookDeleteCommand(), - webhookUpdateCommand(), - webhookDeliveriesCommand(), - ) - - return cmd -} - -var webhookEvents []string - -func init() { - events := webhook.Events() - webhookEvents = make([]string, len(events)) - for i, e := range events { - webhookEvents[i] = e.String() - } -} - -func webhookListCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "list REPOSITORY", - Short: "List repository webhooks", - Args: cobra.ExactArgs(1), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - repo, err := be.Repository(ctx, args[0]) - if err != nil { - return err - } - - webhooks, err := be.ListWebhooks(ctx, repo) - if err != nil { - return err - } - - return tablewriter.Render( - cmd.OutOrStdout(), - webhooks, - []string{"ID", "URL", "Events", "Active", "Created At", "Updated At"}, - func(h webhook.Hook) ([]string, error) { - events := make([]string, len(h.Events)) - for i, e := range h.Events { - events[i] = e.String() - } - - row := []string{ - strconv.FormatInt(h.ID, 10), - h.URL, - strings.Join(events, ","), - strconv.FormatBool(h.Active), - humanize.Time(h.CreatedAt), - humanize.Time(h.UpdatedAt), - } - - return row, nil - }, - ) - }, - } - - return cmd -} - -func webhookCreateCommand() *cobra.Command { - var events []string - var secret string - var active bool - var contentType string - cmd := &cobra.Command{ - Use: "create REPOSITORY URL", - Short: "Create a repository webhook", - Args: cobra.ExactArgs(2), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - repo, err := be.Repository(ctx, args[0]) - if err != nil { - return err - } - - var evs []webhook.Event - for _, e := range events { - ev, err := webhook.ParseEvent(e) - if err != nil { - return fmt.Errorf("invalid event: %w", err) - } - - evs = append(evs, ev) - } - - var ct webhook.ContentType - switch strings.ToLower(strings.TrimSpace(contentType)) { - case "json": - ct = webhook.ContentTypeJSON - case "form": - ct = webhook.ContentTypeForm - default: - return webhook.ErrInvalidContentType - } - - return be.CreateWebhook(ctx, repo, strings.TrimSpace(args[1]), ct, secret, evs, active) - }, - } - - cmd.Flags().StringSliceVarP(&events, "events", "e", nil, fmt.Sprintf("events to trigger the webhook, available events are (%s)", strings.Join(webhookEvents, ", "))) - cmd.Flags().StringVarP(&secret, "secret", "s", "", "secret to sign the webhook payload") - cmd.Flags().BoolVarP(&active, "active", "a", true, "whether the webhook is active") - cmd.Flags().StringVarP(&contentType, "content-type", "c", "json", "content type of the webhook payload, can be either `json` or `form`") - - return cmd -} - -func webhookDeleteCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "delete REPOSITORY WEBHOOK_ID", - Short: "Delete a repository webhook", - Args: cobra.ExactArgs(2), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - repo, err := be.Repository(ctx, args[0]) - if err != nil { - return err - } - - id, err := strconv.ParseInt(args[1], 10, 64) - if err != nil { - return fmt.Errorf("invalid webhook ID: %w", err) - } - - return be.DeleteWebhook(ctx, repo, id) - }, - } - - return cmd -} - -func webhookUpdateCommand() *cobra.Command { - var events []string - var secret string - var active string - var contentType string - var url string - cmd := &cobra.Command{ - Use: "update REPOSITORY WEBHOOK_ID", - Short: "Update a repository webhook", - Args: cobra.ExactArgs(2), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - repo, err := be.Repository(ctx, args[0]) - if err != nil { - return err - } - - id, err := strconv.ParseInt(args[1], 10, 64) - if err != nil { - return fmt.Errorf("invalid webhook ID: %w", err) - } - - wh, err := be.Webhook(ctx, repo, id) - if err != nil { - return err - } - - newURL := wh.URL - if url != "" { - newURL = url - } - - newSecret := wh.Secret - if secret != "" { - newSecret = secret - } - - newActive := wh.Active - if active != "" { - active, err := strconv.ParseBool(active) - if err != nil { - return fmt.Errorf("invalid active value: %w", err) - } - - newActive = active - } - - newContentType := wh.ContentType - if contentType != "" { - var ct webhook.ContentType - switch strings.ToLower(strings.TrimSpace(contentType)) { - case "json": - ct = webhook.ContentTypeJSON - case "form": - ct = webhook.ContentTypeForm - default: - return webhook.ErrInvalidContentType - } - newContentType = ct - } - - newEvents := wh.Events - if len(events) > 0 { - var evs []webhook.Event - for _, e := range events { - ev, err := webhook.ParseEvent(e) - if err != nil { - return fmt.Errorf("invalid event: %w", err) - } - - evs = append(evs, ev) - } - - newEvents = evs - } - - return be.UpdateWebhook(ctx, repo, id, newURL, newContentType, newSecret, newEvents, newActive) - }, - } - - cmd.Flags().StringSliceVarP(&events, "events", "e", nil, fmt.Sprintf("events to trigger the webhook, available events are (%s)", strings.Join(webhookEvents, ", "))) - cmd.Flags().StringVarP(&secret, "secret", "s", "", "secret to sign the webhook payload") - cmd.Flags().StringVarP(&active, "active", "a", "", "whether the webhook is active") - cmd.Flags().StringVarP(&contentType, "content-type", "c", "", "content type of the webhook payload, can be either `json` or `form`") - cmd.Flags().StringVarP(&url, "url", "u", "", "webhook URL") - - return cmd -} - -func webhookDeliveriesCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "deliveries", - Short: "Manage webhook deliveries", - Aliases: []string{"delivery", "deliver"}, - } - - cmd.AddCommand( - webhookDeliveriesListCommand(), - webhookDeliveriesRedeliverCommand(), - webhookDeliveriesGetCommand(), - ) - - return cmd -} - -func webhookDeliveriesListCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "list REPOSITORY WEBHOOK_ID", - Short: "List webhook deliveries", - Args: cobra.ExactArgs(2), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - id, err := strconv.ParseInt(args[1], 10, 64) - if err != nil { - return fmt.Errorf("invalid webhook ID: %w", err) - } - - dels, err := be.ListWebhookDeliveries(ctx, id) - if err != nil { - return err - } - - return tablewriter.Render( - cmd.OutOrStdout(), - dels, - []string{"Status", "ID", "Event", "Created At"}, - func(d webhook.Delivery) ([]string, error) { - status := "❌" - if d.ResponseStatus >= 200 && d.ResponseStatus < 300 { - status = "✅" - } - - return []string{ - status, - d.ID.String(), - d.Event.String(), - humanize.Time(d.CreatedAt), - }, nil - }, - ) - }, - } - - return cmd -} - -func webhookDeliveriesRedeliverCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "redeliver REPOSITORY WEBHOOK_ID DELIVERY_ID", - Short: "Redeliver a webhook delivery", - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - repo, err := be.Repository(ctx, args[0]) - if err != nil { - return err - } - - id, err := strconv.ParseInt(args[1], 10, 64) - if err != nil { - return fmt.Errorf("invalid webhook ID: %w", err) - } - - delID, err := uuid.Parse(args[2]) - if err != nil { - return fmt.Errorf("invalid delivery ID: %w", err) - } - - return be.RedeliverWebhookDelivery(ctx, repo, id, delID) - }, - } - - return cmd -} - -func webhookDeliveriesGetCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "get REPOSITORY WEBHOOK_ID DELIVERY_ID", - Short: "Get a webhook delivery", - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - be := backend.FromContext(ctx) - id, err := strconv.ParseInt(args[1], 10, 64) - if err != nil { - return fmt.Errorf("invalid webhook ID: %w", err) - } - - delID, err := uuid.Parse(args[2]) - if err != nil { - return fmt.Errorf("invalid delivery ID: %w", err) - } - - del, err := be.WebhookDelivery(ctx, id, delID) - if err != nil { - return err - } - - out := cmd.OutOrStdout() - fmt.Fprintf(out, "ID: %s\n", del.ID) - fmt.Fprintf(out, "Event: %s\n", del.Event) - fmt.Fprintf(out, "Request URL: %s\n", del.RequestURL) - fmt.Fprintf(out, "Request Method: %s\n", del.RequestMethod) - fmt.Fprintf(out, "Request Error: %s\n", del.RequestError.String) - fmt.Fprintf(out, "Request Headers:\n") - reqHeaders := strings.Split(del.RequestHeaders, "\n") - for _, h := range reqHeaders { - fmt.Fprintf(out, " %s\n", h) - } - - fmt.Fprintf(out, "Request Body:\n") - reqBody := strings.Split(del.RequestBody, "\n") - for _, b := range reqBody { - fmt.Fprintf(out, " %s\n", b) - } - - fmt.Fprintf(out, "Response Status: %d\n", del.ResponseStatus) - fmt.Fprintf(out, "Response Headers:\n") - resHeaders := strings.Split(del.ResponseHeaders, "\n") - for _, h := range resHeaders { - fmt.Fprintf(out, " %s\n", h) - } - - fmt.Fprintf(out, "Response Body:\n") - resBody := strings.Split(del.ResponseBody, "\n") - for _, b := range resBody { - fmt.Fprintf(out, " %s\n", b) - } - - return nil - }, - } - - return cmd -} diff --git a/server/ssh/middleware.go b/server/ssh/middleware.go deleted file mode 100644 index 23bf5ea97..000000000 --- a/server/ssh/middleware.go +++ /dev/null @@ -1,189 +0,0 @@ -package ssh - -import ( - "fmt" - "time" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/ssh/cmd" - "github.com/charmbracelet/soft-serve/server/sshutils" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/charmbracelet/ssh" - "github.com/charmbracelet/wish" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/spf13/cobra" - gossh "golang.org/x/crypto/ssh" -) - -// ErrPermissionDenied is returned when a user is not allowed connect. -var ErrPermissionDenied = fmt.Errorf("permission denied") - -// AuthenticationMiddleware handles authentication. -func AuthenticationMiddleware(sh ssh.Handler) ssh.Handler { - return func(s ssh.Session) { - // XXX: The authentication key is set in the context but gossh doesn't - // validate the authentication. We need to verify that the _last_ key - // that was approved is the one that's being used. - - pk := s.PublicKey() - if pk != nil { - // There is no public key stored in the context, public-key auth - // was never requested, skip - perms := s.Permissions().Permissions - if perms == nil { - wish.Fatalln(s, ErrPermissionDenied) - return - } - - // Check if the key is the same as the one we have in context - fp := perms.Extensions["pubkey-fp"] - if fp != gossh.FingerprintSHA256(pk) { - wish.Fatalln(s, ErrPermissionDenied) - return - } - } - - sh(s) - } -} - -// ContextMiddleware adds the config, backend, and logger to the session context. -func ContextMiddleware(cfg *config.Config, dbx *db.DB, datastore store.Store, be *backend.Backend, logger *log.Logger) func(ssh.Handler) ssh.Handler { - return func(sh ssh.Handler) ssh.Handler { - return func(s ssh.Session) { - s.Context().SetValue(sshutils.ContextKeySession, s) - s.Context().SetValue(config.ContextKey, cfg) - s.Context().SetValue(db.ContextKey, dbx) - s.Context().SetValue(store.ContextKey, datastore) - s.Context().SetValue(backend.ContextKey, be) - s.Context().SetValue(log.ContextKey, logger.WithPrefix("ssh")) - sh(s) - } - } -} - -var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "cli", - Name: "commands_total", - Help: "Total times each command was called", -}, []string{"command"}) - -// CommandMiddleware handles git commands and CLI commands. -// This middleware must be run after the ContextMiddleware. -func CommandMiddleware(sh ssh.Handler) ssh.Handler { - return func(s ssh.Session) { - func() { - _, _, ptyReq := s.Pty() - if ptyReq { - return - } - - ctx := s.Context() - cfg := config.FromContext(ctx) - - args := s.Command() - cliCommandCounter.WithLabelValues(cmd.CommandName(args)).Inc() - rootCmd := &cobra.Command{ - Short: "Soft Serve is a self-hostable Git server for the command line.", - SilenceUsage: true, - } - rootCmd.CompletionOptions.DisableDefaultCmd = true - - rootCmd.SetUsageTemplate(cmd.UsageTemplate) - rootCmd.SetUsageFunc(cmd.UsageFunc) - rootCmd.AddCommand( - cmd.GitUploadPackCommand(), - cmd.GitUploadArchiveCommand(), - cmd.GitReceivePackCommand(), - cmd.RepoCommand(), - cmd.SettingsCommand(), - cmd.UserCommand(), - cmd.InfoCommand(), - cmd.PubkeyCommand(), - cmd.SetUsernameCommand(), - cmd.JWTCommand(), - cmd.TokenCommand(), - ) - - if cfg.LFS.Enabled { - rootCmd.AddCommand( - cmd.GitLFSAuthenticateCommand(), - ) - - if cfg.LFS.SSHEnabled { - rootCmd.AddCommand( - cmd.GitLFSTransfer(), - ) - } - } - - rootCmd.SetArgs(args) - if len(args) == 0 { - // otherwise it'll default to os.Args, which is not what we want. - rootCmd.SetArgs([]string{"--help"}) - } - rootCmd.SetIn(s) - rootCmd.SetOut(s) - rootCmd.SetErr(s.Stderr()) - rootCmd.SetContext(ctx) - - if err := rootCmd.ExecuteContext(ctx); err != nil { - s.Exit(1) // nolint: errcheck - return - } - }() - sh(s) - } -} - -// LoggingMiddleware logs the ssh connection and command. -func LoggingMiddleware(sh ssh.Handler) ssh.Handler { - return func(s ssh.Session) { - ctx := s.Context() - logger := log.FromContext(ctx).WithPrefix("ssh") - ct := time.Now() - hpk := sshutils.MarshalAuthorizedKey(s.PublicKey()) - ptyReq, _, isPty := s.Pty() - addr := s.RemoteAddr().String() - user := proto.UserFromContext(ctx) - logArgs := []interface{}{ - "addr", - addr, - "cmd", - s.Command(), - } - - if user != nil { - logArgs = append([]interface{}{ - "username", - user.Username(), - }, logArgs...) - } - - if isPty { - logArgs = []interface{}{ - "term", ptyReq.Term, - "width", ptyReq.Window.Width, - "height", ptyReq.Window.Height, - } - } - - if config.IsVerbose() { - logArgs = append(logArgs, - "key", hpk, - "envs", s.Environ(), - ) - } - - msg := fmt.Sprintf("user %q", s.User()) - logger.Debug(msg+" connected", logArgs...) - sh(s) - logger.Debug(msg+" disconnected", append(logArgs, "duration", time.Since(ct))...) - } -} diff --git a/server/ssh/session.go b/server/ssh/session.go deleted file mode 100644 index 53496e3af..000000000 --- a/server/ssh/session.go +++ /dev/null @@ -1,99 +0,0 @@ -package ssh - -import ( - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/charmbracelet/ssh" - "github.com/charmbracelet/wish" - "github.com/muesli/termenv" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" -) - -var tuiSessionCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "ssh", - Name: "tui_session_total", - Help: "The total number of TUI sessions", -}, []string{"repo", "term"}) - -var tuiSessionDuration = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "ssh", - Name: "tui_session_seconds_total", - Help: "The total number of TUI sessions", -}, []string{"repo", "term"}) - -// SessionHandler is the soft-serve bubbletea ssh session handler. -// This middleware must be run after the ContextMiddleware. -func SessionHandler(s ssh.Session) *tea.Program { - pty, _, active := s.Pty() - if !active { - return nil - } - - ctx := s.Context() - be := backend.FromContext(ctx) - cfg := config.FromContext(ctx) - cmd := s.Command() - initialRepo := "" - if len(cmd) == 1 { - initialRepo = cmd[0] - auth := be.AccessLevelByPublicKey(ctx, initialRepo, s.PublicKey()) - if auth < access.ReadOnlyAccess { - wish.Fatalln(s, proto.ErrUnauthorized) - return nil - } - } - - envs := &sessionEnv{s} - output := termenv.NewOutput(s, termenv.WithColorCache(true), termenv.WithEnvironment(envs)) - c := common.NewCommon(ctx, output, pty.Window.Width, pty.Window.Height) - c.SetValue(common.ConfigKey, cfg) - m := NewUI(c, initialRepo) - p := tea.NewProgram(m, - tea.WithInput(s), - tea.WithOutput(s), - tea.WithAltScreen(), - tea.WithoutCatchPanics(), - tea.WithMouseCellMotion(), - tea.WithContext(ctx), - ) - - tuiSessionCounter.WithLabelValues(initialRepo, pty.Term).Inc() - - start := time.Now() - go func() { - <-ctx.Done() - tuiSessionDuration.WithLabelValues(initialRepo, pty.Term).Add(time.Since(start).Seconds()) - }() - - return p -} - -var _ termenv.Environ = &sessionEnv{} - -type sessionEnv struct { - ssh.Session -} - -func (s *sessionEnv) Environ() []string { - pty, _, _ := s.Pty() - return append(s.Session.Environ(), "TERM="+pty.Term) -} - -func (s *sessionEnv) Getenv(key string) string { - for _, env := range s.Environ() { - if strings.HasPrefix(env, key+"=") { - return strings.TrimPrefix(env, key+"=") - } - } - return "" -} diff --git a/server/ssh/session_test.go b/server/ssh/session_test.go deleted file mode 100644 index 17cbc5c08..000000000 --- a/server/ssh/session_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package ssh - -import ( - "context" - "errors" - "fmt" - "os" - "testing" - "time" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/migrate" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/charmbracelet/soft-serve/server/store/database" - "github.com/charmbracelet/soft-serve/server/test" - "github.com/charmbracelet/ssh" - bm "github.com/charmbracelet/wish/bubbletea" - "github.com/charmbracelet/wish/testsession" - "github.com/matryer/is" - "github.com/muesli/termenv" - gossh "golang.org/x/crypto/ssh" - _ "modernc.org/sqlite" // sqlite driver -) - -func TestSession(t *testing.T) { - is := is.New(t) - t.Run("authorized repo access", func(t *testing.T) { - t.Log("setting up") - s, close := setup(t) - s.Stderr = os.Stderr - t.Log("requesting pty") - err := s.RequestPty("xterm", 80, 40, nil) - is.NoErr(err) - go func() { - time.Sleep(1 * time.Second) - // s.Signal(gossh.SIGTERM) - s.Close() // nolint: errcheck - }() - t.Log("waiting for session to exit") - _, err = s.Output("test") - var ee *gossh.ExitMissingError - is.True(errors.As(err, &ee)) - t.Log("session exited") - is.NoErr(close()) - }) -} - -func setup(tb testing.TB) (*gossh.Session, func() error) { - tb.Helper() - is := is.New(tb) - dp := tb.TempDir() - is.NoErr(os.Setenv("SOFT_SERVE_DATA_PATH", dp)) - is.NoErr(os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", ":9418")) - is.NoErr(os.Setenv("SOFT_SERVE_SSH_LISTEN_ADDR", fmt.Sprintf(":%d", test.RandomPort()))) - tb.Cleanup(func() { - is.NoErr(os.Unsetenv("SOFT_SERVE_DATA_PATH")) - is.NoErr(os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR")) - is.NoErr(os.Unsetenv("SOFT_SERVE_SSH_LISTEN_ADDR")) - is.NoErr(os.RemoveAll(dp)) - }) - ctx := context.TODO() - cfg := config.DefaultConfig() - if err := cfg.Validate(); err != nil { - log.Fatal(err) - } - ctx = config.WithContext(ctx, cfg) - dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource) - if err != nil { - tb.Fatal(err) - } - if err := migrate.Migrate(ctx, dbx); err != nil { - tb.Fatal(err) - } - dbstore := database.New(ctx, dbx) - ctx = store.WithContext(ctx, dbstore) - be := backend.New(ctx, cfg, dbx) - ctx = backend.WithContext(ctx, be) - return testsession.New(tb, &ssh.Server{ - Handler: ContextMiddleware(cfg, dbx, dbstore, be, log.Default())(bm.MiddlewareWithProgramHandler(SessionHandler, termenv.ANSI256)(func(s ssh.Session) { - _, _, active := s.Pty() - if !active { - os.Exit(1) - } - s.Exit(0) - })), - }, nil), dbx.Close -} diff --git a/server/ssh/ssh.go b/server/ssh/ssh.go deleted file mode 100644 index 4d57dc90b..000000000 --- a/server/ssh/ssh.go +++ /dev/null @@ -1,206 +0,0 @@ -package ssh - -import ( - "context" - "fmt" - "net" - "os" - "strconv" - "time" - - "github.com/charmbracelet/keygen" - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/charmbracelet/ssh" - "github.com/charmbracelet/wish" - bm "github.com/charmbracelet/wish/bubbletea" - rm "github.com/charmbracelet/wish/recover" - "github.com/muesli/termenv" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - gossh "golang.org/x/crypto/ssh" -) - -var ( - publicKeyCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "ssh", - Name: "public_key_auth_total", - Help: "The total number of public key auth requests", - }, []string{"allowed"}) - - keyboardInteractiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "ssh", - Name: "keyboard_interactive_auth_total", - Help: "The total number of keyboard interactive auth requests", - }, []string{"allowed"}) -) - -// SSHServer is a SSH server that implements the git protocol. -type SSHServer struct { // nolint: revive - srv *ssh.Server - cfg *config.Config - be *backend.Backend - ctx context.Context - logger *log.Logger -} - -// NewSSHServer returns a new SSHServer. -func NewSSHServer(ctx context.Context) (*SSHServer, error) { - cfg := config.FromContext(ctx) - logger := log.FromContext(ctx).WithPrefix("ssh") - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - be := backend.FromContext(ctx) - - var err error - s := &SSHServer{ - cfg: cfg, - ctx: ctx, - be: be, - logger: logger, - } - - mw := []wish.Middleware{ - rm.MiddlewareWithLogger( - logger, - // BubbleTea middleware. - bm.MiddlewareWithProgramHandler(SessionHandler, termenv.ANSI256), - // CLI middleware. - CommandMiddleware, - // Logging middleware. - LoggingMiddleware, - // Context middleware. - ContextMiddleware(cfg, dbx, datastore, be, logger), - // Authentication middleware. - // gossh.PublicKeyHandler doesn't guarantee that the public key - // is in fact the one used for authentication, so we need to - // check it again here. - AuthenticationMiddleware, - ), - } - - s.srv, err = wish.NewServer( - ssh.PublicKeyAuth(s.PublicKeyHandler), - ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler), - wish.WithAddress(cfg.SSH.ListenAddr), - wish.WithHostKeyPath(cfg.SSH.KeyPath), - wish.WithMiddleware(mw...), - ) - if err != nil { - return nil, err - } - - if config.IsDebug() { - s.srv.ServerConfigCallback = func(ctx ssh.Context) *gossh.ServerConfig { - return &gossh.ServerConfig{ - AuthLogCallback: func(conn gossh.ConnMetadata, method string, err error) { - logger.Debug("authentication", "user", conn.User(), "method", method, "err", err) - }, - } - } - } - - if cfg.SSH.MaxTimeout > 0 { - s.srv.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second - } - - if cfg.SSH.IdleTimeout > 0 { - s.srv.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second - } - - // Create client ssh key - if _, err := os.Stat(cfg.SSH.ClientKeyPath); err != nil && os.IsNotExist(err) { - _, err := keygen.New(cfg.SSH.ClientKeyPath, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite()) - if err != nil { - return nil, fmt.Errorf("client ssh key: %w", err) - } - } - - return s, nil -} - -// ListenAndServe starts the SSH server. -func (s *SSHServer) ListenAndServe() error { - return s.srv.ListenAndServe() -} - -// Serve starts the SSH server on the given net.Listener. -func (s *SSHServer) Serve(l net.Listener) error { - return s.srv.Serve(l) -} - -// Close closes the SSH server. -func (s *SSHServer) Close() error { - return s.srv.Close() -} - -// Shutdown gracefully shuts down the SSH server. -func (s *SSHServer) Shutdown(ctx context.Context) error { - return s.srv.Shutdown(ctx) -} - -func initializePermissions(ctx ssh.Context) { - perms := ctx.Permissions() - if perms == nil || perms.Permissions == nil { - perms = &ssh.Permissions{Permissions: &gossh.Permissions{}} - } - if perms.Extensions == nil { - perms.Extensions = make(map[string]string) - } - if perms.Permissions.Extensions == nil { - perms.Permissions.Extensions = make(map[string]string) - } -} - -// PublicKeyAuthHandler handles public key authentication. -func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed bool) { - if pk == nil { - return false - } - - defer func(allowed *bool) { - publicKeyCounter.WithLabelValues(strconv.FormatBool(*allowed)).Inc() - }(&allowed) - - user, _ := s.be.UserByPublicKey(ctx, pk) - if user != nil { - ctx.SetValue(proto.ContextKeyUser, user) - allowed = true - - // XXX: store the first "approved" public-key fingerprint in the - // permissions block to use for authentication later. - initializePermissions(ctx) - perms := ctx.Permissions() - - // Set the public key fingerprint to be used for authentication. - perms.Extensions["pubkey-fp"] = gossh.FingerprintSHA256(pk) - ctx.SetValue(ssh.ContextKeyPermissions, perms) - } - - return -} - -// KeyboardInteractiveHandler handles keyboard interactive authentication. -// This is used after all public key authentication has failed. -func (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool { - ac := s.be.AllowKeyless(ctx) - keyboardInteractiveCounter.WithLabelValues(strconv.FormatBool(ac)).Inc() - - // If we're allowing keyless access, reset the public key fingerprint - if ac { - initializePermissions(ctx) - perms := ctx.Permissions() - - // XXX: reset the public-key fingerprint. This is used to validate the - // public key being used to authenticate. - perms.Extensions["pubkey-fp"] = "" - ctx.SetValue(ssh.ContextKeyPermissions, perms) - } - return ac -} diff --git a/server/ssh/ui.go b/server/ssh/ui.go deleted file mode 100644 index 87c73ad71..000000000 --- a/server/ssh/ui.go +++ /dev/null @@ -1,332 +0,0 @@ -package ssh - -import ( - "errors" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/charmbracelet/soft-serve/server/ui/components/footer" - "github.com/charmbracelet/soft-serve/server/ui/components/header" - "github.com/charmbracelet/soft-serve/server/ui/components/selector" - "github.com/charmbracelet/soft-serve/server/ui/pages/repo" - "github.com/charmbracelet/soft-serve/server/ui/pages/selection" -) - -type page int - -const ( - selectionPage page = iota - repoPage -) - -type sessionState int - -const ( - loadingState sessionState = iota - errorState - readyState -) - -// UI is the main UI model. -type UI struct { - serverName string - initialRepo string - common common.Common - pages []common.Component - activePage page - state sessionState - header *header.Header - footer *footer.Footer - showFooter bool - error error -} - -// NewUI returns a new UI model. -func NewUI(c common.Common, initialRepo string) *UI { - serverName := c.Config().Name - h := header.New(c, serverName) - ui := &UI{ - serverName: serverName, - common: c, - pages: make([]common.Component, 2), // selection & repo - activePage: selectionPage, - state: loadingState, - header: h, - initialRepo: initialRepo, - showFooter: true, - } - ui.footer = footer.New(c, ui) - return ui -} - -func (ui *UI) getMargins() (wm, hm int) { - style := ui.common.Styles.App.Copy() - switch ui.activePage { - case selectionPage: - hm += ui.common.Styles.ServerName.GetHeight() + - ui.common.Styles.ServerName.GetVerticalFrameSize() - case repoPage: - } - wm += style.GetHorizontalFrameSize() - hm += style.GetVerticalFrameSize() - if ui.showFooter { - // NOTE: we don't use the footer's style to determine the margins - // because footer.Height() is the height of the footer after applying - // the styles. - hm += ui.footer.Height() - } - return -} - -// ShortHelp implements help.KeyMap. -func (ui *UI) ShortHelp() []key.Binding { - b := make([]key.Binding, 0) - switch ui.state { - case errorState: - b = append(b, ui.common.KeyMap.Back) - case readyState: - b = append(b, ui.pages[ui.activePage].ShortHelp()...) - } - if !ui.IsFiltering() { - b = append(b, ui.common.KeyMap.Quit) - } - b = append(b, ui.common.KeyMap.Help) - return b -} - -// FullHelp implements help.KeyMap. -func (ui *UI) FullHelp() [][]key.Binding { - b := make([][]key.Binding, 0) - switch ui.state { - case errorState: - b = append(b, []key.Binding{ui.common.KeyMap.Back}) - case readyState: - b = append(b, ui.pages[ui.activePage].FullHelp()...) - } - h := []key.Binding{ - ui.common.KeyMap.Help, - } - if !ui.IsFiltering() { - h = append(h, ui.common.KeyMap.Quit) - } - b = append(b, h) - return b -} - -// SetSize implements common.Component. -func (ui *UI) SetSize(width, height int) { - ui.common.SetSize(width, height) - wm, hm := ui.getMargins() - ui.header.SetSize(width-wm, height-hm) - ui.footer.SetSize(width-wm, height-hm) - for _, p := range ui.pages { - if p != nil { - p.SetSize(width-wm, height-hm) - } - } -} - -// Init implements tea.Model. -func (ui *UI) Init() tea.Cmd { - ui.pages[selectionPage] = selection.New(ui.common) - ui.pages[repoPage] = repo.New(ui.common, - repo.NewReadme(ui.common), - repo.NewFiles(ui.common), - repo.NewLog(ui.common), - repo.NewRefs(ui.common, git.RefsHeads), - repo.NewRefs(ui.common, git.RefsTags), - ) - ui.SetSize(ui.common.Width, ui.common.Height) - cmds := make([]tea.Cmd, 0) - cmds = append(cmds, - ui.pages[selectionPage].Init(), - ui.pages[repoPage].Init(), - ) - if ui.initialRepo != "" { - cmds = append(cmds, ui.initialRepoCmd(ui.initialRepo)) - } - ui.state = readyState - ui.SetSize(ui.common.Width, ui.common.Height) - return tea.Batch(cmds...) -} - -// IsFiltering returns true if the selection page is filtering. -func (ui *UI) IsFiltering() bool { - if ui.activePage == selectionPage { - if s, ok := ui.pages[selectionPage].(*selection.Selection); ok && s.FilterState() == list.Filtering { - return true - } - } - return false -} - -// Update implements tea.Model. -func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - ui.common.Logger.Debugf("msg received: %T", msg) - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case tea.WindowSizeMsg: - ui.SetSize(msg.Width, msg.Height) - for i, p := range ui.pages { - m, cmd := p.Update(msg) - ui.pages[i] = m.(common.Component) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - case tea.KeyMsg, tea.MouseMsg: - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil: - ui.error = nil - ui.state = readyState - // Always show the footer on error. - ui.showFooter = ui.footer.ShowAll() - case key.Matches(msg, ui.common.KeyMap.Help): - cmds = append(cmds, footer.ToggleFooterCmd) - case key.Matches(msg, ui.common.KeyMap.Quit): - if !ui.IsFiltering() { - // Stop bubblezone background workers. - ui.common.Zone.Close() - return ui, tea.Quit - } - case ui.activePage == repoPage && key.Matches(msg, ui.common.KeyMap.Back): - ui.activePage = selectionPage - // Always show the footer on selection page. - ui.showFooter = true - } - case tea.MouseMsg: - switch msg.Type { - case tea.MouseLeft: - switch { - case ui.common.Zone.Get("footer").InBounds(msg): - cmds = append(cmds, footer.ToggleFooterCmd) - } - } - } - case footer.ToggleFooterMsg: - ui.footer.SetShowAll(!ui.footer.ShowAll()) - // Show the footer when on repo page and shot all help. - if ui.error == nil && ui.activePage == repoPage { - ui.showFooter = !ui.showFooter - } - case repo.RepoMsg: - ui.common.SetValue(common.RepoKey, msg) - ui.activePage = repoPage - // Show the footer on repo page if show all is set. - ui.showFooter = ui.footer.ShowAll() - cmds = append(cmds, repo.UpdateRefCmd(msg)) - case common.ErrorMsg: - ui.error = msg - ui.state = errorState - ui.showFooter = true - case selector.SelectMsg: - switch msg.IdentifiableItem.(type) { - case selection.Item: - if ui.activePage == selectionPage { - cmds = append(cmds, ui.setRepoCmd(msg.ID())) - } - } - } - h, cmd := ui.header.Update(msg) - ui.header = h.(*header.Header) - if cmd != nil { - cmds = append(cmds, cmd) - } - f, cmd := ui.footer.Update(msg) - ui.footer = f.(*footer.Footer) - if cmd != nil { - cmds = append(cmds, cmd) - } - if ui.state != loadingState { - m, cmd := ui.pages[ui.activePage].Update(msg) - ui.pages[ui.activePage] = m.(common.Component) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - // This fixes determining the height margin of the footer. - ui.SetSize(ui.common.Width, ui.common.Height) - return ui, tea.Batch(cmds...) -} - -// View implements tea.Model. -func (ui *UI) View() string { - var view string - wm, hm := ui.getMargins() - switch ui.state { - case loadingState: - view = "Loading..." - case errorState: - err := ui.common.Styles.ErrorTitle.Render("Bummer") - err += ui.common.Styles.ErrorBody.Render(ui.error.Error()) - view = ui.common.Styles.Error.Copy(). - Width(ui.common.Width - - wm - - ui.common.Styles.ErrorBody.GetHorizontalFrameSize()). - Height(ui.common.Height - - hm - - ui.common.Styles.Error.GetVerticalFrameSize()). - Render(err) - case readyState: - view = ui.pages[ui.activePage].View() - default: - view = "Unknown state :/ this is a bug!" - } - if ui.activePage == selectionPage { - view = lipgloss.JoinVertical(lipgloss.Top, ui.header.View(), view) - } - if ui.showFooter { - view = lipgloss.JoinVertical(lipgloss.Top, view, ui.footer.View()) - } - return ui.common.Zone.Scan( - ui.common.Styles.App.Render(view), - ) -} - -func (ui *UI) openRepo(rn string) (proto.Repository, error) { - cfg := ui.common.Config() - if cfg == nil { - return nil, errors.New("config is nil") - } - - ctx := ui.common.Context() - be := ui.common.Backend() - repos, err := be.Repositories(ctx) - if err != nil { - ui.common.Logger.Debugf("ui: failed to list repos: %v", err) - return nil, err - } - for _, r := range repos { - if r.Name() == rn { - return r, nil - } - } - return nil, common.ErrMissingRepo -} - -func (ui *UI) setRepoCmd(rn string) tea.Cmd { - return func() tea.Msg { - r, err := ui.openRepo(rn) - if err != nil { - return common.ErrorMsg(err) - } - return repo.RepoMsg(r) - } -} - -func (ui *UI) initialRepoCmd(rn string) tea.Cmd { - return func() tea.Msg { - r, err := ui.openRepo(rn) - if err != nil { - return nil - } - return repo.RepoMsg(r) - } -} diff --git a/server/sshutils/utils.go b/server/sshutils/utils.go deleted file mode 100644 index 2686ac7bc..000000000 --- a/server/sshutils/utils.go +++ /dev/null @@ -1,51 +0,0 @@ -package sshutils - -import ( - "bytes" - "context" - - "github.com/charmbracelet/ssh" - gossh "golang.org/x/crypto/ssh" -) - -// ParseAuthorizedKey parses an authorized key string into a public key. -func ParseAuthorizedKey(ak string) (gossh.PublicKey, string, error) { - pk, c, _, _, err := gossh.ParseAuthorizedKey([]byte(ak)) - return pk, c, err -} - -// MarshalAuthorizedKey marshals a public key into an authorized key string. -// -// This is the inverse of ParseAuthorizedKey. -// This function is a copy of ssh.MarshalAuthorizedKey, but without the trailing newline. -// It returns an empty string if pk is nil. -func MarshalAuthorizedKey(pk gossh.PublicKey) string { - if pk == nil { - return "" - } - return string(bytes.TrimSuffix(gossh.MarshalAuthorizedKey(pk), []byte("\n"))) -} - -// KeysEqual returns whether the two public keys are equal. -func KeysEqual(a, b gossh.PublicKey) bool { - return ssh.KeysEqual(a, b) -} - -// PublicKeyFromContext returns the public key from the context. -func PublicKeyFromContext(ctx context.Context) gossh.PublicKey { - if pk, ok := ctx.Value(ssh.ContextKeyPublicKey).(gossh.PublicKey); ok { - return pk - } - return nil -} - -// ContextKeySession is the context key for the SSH session. -var ContextKeySession = &struct{ string }{"session"} - -// SessionFromContext returns the SSH session from the context. -func SessionFromContext(ctx context.Context) ssh.Session { - if s, ok := ctx.Value(ContextKeySession).(ssh.Session); ok { - return s - } - return nil -} diff --git a/server/sshutils/utils_test.go b/server/sshutils/utils_test.go deleted file mode 100644 index 96300ed08..000000000 --- a/server/sshutils/utils_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package sshutils - -import ( - "testing" - - "github.com/charmbracelet/keygen" - "golang.org/x/crypto/ssh" -) - -func generateKeys(tb testing.TB) (*keygen.SSHKeyPair, *keygen.SSHKeyPair) { - goodKey1, err := keygen.New("", keygen.WithKeyType(keygen.Ed25519)) - if err != nil { - tb.Fatal(err) - } - goodKey2, err := keygen.New("", keygen.WithKeyType(keygen.RSA)) - if err != nil { - tb.Fatal(err) - } - - return goodKey1, goodKey2 -} - -func TestParseAuthorizedKey(t *testing.T) { - goodKey1, goodKey2 := generateKeys(t) - cases := []struct { - in string - good bool - }{ - { - goodKey1.AuthorizedKey(), - true, - }, - { - goodKey2.AuthorizedKey(), - true, - }, - { - goodKey1.AuthorizedKey() + "test", - false, - }, - { - goodKey2.AuthorizedKey() + "bad", - false, - }, - } - for _, c := range cases { - _, _, err := ParseAuthorizedKey(c.in) - if c.good && err != nil { - t.Errorf("ParseAuthorizedKey(%q) returned error: %v", c.in, err) - } - if !c.good && err == nil { - t.Errorf("ParseAuthorizedKey(%q) did not return error", c.in) - } - } -} - -func TestMarshalAuthorizedKey(t *testing.T) { - goodKey1, goodKey2 := generateKeys(t) - cases := []struct { - in ssh.PublicKey - expected string - }{ - { - goodKey1.PublicKey(), - goodKey1.AuthorizedKey(), - }, - { - goodKey2.PublicKey(), - goodKey2.AuthorizedKey(), - }, - { - nil, - "", - }, - } - for _, c := range cases { - out := MarshalAuthorizedKey(c.in) - if out != c.expected { - t.Errorf("MarshalAuthorizedKey(%v) returned %q, expected %q", c.in, out, c.expected) - } - } -} - -func TestKeysEqual(t *testing.T) { - goodKey1, goodKey2 := generateKeys(t) - cases := []struct { - in1 ssh.PublicKey - in2 ssh.PublicKey - expected bool - }{ - { - goodKey1.PublicKey(), - goodKey1.PublicKey(), - true, - }, - { - goodKey2.PublicKey(), - goodKey2.PublicKey(), - true, - }, - { - goodKey1.PublicKey(), - goodKey2.PublicKey(), - false, - }, - { - nil, - nil, - false, - }, - { - nil, - goodKey1.PublicKey(), - false, - }, - } - - for _, c := range cases { - out := KeysEqual(c.in1, c.in2) - if out != c.expected { - t.Errorf("KeysEqual(%v, %v) returned %v, expected %v", c.in1, c.in2, out, c.expected) - } - } -} diff --git a/server/stats/stats.go b/server/stats/stats.go deleted file mode 100644 index 109e5207c..000000000 --- a/server/stats/stats.go +++ /dev/null @@ -1,51 +0,0 @@ -package stats - -import ( - "context" - "net/http" - "time" - - "github.com/charmbracelet/soft-serve/server/config" - "github.com/prometheus/client_golang/prometheus/promhttp" -) - -// StatsServer is a server for collecting and reporting statistics. -type StatsServer struct { //nolint:revive - ctx context.Context - cfg *config.Config - server *http.Server -} - -// NewStatsServer returns a new StatsServer. -func NewStatsServer(ctx context.Context) (*StatsServer, error) { - cfg := config.FromContext(ctx) - mux := http.NewServeMux() - mux.Handle("/metrics", promhttp.Handler()) - return &StatsServer{ - ctx: ctx, - cfg: cfg, - server: &http.Server{ - Addr: cfg.Stats.ListenAddr, - Handler: mux, - ReadHeaderTimeout: time.Second * 10, - ReadTimeout: time.Second * 10, - WriteTimeout: time.Second * 10, - MaxHeaderBytes: http.DefaultMaxHeaderBytes, - }, - }, nil -} - -// ListenAndServe starts the StatsServer. -func (s *StatsServer) ListenAndServe() error { - return s.server.ListenAndServe() -} - -// Shutdown gracefully shuts down the StatsServer. -func (s *StatsServer) Shutdown(ctx context.Context) error { - return s.server.Shutdown(ctx) -} - -// Close closes the StatsServer. -func (s *StatsServer) Close() error { - return s.server.Close() -} diff --git a/server/storage/local.go b/server/storage/local.go deleted file mode 100644 index 496f20e37..000000000 --- a/server/storage/local.go +++ /dev/null @@ -1,90 +0,0 @@ -package storage - -import ( - "errors" - "io" - "io/fs" - "os" - "path/filepath" - "strings" -) - -// LocalStorage is a storage implementation that stores objects on the local -// filesystem. -type LocalStorage struct { - root string -} - -var _ Storage = (*LocalStorage)(nil) - -// NewLocalStorage creates a new LocalStorage. -func NewLocalStorage(root string) *LocalStorage { - return &LocalStorage{root: root} -} - -// Delete implements Storage. -func (l *LocalStorage) Delete(name string) error { - name = l.fixPath(name) - return os.Remove(name) -} - -// Open implements Storage. -func (l *LocalStorage) Open(name string) (Object, error) { - name = l.fixPath(name) - return os.Open(name) -} - -// Stat implements Storage. -func (l *LocalStorage) Stat(name string) (fs.FileInfo, error) { - name = l.fixPath(name) - return os.Stat(name) -} - -// Put implements Storage. -func (l *LocalStorage) Put(name string, r io.Reader) (int64, error) { - name = l.fixPath(name) - if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil { - return 0, err - } - - f, err := os.Create(name) - if err != nil { - return 0, err - } - defer f.Close() // nolint: errcheck - return io.Copy(f, r) -} - -// Exists implements Storage. -func (l *LocalStorage) Exists(name string) (bool, error) { - name = l.fixPath(name) - _, err := os.Stat(name) - if err == nil { - return true, nil - } - if errors.Is(err, fs.ErrNotExist) { - return false, nil - } - return false, err -} - -// Rename implements Storage. -func (l *LocalStorage) Rename(oldName, newName string) error { - oldName = l.fixPath(oldName) - newName = l.fixPath(newName) - if err := os.MkdirAll(filepath.Dir(newName), os.ModePerm); err != nil { - return err - } - - return os.Rename(oldName, newName) -} - -// Replace all slashes with the OS-specific separator -func (l LocalStorage) fixPath(path string) string { - path = strings.ReplaceAll(path, "/", string(os.PathSeparator)) - if !filepath.IsAbs(path) { - return filepath.Join(l.root, path) - } - - return path -} diff --git a/server/storage/storage.go b/server/storage/storage.go deleted file mode 100644 index 9f8467262..000000000 --- a/server/storage/storage.go +++ /dev/null @@ -1,23 +0,0 @@ -package storage - -import ( - "io" - "io/fs" -) - -// Object is an interface for objects that can be stored. -type Object interface { - io.Seeker - fs.File - Name() string -} - -// Storage is an interface for storing and retrieving objects. -type Storage interface { - Open(name string) (Object, error) - Stat(name string) (fs.FileInfo, error) - Put(name string, r io.Reader) (int64, error) - Delete(name string) error - Exists(name string) (bool, error) - Rename(oldName, newName string) error -} diff --git a/server/store/access_token.go b/server/store/access_token.go deleted file mode 100644 index c502ea6b1..000000000 --- a/server/store/access_token.go +++ /dev/null @@ -1,19 +0,0 @@ -package store - -import ( - "context" - "time" - - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" -) - -// AccessTokenStore is an interface for managing access tokens. -type AccessTokenStore interface { - GetAccessToken(ctx context.Context, h db.Handler, id int64) (models.AccessToken, error) - GetAccessTokenByToken(ctx context.Context, h db.Handler, token string) (models.AccessToken, error) - GetAccessTokensByUserID(ctx context.Context, h db.Handler, userID int64) ([]models.AccessToken, error) - CreateAccessToken(ctx context.Context, h db.Handler, name string, userID int64, token string, expiresAt time.Time) (models.AccessToken, error) - DeleteAccessToken(ctx context.Context, h db.Handler, id int64) error - DeleteAccessTokenForUser(ctx context.Context, h db.Handler, userID int64, id int64) error -} diff --git a/server/store/collab.go b/server/store/collab.go deleted file mode 100644 index f8907e1ca..000000000 --- a/server/store/collab.go +++ /dev/null @@ -1,18 +0,0 @@ -package store - -import ( - "context" - - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" -) - -// CollaboratorStore is an interface for managing collaborators. -type CollaboratorStore interface { - GetCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) (models.Collab, error) - AddCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string, level access.AccessLevel) error - RemoveCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error - ListCollabsByRepo(ctx context.Context, h db.Handler, repo string) ([]models.Collab, error) - ListCollabsByRepoAsUsers(ctx context.Context, h db.Handler, repo string) ([]models.User, error) -} diff --git a/server/store/context.go b/server/store/context.go deleted file mode 100644 index 938c7dca0..000000000 --- a/server/store/context.go +++ /dev/null @@ -1,20 +0,0 @@ -package store - -import "context" - -// ContextKey is the store context key. -var ContextKey = &struct{ string }{"store"} - -// FromContext returns the store from the given context. -func FromContext(ctx context.Context) Store { - if s, ok := ctx.Value(ContextKey).(Store); ok { - return s - } - - return nil -} - -// WithContext returns a new context with the given store. -func WithContext(ctx context.Context, s Store) context.Context { - return context.WithValue(ctx, ContextKey, s) -} diff --git a/server/store/database/access_token.go b/server/store/database/access_token.go deleted file mode 100644 index 16f72b6aa..000000000 --- a/server/store/database/access_token.go +++ /dev/null @@ -1,74 +0,0 @@ -package database - -import ( - "context" - "time" - - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "github.com/charmbracelet/soft-serve/server/store" -) - -type accessTokenStore struct{} - -var _ store.AccessTokenStore = (*accessTokenStore)(nil) - -// CreateAccessToken implements store.AccessTokenStore. -func (s *accessTokenStore) CreateAccessToken(ctx context.Context, h db.Handler, name string, userID int64, token string, expiresAt time.Time) (models.AccessToken, error) { - queryWithoutExpires := `INSERT INTO access_tokens (name, user_id, token, created_at, updated_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING id` - queryWithExpires := `INSERT INTO access_tokens (name, user_id, token, expires_at, created_at, updated_at) - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING id` - - query := queryWithoutExpires - values := []interface{}{name, userID, token} - if !expiresAt.IsZero() { - query = queryWithExpires - values = append(values, expiresAt.UTC()) - } - - var id int64 - if err := h.GetContext(ctx, &id, h.Rebind(query), values...); err != nil { - return models.AccessToken{}, err - } - - return s.GetAccessToken(ctx, h, id) -} - -// DeleteAccessToken implements store.AccessTokenStore. -func (*accessTokenStore) DeleteAccessToken(ctx context.Context, h db.Handler, id int64) error { - query := h.Rebind(`DELETE FROM access_tokens WHERE id = ?`) - _, err := h.ExecContext(ctx, query, id) - return err -} - -// DeleteAccessTokenForUser implements store.AccessTokenStore. -func (*accessTokenStore) DeleteAccessTokenForUser(ctx context.Context, h db.Handler, userID int64, id int64) error { - query := h.Rebind(`DELETE FROM access_tokens WHERE user_id = ? AND id = ?`) - _, err := h.ExecContext(ctx, query, userID, id) - return err -} - -// GetAccessToken implements store.AccessTokenStore. -func (*accessTokenStore) GetAccessToken(ctx context.Context, h db.Handler, id int64) (models.AccessToken, error) { - query := h.Rebind(`SELECT * FROM access_tokens WHERE id = ?`) - var m models.AccessToken - err := h.GetContext(ctx, &m, query, id) - return m, err -} - -// GetAccessTokensByUserID implements store.AccessTokenStore. -func (*accessTokenStore) GetAccessTokensByUserID(ctx context.Context, h db.Handler, userID int64) ([]models.AccessToken, error) { - query := h.Rebind(`SELECT * FROM access_tokens WHERE user_id = ?`) - var m []models.AccessToken - err := h.SelectContext(ctx, &m, query, userID) - return m, err -} - -// GetAccessTokenByToken implements store.AccessTokenStore. -func (*accessTokenStore) GetAccessTokenByToken(ctx context.Context, h db.Handler, token string) (models.AccessToken, error) { - query := h.Rebind(`SELECT * FROM access_tokens WHERE token = ?`) - var m models.AccessToken - err := h.GetContext(ctx, &m, query, token) - return m, err -} diff --git a/server/store/database/collab.go b/server/store/database/collab.go deleted file mode 100644 index ea4e24a2f..000000000 --- a/server/store/database/collab.go +++ /dev/null @@ -1,126 +0,0 @@ -package database - -import ( - "context" - "strings" - - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/charmbracelet/soft-serve/server/utils" -) - -type collabStore struct{} - -var _ store.CollaboratorStore = (*collabStore)(nil) - -// AddCollabByUsernameAndRepo implements store.CollaboratorStore. -func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string, level access.AccessLevel) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - repo = utils.SanitizeRepo(repo) - - query := tx.Rebind(`INSERT INTO collabs (access_level, user_id, repo_id, updated_at) - VALUES ( - ?, - ( - SELECT id FROM users WHERE username = ? - ), - ( - SELECT id FROM repos WHERE name = ? - ), - CURRENT_TIMESTAMP - );`) - _, err := tx.ExecContext(ctx, query, level, username, repo) - return err -} - -// GetCollabByUsernameAndRepo implements store.CollaboratorStore. -func (*collabStore) GetCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string) (models.Collab, error) { - var m models.Collab - - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return models.Collab{}, err - } - - repo = utils.SanitizeRepo(repo) - - err := tx.GetContext(ctx, &m, tx.Rebind(` - SELECT - collabs.* - FROM - collabs - INNER JOIN users ON users.id = collabs.user_id - INNER JOIN repos ON repos.id = collabs.repo_id - WHERE - users.username = ? AND repos.name = ? - `), username, repo) - - return m, err -} - -// ListCollabsByRepo implements store.CollaboratorStore. -func (*collabStore) ListCollabsByRepo(ctx context.Context, tx db.Handler, repo string) ([]models.Collab, error) { - var m []models.Collab - - repo = utils.SanitizeRepo(repo) - query := tx.Rebind(` - SELECT - collabs.* - FROM - collabs - INNER JOIN repos ON repos.id = collabs.repo_id - WHERE - repos.name = ? - `) - - err := tx.SelectContext(ctx, &m, query, repo) - return m, err -} - -// ListCollabsByRepoAsUsers implements store.CollaboratorStore. -func (*collabStore) ListCollabsByRepoAsUsers(ctx context.Context, tx db.Handler, repo string) ([]models.User, error) { - var m []models.User - - repo = utils.SanitizeRepo(repo) - query := tx.Rebind(` - SELECT - users.* - FROM - users - INNER JOIN collabs ON collabs.user_id = users.id - INNER JOIN repos ON repos.id = collabs.repo_id - WHERE - repos.name = ? - `) - - err := tx.SelectContext(ctx, &m, query, repo) - return m, err -} - -// RemoveCollabByUsernameAndRepo implements store.CollaboratorStore. -func (*collabStore) RemoveCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - repo = utils.SanitizeRepo(repo) - query := tx.Rebind(` - DELETE FROM - collabs - WHERE - user_id = ( - SELECT id FROM users WHERE username = ? - ) AND repo_id = ( - SELECT id FROM repos WHERE name = ? - ) - `) - _, err := tx.ExecContext(ctx, query, username, repo) - return err -} diff --git a/server/store/database/database.go b/server/store/database/database.go deleted file mode 100644 index ab6155e8e..000000000 --- a/server/store/database/database.go +++ /dev/null @@ -1,47 +0,0 @@ -package database - -import ( - "context" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/store" -) - -type datastore struct { - ctx context.Context - cfg *config.Config - db *db.DB - logger *log.Logger - - *settingsStore - *repoStore - *userStore - *collabStore - *lfsStore - *accessTokenStore - *webhookStore -} - -// New returns a new store.Store database. -func New(ctx context.Context, db *db.DB) store.Store { - cfg := config.FromContext(ctx) - logger := log.FromContext(ctx).WithPrefix("store") - - s := &datastore{ - ctx: ctx, - cfg: cfg, - db: db, - logger: logger, - - settingsStore: &settingsStore{}, - repoStore: &repoStore{}, - userStore: &userStore{}, - collabStore: &collabStore{}, - lfsStore: &lfsStore{}, - accessTokenStore: &accessTokenStore{}, - } - - return s -} diff --git a/server/store/database/lfs.go b/server/store/database/lfs.go deleted file mode 100644 index 64fef3716..000000000 --- a/server/store/database/lfs.go +++ /dev/null @@ -1,199 +0,0 @@ -package database - -import ( - "context" - "strings" - - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "github.com/charmbracelet/soft-serve/server/store" -) - -type lfsStore struct{} - -var _ store.LFSStore = (*lfsStore)(nil) - -func sanitizePath(path string) string { - path = strings.TrimSpace(path) - path = strings.TrimPrefix(path, "/") - return path -} - -// CreateLFSLockForUser implements store.LFSStore. -func (*lfsStore) CreateLFSLockForUser(ctx context.Context, tx db.Handler, repoID int64, userID int64, path string, refname string) error { - path = sanitizePath(path) - query := tx.Rebind(`INSERT INTO lfs_locks (repo_id, user_id, path, refname, updated_at) - VALUES ( - ?, - ?, - ?, - ?, - CURRENT_TIMESTAMP - ); - `) - _, err := tx.ExecContext(ctx, query, repoID, userID, path, refname) - return db.WrapError(err) -} - -// GetLFSLocks implements store.LFSStore. -func (*lfsStore) GetLFSLocks(ctx context.Context, tx db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error) { - if page <= 0 { - page = 1 - } - - var locks []models.LFSLock - query := tx.Rebind(` - SELECT * - FROM lfs_locks - WHERE repo_id = ? - ORDER BY updated_at DESC - LIMIT ? OFFSET ?; - `) - err := tx.SelectContext(ctx, &locks, query, repoID, limit, (page-1)*limit) - return locks, db.WrapError(err) -} - -func (s *lfsStore) GetLFSLocksWithCount(ctx context.Context, tx db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error) { - locks, err := s.GetLFSLocks(ctx, tx, repoID, page, limit) - if err != nil { - return nil, 0, err - } - - var count int64 - query := tx.Rebind(` - SELECT COUNT(*) - FROM lfs_locks - WHERE repo_id = ?; - `) - err = tx.GetContext(ctx, &count, query, repoID) - if err != nil { - return nil, 0, db.WrapError(err) - } - - return locks, count, nil -} - -// GetLFSLocksForUser implements store.LFSStore. -func (*lfsStore) GetLFSLocksForUser(ctx context.Context, tx db.Handler, repoID int64, userID int64) ([]models.LFSLock, error) { - var locks []models.LFSLock - query := tx.Rebind(` - SELECT * - FROM lfs_locks - WHERE repo_id = ? AND user_id = ?; - `) - err := tx.SelectContext(ctx, &locks, query, repoID, userID) - return locks, db.WrapError(err) -} - -// GetLFSLocksForPath implements store.LFSStore. -func (*lfsStore) GetLFSLockForPath(ctx context.Context, tx db.Handler, repoID int64, path string) (models.LFSLock, error) { - path = sanitizePath(path) - var lock models.LFSLock - query := tx.Rebind(` - SELECT * - FROM lfs_locks - WHERE repo_id = ? AND path = ?; - `) - err := tx.GetContext(ctx, &lock, query, repoID, path) - return lock, db.WrapError(err) -} - -// GetLFSLockForUserPath implements store.LFSStore. -func (*lfsStore) GetLFSLockForUserPath(ctx context.Context, tx db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error) { - path = sanitizePath(path) - var lock models.LFSLock - query := tx.Rebind(` - SELECT * - FROM lfs_locks - WHERE repo_id = ? AND user_id = ? AND path = ?; - `) - err := tx.GetContext(ctx, &lock, query, repoID, userID, path) - return lock, db.WrapError(err) -} - -// GetLFSLockByID implements store.LFSStore. -func (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, id int64) (models.LFSLock, error) { - var lock models.LFSLock - query := tx.Rebind(` - SELECT * - FROM lfs_locks - WHERE lfs_locks.id = ?; - `) - err := tx.GetContext(ctx, &lock, query, id) - return lock, db.WrapError(err) -} - -// GetLFSLockForUserByID implements store.LFSStore. -func (*lfsStore) GetLFSLockForUserByID(ctx context.Context, tx db.Handler, repoID int64, userID int64, id int64) (models.LFSLock, error) { - var lock models.LFSLock - query := tx.Rebind(` - SELECT * - FROM lfs_locks - WHERE id = ? AND user_id = ? AND repo_id = ?; - `) - err := tx.GetContext(ctx, &lock, query, id, userID, repoID) - return lock, db.WrapError(err) -} - -// DeleteLFSLockForUserByID implements store.LFSStore. -func (*lfsStore) DeleteLFSLockForUserByID(ctx context.Context, tx db.Handler, repoID int64, userID int64, id int64) error { - query := tx.Rebind(` - DELETE FROM lfs_locks - WHERE repo_id = ? AND user_id = ? AND id = ?; - `) - _, err := tx.ExecContext(ctx, query, repoID, userID, id) - return db.WrapError(err) -} - -// DeleteLFSLock implements store.LFSStore. -func (*lfsStore) DeleteLFSLock(ctx context.Context, tx db.Handler, repoID int64, id int64) error { - query := tx.Rebind(` - DELETE FROM lfs_locks - WHERE repo_id = ? AND id = ?; - `) - _, err := tx.ExecContext(ctx, query, repoID, id) - return db.WrapError(err) -} - -// CreateLFSObject implements store.LFSStore. -func (*lfsStore) CreateLFSObject(ctx context.Context, tx db.Handler, repoID int64, oid string, size int64) error { - query := tx.Rebind(`INSERT INTO lfs_objects (repo_id, oid, size, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP);`) - _, err := tx.ExecContext(ctx, query, repoID, oid, size) - return db.WrapError(err) -} - -// DeleteLFSObjectByOid implements store.LFSStore. -func (*lfsStore) DeleteLFSObjectByOid(ctx context.Context, tx db.Handler, repoID int64, oid string) error { - query := tx.Rebind(`DELETE FROM lfs_objects WHERE repo_id = ? AND oid = ?;`) - _, err := tx.ExecContext(ctx, query, repoID, oid) - return db.WrapError(err) -} - -// GetLFSObjectByOid implements store.LFSStore. -func (*lfsStore) GetLFSObjectByOid(ctx context.Context, tx db.Handler, repoID int64, oid string) (models.LFSObject, error) { - var obj models.LFSObject - query := tx.Rebind(`SELECT * FROM lfs_objects WHERE repo_id = ? AND oid = ?;`) - err := tx.GetContext(ctx, &obj, query, repoID, oid) - return obj, db.WrapError(err) -} - -// GetLFSObjects implements store.LFSStore. -func (*lfsStore) GetLFSObjects(ctx context.Context, tx db.Handler, repoID int64) ([]models.LFSObject, error) { - var objs []models.LFSObject - query := tx.Rebind(`SELECT * FROM lfs_objects WHERE repo_id = ?;`) - err := tx.SelectContext(ctx, &objs, query, repoID) - return objs, db.WrapError(err) -} - -// GetLFSObjectsByName implements store.LFSStore. -func (*lfsStore) GetLFSObjectsByName(ctx context.Context, tx db.Handler, name string) ([]models.LFSObject, error) { - var objs []models.LFSObject - query := tx.Rebind(` - SELECT lfs_objects.* - FROM lfs_objects - INNER JOIN repos ON lfs_objects.repo_id = repos.id - WHERE repos.name = ?; - `) - err := tx.SelectContext(ctx, &objs, query, name) - return objs, db.WrapError(err) -} diff --git a/server/store/database/repo.go b/server/store/database/repo.go deleted file mode 100644 index 03c080444..000000000 --- a/server/store/database/repo.go +++ /dev/null @@ -1,152 +0,0 @@ -package database - -import ( - "context" - - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/charmbracelet/soft-serve/server/utils" -) - -type repoStore struct{} - -var _ store.RepositoryStore = (*repoStore)(nil) - -// CreateRepo implements store.RepositoryStore. -func (*repoStore) CreateRepo(ctx context.Context, tx db.Handler, name string, userID int64, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error { - name = utils.SanitizeRepo(name) - values := []interface{}{ - name, projectName, description, isPrivate, isMirror, isHidden, - } - query := `INSERT INTO repos (name, project_name, description, private, mirror, hidden, updated_at) - VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);` - if userID > 0 { - query = `INSERT INTO repos (name, project_name, description, private, mirror, hidden, updated_at, user_id) - VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?);` - values = append(values, userID) - } - - query = tx.Rebind(query) - _, err := tx.ExecContext(ctx, query, values...) - return db.WrapError(err) -} - -// DeleteRepoByName implements store.RepositoryStore. -func (*repoStore) DeleteRepoByName(ctx context.Context, tx db.Handler, name string) error { - name = utils.SanitizeRepo(name) - query := tx.Rebind("DELETE FROM repos WHERE name = ?;") - _, err := tx.ExecContext(ctx, query, name) - return db.WrapError(err) -} - -// GetAllRepos implements store.RepositoryStore. -func (*repoStore) GetAllRepos(ctx context.Context, tx db.Handler) ([]models.Repo, error) { - var repos []models.Repo - query := tx.Rebind("SELECT * FROM repos;") - err := tx.SelectContext(ctx, &repos, query) - return repos, db.WrapError(err) -} - -// GetUserRepos implements store.RepositoryStore. -func (*repoStore) GetUserRepos(ctx context.Context, tx db.Handler, userID int64) ([]models.Repo, error) { - var repos []models.Repo - query := tx.Rebind("SELECT * FROM repos WHERE user_id = ?;") - err := tx.SelectContext(ctx, &repos, query, userID) - return repos, db.WrapError(err) -} - -// GetRepoByName implements store.RepositoryStore. -func (*repoStore) GetRepoByName(ctx context.Context, tx db.Handler, name string) (models.Repo, error) { - var repo models.Repo - name = utils.SanitizeRepo(name) - query := tx.Rebind("SELECT * FROM repos WHERE name = ?;") - err := tx.GetContext(ctx, &repo, query, name) - return repo, db.WrapError(err) -} - -// GetRepoDescriptionByName implements store.RepositoryStore. -func (*repoStore) GetRepoDescriptionByName(ctx context.Context, tx db.Handler, name string) (string, error) { - var description string - name = utils.SanitizeRepo(name) - query := tx.Rebind("SELECT description FROM repos WHERE name = ?;") - err := tx.GetContext(ctx, &description, query, name) - return description, db.WrapError(err) -} - -// GetRepoIsHiddenByName implements store.RepositoryStore. -func (*repoStore) GetRepoIsHiddenByName(ctx context.Context, tx db.Handler, name string) (bool, error) { - var isHidden bool - name = utils.SanitizeRepo(name) - query := tx.Rebind("SELECT hidden FROM repos WHERE name = ?;") - err := tx.GetContext(ctx, &isHidden, query, name) - return isHidden, db.WrapError(err) -} - -// GetRepoIsMirrorByName implements store.RepositoryStore. -func (*repoStore) GetRepoIsMirrorByName(ctx context.Context, tx db.Handler, name string) (bool, error) { - var isMirror bool - name = utils.SanitizeRepo(name) - query := tx.Rebind("SELECT mirror FROM repos WHERE name = ?;") - err := tx.GetContext(ctx, &isMirror, query, name) - return isMirror, db.WrapError(err) -} - -// GetRepoIsPrivateByName implements store.RepositoryStore. -func (*repoStore) GetRepoIsPrivateByName(ctx context.Context, tx db.Handler, name string) (bool, error) { - var isPrivate bool - name = utils.SanitizeRepo(name) - query := tx.Rebind("SELECT private FROM repos WHERE name = ?;") - err := tx.GetContext(ctx, &isPrivate, query, name) - return isPrivate, db.WrapError(err) -} - -// GetRepoProjectNameByName implements store.RepositoryStore. -func (*repoStore) GetRepoProjectNameByName(ctx context.Context, tx db.Handler, name string) (string, error) { - var pname string - name = utils.SanitizeRepo(name) - query := tx.Rebind("SELECT project_name FROM repos WHERE name = ?;") - err := tx.GetContext(ctx, &pname, query, name) - return pname, db.WrapError(err) -} - -// SetRepoDescriptionByName implements store.RepositoryStore. -func (*repoStore) SetRepoDescriptionByName(ctx context.Context, tx db.Handler, name string, description string) error { - name = utils.SanitizeRepo(name) - query := tx.Rebind("UPDATE repos SET description = ? WHERE name = ?;") - _, err := tx.ExecContext(ctx, query, description, name) - return db.WrapError(err) -} - -// SetRepoIsHiddenByName implements store.RepositoryStore. -func (*repoStore) SetRepoIsHiddenByName(ctx context.Context, tx db.Handler, name string, isHidden bool) error { - name = utils.SanitizeRepo(name) - query := tx.Rebind("UPDATE repos SET hidden = ? WHERE name = ?;") - _, err := tx.ExecContext(ctx, query, isHidden, name) - return db.WrapError(err) -} - -// SetRepoIsPrivateByName implements store.RepositoryStore. -func (*repoStore) SetRepoIsPrivateByName(ctx context.Context, tx db.Handler, name string, isPrivate bool) error { - name = utils.SanitizeRepo(name) - query := tx.Rebind("UPDATE repos SET private = ? WHERE name = ?;") - _, err := tx.ExecContext(ctx, query, isPrivate, name) - return db.WrapError(err) -} - -// SetRepoNameByName implements store.RepositoryStore. -func (*repoStore) SetRepoNameByName(ctx context.Context, tx db.Handler, name string, newName string) error { - name = utils.SanitizeRepo(name) - newName = utils.SanitizeRepo(newName) - query := tx.Rebind("UPDATE repos SET name = ? WHERE name = ?;") - _, err := tx.ExecContext(ctx, query, newName, name) - return db.WrapError(err) -} - -// SetRepoProjectNameByName implements store.RepositoryStore. -func (*repoStore) SetRepoProjectNameByName(ctx context.Context, tx db.Handler, name string, projectName string) error { - name = utils.SanitizeRepo(name) - query := tx.Rebind("UPDATE repos SET project_name = ? WHERE name = ?;") - _, err := tx.ExecContext(ctx, query, projectName, name) - return db.WrapError(err) -} diff --git a/server/store/database/settings.go b/server/store/database/settings.go deleted file mode 100644 index ace96f717..000000000 --- a/server/store/database/settings.go +++ /dev/null @@ -1,47 +0,0 @@ -package database - -import ( - "context" - - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/store" -) - -type settingsStore struct{} - -var _ store.SettingStore = (*settingsStore)(nil) - -// GetAllowKeylessAccess implements store.SettingStore. -func (*settingsStore) GetAllowKeylessAccess(ctx context.Context, tx db.Handler) (bool, error) { - var allow bool - query := tx.Rebind(`SELECT value FROM settings WHERE "key" = 'allow_keyless'`) - if err := tx.GetContext(ctx, &allow, query); err != nil { - return false, db.WrapError(err) - } - return allow, nil -} - -// GetAnonAccess implements store.SettingStore. -func (*settingsStore) GetAnonAccess(ctx context.Context, tx db.Handler) (access.AccessLevel, error) { - var level string - query := tx.Rebind(`SELECT value FROM settings WHERE "key" = 'anon_access'`) - if err := tx.GetContext(ctx, &level, query); err != nil { - return access.NoAccess, db.WrapError(err) - } - return access.ParseAccessLevel(level), nil -} - -// SetAllowKeylessAccess implements store.SettingStore. -func (*settingsStore) SetAllowKeylessAccess(ctx context.Context, tx db.Handler, allow bool) error { - query := tx.Rebind(`UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE "key" = 'allow_keyless'`) - _, err := tx.ExecContext(ctx, query, allow) - return db.WrapError(err) -} - -// SetAnonAccess implements store.SettingStore. -func (*settingsStore) SetAnonAccess(ctx context.Context, tx db.Handler, level access.AccessLevel) error { - query := tx.Rebind(`UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE "key" = 'anon_access'`) - _, err := tx.ExecContext(ctx, query, level.String()) - return db.WrapError(err) -} diff --git a/server/store/database/user.go b/server/store/database/user.go deleted file mode 100644 index 95572010d..000000000 --- a/server/store/database/user.go +++ /dev/null @@ -1,242 +0,0 @@ -package database - -import ( - "context" - "strings" - - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "github.com/charmbracelet/soft-serve/server/sshutils" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/charmbracelet/soft-serve/server/utils" - "golang.org/x/crypto/ssh" -) - -type userStore struct{} - -var _ store.UserStore = (*userStore)(nil) - -// AddPublicKeyByUsername implements store.UserStore. -func (*userStore) AddPublicKeyByUsername(ctx context.Context, tx db.Handler, username string, pk ssh.PublicKey) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - var userID int64 - if err := tx.GetContext(ctx, &userID, tx.Rebind(`SELECT id FROM users WHERE username = ?`), username); err != nil { - return err - } - - query := tx.Rebind(`INSERT INTO public_keys (user_id, public_key, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP);`) - ak := sshutils.MarshalAuthorizedKey(pk) - _, err := tx.ExecContext(ctx, query, userID, ak) - - return err -} - -// CreateUser implements store.UserStore. -func (*userStore) CreateUser(ctx context.Context, tx db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - query := tx.Rebind(`INSERT INTO users (username, admin, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP) RETURNING id;`) - - var userID int64 - if err := tx.GetContext(ctx, &userID, query, username, isAdmin); err != nil { - return err - } - - for _, pk := range pks { - query := tx.Rebind(`INSERT INTO public_keys (user_id, public_key, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP);`) - ak := sshutils.MarshalAuthorizedKey(pk) - _, err := tx.ExecContext(ctx, query, userID, ak) - if err != nil { - return err - } - } - - return nil -} - -// DeleteUserByUsername implements store.UserStore. -func (*userStore) DeleteUserByUsername(ctx context.Context, tx db.Handler, username string) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - query := tx.Rebind(`DELETE FROM users WHERE username = ?;`) - _, err := tx.ExecContext(ctx, query, username) - return err -} - -// GetUserByID implements store.UserStore. -func (*userStore) GetUserByID(ctx context.Context, tx db.Handler, id int64) (models.User, error) { - var m models.User - query := tx.Rebind(`SELECT * FROM users WHERE id = ?;`) - err := tx.GetContext(ctx, &m, query, id) - return m, err -} - -// FindUserByPublicKey implements store.UserStore. -func (*userStore) FindUserByPublicKey(ctx context.Context, tx db.Handler, pk ssh.PublicKey) (models.User, error) { - var m models.User - query := tx.Rebind(`SELECT users.* - FROM users - INNER JOIN public_keys ON users.id = public_keys.user_id - WHERE public_keys.public_key = ?;`) - err := tx.GetContext(ctx, &m, query, sshutils.MarshalAuthorizedKey(pk)) - return m, err -} - -// FindUserByUsername implements store.UserStore. -func (*userStore) FindUserByUsername(ctx context.Context, tx db.Handler, username string) (models.User, error) { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return models.User{}, err - } - - var m models.User - query := tx.Rebind(`SELECT * FROM users WHERE username = ?;`) - err := tx.GetContext(ctx, &m, query, username) - return m, err -} - -// FindUserByAccessToken implements store.UserStore. -func (*userStore) FindUserByAccessToken(ctx context.Context, tx db.Handler, token string) (models.User, error) { - var m models.User - query := tx.Rebind(`SELECT users.* - FROM users - INNER JOIN access_tokens ON users.id = access_tokens.user_id - WHERE access_tokens.token = ?;`) - err := tx.GetContext(ctx, &m, query, token) - return m, err -} - -// GetAllUsers implements store.UserStore. -func (*userStore) GetAllUsers(ctx context.Context, tx db.Handler) ([]models.User, error) { - var ms []models.User - query := tx.Rebind(`SELECT * FROM users;`) - err := tx.SelectContext(ctx, &ms, query) - return ms, err -} - -// ListPublicKeysByUserID implements store.UserStore.. -func (*userStore) ListPublicKeysByUserID(ctx context.Context, tx db.Handler, id int64) ([]ssh.PublicKey, error) { - var aks []string - query := tx.Rebind(`SELECT public_key FROM public_keys - WHERE user_id = ? - ORDER BY public_keys.id ASC;`) - err := tx.SelectContext(ctx, &aks, query, id) - if err != nil { - return nil, err - } - - pks := make([]ssh.PublicKey, len(aks)) - for i, ak := range aks { - pk, _, err := sshutils.ParseAuthorizedKey(ak) - if err != nil { - return nil, err - } - pks[i] = pk - } - - return pks, nil -} - -// ListPublicKeysByUsername implements store.UserStore. -func (*userStore) ListPublicKeysByUsername(ctx context.Context, tx db.Handler, username string) ([]ssh.PublicKey, error) { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return nil, err - } - - var aks []string - query := tx.Rebind(`SELECT public_key FROM public_keys - INNER JOIN users ON users.id = public_keys.user_id - WHERE users.username = ? - ORDER BY public_keys.id ASC;`) - err := tx.SelectContext(ctx, &aks, query, username) - if err != nil { - return nil, err - } - - pks := make([]ssh.PublicKey, len(aks)) - for i, ak := range aks { - pk, _, err := sshutils.ParseAuthorizedKey(ak) - if err != nil { - return nil, err - } - pks[i] = pk - } - - return pks, nil -} - -// RemovePublicKeyByUsername implements store.UserStore. -func (*userStore) RemovePublicKeyByUsername(ctx context.Context, tx db.Handler, username string, pk ssh.PublicKey) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - query := tx.Rebind(`DELETE FROM public_keys - WHERE user_id = (SELECT id FROM users WHERE username = ?) - AND public_key = ?;`) - _, err := tx.ExecContext(ctx, query, username, sshutils.MarshalAuthorizedKey(pk)) - return err -} - -// SetAdminByUsername implements store.UserStore. -func (*userStore) SetAdminByUsername(ctx context.Context, tx db.Handler, username string, isAdmin bool) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - query := tx.Rebind(`UPDATE users SET admin = ? WHERE username = ?;`) - _, err := tx.ExecContext(ctx, query, isAdmin, username) - return err -} - -// SetUsernameByUsername implements store.UserStore. -func (*userStore) SetUsernameByUsername(ctx context.Context, tx db.Handler, username string, newUsername string) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - newUsername = strings.ToLower(newUsername) - if err := utils.ValidateUsername(newUsername); err != nil { - return err - } - - query := tx.Rebind(`UPDATE users SET username = ? WHERE username = ?;`) - _, err := tx.ExecContext(ctx, query, newUsername, username) - return err -} - -// SetUserPassword implements store.UserStore. -func (*userStore) SetUserPassword(ctx context.Context, tx db.Handler, userID int64, password string) error { - query := tx.Rebind(`UPDATE users SET password = ? WHERE id = ?;`) - _, err := tx.ExecContext(ctx, query, password, userID) - return err -} - -// SetUserPasswordByUsername implements store.UserStore. -func (*userStore) SetUserPasswordByUsername(ctx context.Context, tx db.Handler, username string, password string) error { - username = strings.ToLower(username) - if err := utils.ValidateUsername(username); err != nil { - return err - } - - query := tx.Rebind(`UPDATE users SET password = ? WHERE username = ?;`) - _, err := tx.ExecContext(ctx, query, password, username) - return err -} diff --git a/server/store/database/webhooks.go b/server/store/database/webhooks.go deleted file mode 100644 index abaa25851..000000000 --- a/server/store/database/webhooks.go +++ /dev/null @@ -1,165 +0,0 @@ -package database - -import ( - "context" - - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/google/uuid" - "github.com/jmoiron/sqlx" -) - -type webhookStore struct{} - -var _ store.WebhookStore = (*webhookStore)(nil) - -// CreateWebhook implements store.WebhookStore. -func (*webhookStore) CreateWebhook(ctx context.Context, h db.Handler, repoID int64, url string, secret string, contentType int, active bool) (int64, error) { - var id int64 - query := h.Rebind(`INSERT INTO webhooks (repo_id, url, secret, content_type, active, updated_at) - VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) RETURNING id;`) - err := h.GetContext(ctx, &id, query, repoID, url, secret, contentType, active) - if err != nil { - return 0, err - } - - return id, nil -} - -// CreateWebhookDelivery implements store.WebhookStore. -func (*webhookStore) CreateWebhookDelivery(ctx context.Context, h db.Handler, id uuid.UUID, webhookID int64, event int, url string, method string, requestError error, requestHeaders string, requestBody string, responseStatus int, responseHeaders string, responseBody string) error { - query := h.Rebind(`INSERT INTO webhook_deliveries (id, webhook_id, event, request_url, request_method, request_error, request_headers, request_body, response_status, response_headers, response_body) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`) - var reqErr string - if requestError != nil { - reqErr = requestError.Error() - } - _, err := h.ExecContext(ctx, query, id, webhookID, event, url, method, reqErr, requestHeaders, requestBody, responseStatus, responseHeaders, responseBody) - return err -} - -// CreateWebhookEvents implements store.WebhookStore. -func (*webhookStore) CreateWebhookEvents(ctx context.Context, h db.Handler, webhookID int64, events []int) error { - query := h.Rebind(`INSERT INTO webhook_events (webhook_id, event) - VALUES (?, ?);`) - for _, event := range events { - _, err := h.ExecContext(ctx, query, webhookID, event) - if err != nil { - return err - } - } - return nil -} - -// DeleteWebhookByID implements store.WebhookStore. -func (*webhookStore) DeleteWebhookByID(ctx context.Context, h db.Handler, id int64) error { - query := h.Rebind(`DELETE FROM webhooks WHERE id = ?;`) - _, err := h.ExecContext(ctx, query, id) - return err -} - -// DeleteWebhookForRepoByID implements store.WebhookStore. -func (*webhookStore) DeleteWebhookForRepoByID(ctx context.Context, h db.Handler, repoID int64, id int64) error { - query := h.Rebind(`DELETE FROM webhooks WHERE repo_id = ? AND id = ?;`) - _, err := h.ExecContext(ctx, query, repoID, id) - return err -} - -// DeleteWebhookDeliveryByID implements store.WebhookStore. -func (*webhookStore) DeleteWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) error { - query := h.Rebind(`DELETE FROM webhook_deliveries WHERE webhook_id = ? AND id = ?;`) - _, err := h.ExecContext(ctx, query, webhookID, id) - return err -} - -// DeleteWebhookEventsByWebhookID implements store.WebhookStore. -func (*webhookStore) DeleteWebhookEventsByID(ctx context.Context, h db.Handler, ids []int64) error { - query, args, err := sqlx.In(`DELETE FROM webhook_events WHERE id IN (?);`, ids) - if err != nil { - return err - } - - query = h.Rebind(query) - _, err = h.ExecContext(ctx, query, args...) - return err -} - -// GetWebhookByID implements store.WebhookStore. -func (*webhookStore) GetWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64) (models.Webhook, error) { - query := h.Rebind(`SELECT * FROM webhooks WHERE repo_id = ? AND id = ?;`) - var wh models.Webhook - err := h.GetContext(ctx, &wh, query, repoID, id) - return wh, err -} - -// GetWebhookDeliveriesByWebhookID implements store.WebhookStore. -func (*webhookStore) GetWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error) { - query := h.Rebind(`SELECT * FROM webhook_deliveries WHERE webhook_id = ?;`) - var whds []models.WebhookDelivery - err := h.SelectContext(ctx, &whds, query, webhookID) - return whds, err -} - -// GetWebhookDeliveryByID implements store.WebhookStore. -func (*webhookStore) GetWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) (models.WebhookDelivery, error) { - query := h.Rebind(`SELECT * FROM webhook_deliveries WHERE webhook_id = ? AND id = ?;`) - var whd models.WebhookDelivery - err := h.GetContext(ctx, &whd, query, webhookID, id) - return whd, err -} - -// GetWebhookEventByID implements store.WebhookStore. -func (*webhookStore) GetWebhookEventByID(ctx context.Context, h db.Handler, id int64) (models.WebhookEvent, error) { - query := h.Rebind(`SELECT * FROM webhook_events WHERE id = ?;`) - var whe models.WebhookEvent - err := h.GetContext(ctx, &whe, query, id) - return whe, err -} - -// GetWebhookEventsByWebhookID implements store.WebhookStore. -func (*webhookStore) GetWebhookEventsByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookEvent, error) { - query := h.Rebind(`SELECT * FROM webhook_events WHERE webhook_id = ?;`) - var whes []models.WebhookEvent - err := h.SelectContext(ctx, &whes, query, webhookID) - return whes, err -} - -// GetWebhooksByRepoID implements store.WebhookStore. -func (*webhookStore) GetWebhooksByRepoID(ctx context.Context, h db.Handler, repoID int64) ([]models.Webhook, error) { - query := h.Rebind(`SELECT * FROM webhooks WHERE repo_id = ?;`) - var whs []models.Webhook - err := h.SelectContext(ctx, &whs, query, repoID) - return whs, err -} - -// GetWebhooksByRepoIDWhereEvent implements store.WebhookStore. -func (*webhookStore) GetWebhooksByRepoIDWhereEvent(ctx context.Context, h db.Handler, repoID int64, events []int) ([]models.Webhook, error) { - query, args, err := sqlx.In(`SELECT webhooks.* - FROM webhooks - INNER JOIN webhook_events ON webhooks.id = webhook_events.webhook_id - WHERE webhooks.repo_id = ? AND webhook_events.event IN (?);`, repoID, events) - if err != nil { - return nil, err - } - - query = h.Rebind(query) - var whs []models.Webhook - err = h.SelectContext(ctx, &whs, query, args...) - return whs, err -} - -// ListWebhookDeliveriesByWebhookID implements store.WebhookStore. -func (*webhookStore) ListWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error) { - query := h.Rebind(`SELECT id, response_status, event FROM webhook_deliveries WHERE webhook_id = ?;`) - var whds []models.WebhookDelivery - err := h.SelectContext(ctx, &whds, query, webhookID) - return whds, err -} - -// UpdateWebhookByID implements store.WebhookStore. -func (*webhookStore) UpdateWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64, url string, secret string, contentType int, active bool) error { - query := h.Rebind(`UPDATE webhooks SET url = ?, secret = ?, content_type = ?, active = ?, updated_at = CURRENT_TIMESTAMP WHERE repo_id = ? AND id = ?;`) - _, err := h.ExecContext(ctx, query, url, secret, contentType, active, repoID, id) - return err -} diff --git a/server/store/lfs.go b/server/store/lfs.go deleted file mode 100644 index 067285eac..000000000 --- a/server/store/lfs.go +++ /dev/null @@ -1,28 +0,0 @@ -package store - -import ( - "context" - - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" -) - -// LFSStore is the interface for the LFS store. -type LFSStore interface { - CreateLFSObject(ctx context.Context, h db.Handler, repoID int64, oid string, size int64) error - GetLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) (models.LFSObject, error) - GetLFSObjects(ctx context.Context, h db.Handler, repoID int64) ([]models.LFSObject, error) - GetLFSObjectsByName(ctx context.Context, h db.Handler, name string) ([]models.LFSObject, error) - DeleteLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) error - - CreateLFSLockForUser(ctx context.Context, h db.Handler, repoID int64, userID int64, path string, refname string) error - GetLFSLocks(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error) - GetLFSLocksWithCount(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error) - GetLFSLocksForUser(ctx context.Context, h db.Handler, repoID int64, userID int64) ([]models.LFSLock, error) - GetLFSLockForPath(ctx context.Context, h db.Handler, repoID int64, path string) (models.LFSLock, error) - GetLFSLockForUserPath(ctx context.Context, h db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error) - GetLFSLockByID(ctx context.Context, h db.Handler, id int64) (models.LFSLock, error) - GetLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) (models.LFSLock, error) - DeleteLFSLock(ctx context.Context, h db.Handler, repoID int64, id int64) error - DeleteLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) error -} diff --git a/server/store/repo.go b/server/store/repo.go deleted file mode 100644 index 01feece79..000000000 --- a/server/store/repo.go +++ /dev/null @@ -1,28 +0,0 @@ -package store - -import ( - "context" - - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" -) - -// RepositoryStore is an interface for managing repositories. -type RepositoryStore interface { - GetRepoByName(ctx context.Context, h db.Handler, name string) (models.Repo, error) - GetAllRepos(ctx context.Context, h db.Handler) ([]models.Repo, error) - GetUserRepos(ctx context.Context, h db.Handler, userID int64) ([]models.Repo, error) - CreateRepo(ctx context.Context, h db.Handler, name string, userID int64, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error - DeleteRepoByName(ctx context.Context, h db.Handler, name string) error - SetRepoNameByName(ctx context.Context, h db.Handler, name string, newName string) error - - GetRepoProjectNameByName(ctx context.Context, h db.Handler, name string) (string, error) - SetRepoProjectNameByName(ctx context.Context, h db.Handler, name string, projectName string) error - GetRepoDescriptionByName(ctx context.Context, h db.Handler, name string) (string, error) - SetRepoDescriptionByName(ctx context.Context, h db.Handler, name string, description string) error - GetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string) (bool, error) - SetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string, isPrivate bool) error - GetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string) (bool, error) - SetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string, isHidden bool) error - GetRepoIsMirrorByName(ctx context.Context, h db.Handler, name string) (bool, error) -} diff --git a/server/store/settings.go b/server/store/settings.go deleted file mode 100644 index 75dd9d7af..000000000 --- a/server/store/settings.go +++ /dev/null @@ -1,16 +0,0 @@ -package store - -import ( - "context" - - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/db" -) - -// SettingStore is an interface for managing settings. -type SettingStore interface { - GetAnonAccess(ctx context.Context, h db.Handler) (access.AccessLevel, error) - SetAnonAccess(ctx context.Context, h db.Handler, level access.AccessLevel) error - GetAllowKeylessAccess(ctx context.Context, h db.Handler) (bool, error) - SetAllowKeylessAccess(ctx context.Context, h db.Handler, allow bool) error -} diff --git a/server/store/store.go b/server/store/store.go deleted file mode 100644 index 41490cbf8..000000000 --- a/server/store/store.go +++ /dev/null @@ -1,12 +0,0 @@ -package store - -// Store is an interface for managing repositories, users, and settings. -type Store interface { - RepositoryStore - UserStore - CollaboratorStore - SettingStore - LFSStore - AccessTokenStore - WebhookStore -} diff --git a/server/store/user.go b/server/store/user.go deleted file mode 100644 index 43c523736..000000000 --- a/server/store/user.go +++ /dev/null @@ -1,28 +0,0 @@ -package store - -import ( - "context" - - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "golang.org/x/crypto/ssh" -) - -// UserStore is an interface for managing users. -type UserStore interface { - GetUserByID(ctx context.Context, h db.Handler, id int64) (models.User, error) - FindUserByUsername(ctx context.Context, h db.Handler, username string) (models.User, error) - FindUserByPublicKey(ctx context.Context, h db.Handler, pk ssh.PublicKey) (models.User, error) - FindUserByAccessToken(ctx context.Context, h db.Handler, token string) (models.User, error) - GetAllUsers(ctx context.Context, h db.Handler) ([]models.User, error) - CreateUser(ctx context.Context, h db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error - DeleteUserByUsername(ctx context.Context, h db.Handler, username string) error - SetUsernameByUsername(ctx context.Context, h db.Handler, username string, newUsername string) error - SetAdminByUsername(ctx context.Context, h db.Handler, username string, isAdmin bool) error - AddPublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error - RemovePublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error - ListPublicKeysByUserID(ctx context.Context, h db.Handler, id int64) ([]ssh.PublicKey, error) - ListPublicKeysByUsername(ctx context.Context, h db.Handler, username string) ([]ssh.PublicKey, error) - SetUserPassword(ctx context.Context, h db.Handler, userID int64, password string) error - SetUserPasswordByUsername(ctx context.Context, h db.Handler, username string, password string) error -} diff --git a/server/store/webhooks.go b/server/store/webhooks.go deleted file mode 100644 index 139753484..000000000 --- a/server/store/webhooks.go +++ /dev/null @@ -1,48 +0,0 @@ -package store - -import ( - "context" - - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "github.com/google/uuid" -) - -// WebhookStore is an interface for managing webhooks. -type WebhookStore interface { - // GetWebhookByID returns a webhook by its ID. - GetWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64) (models.Webhook, error) - // GetWebhooksByRepoID returns all webhooks for a repository. - GetWebhooksByRepoID(ctx context.Context, h db.Handler, repoID int64) ([]models.Webhook, error) - // GetWebhooksByRepoIDWhereEvent returns all webhooks for a repository where event is in the events. - GetWebhooksByRepoIDWhereEvent(ctx context.Context, h db.Handler, repoID int64, events []int) ([]models.Webhook, error) - // CreateWebhook creates a webhook. - CreateWebhook(ctx context.Context, h db.Handler, repoID int64, url string, secret string, contentType int, active bool) (int64, error) - // UpdateWebhookByID updates a webhook by its ID. - UpdateWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64, url string, secret string, contentType int, active bool) error - // DeleteWebhookByID deletes a webhook by its ID. - DeleteWebhookByID(ctx context.Context, h db.Handler, id int64) error - // DeleteWebhookForRepoByID deletes a webhook for a repository by its ID. - DeleteWebhookForRepoByID(ctx context.Context, h db.Handler, repoID int64, id int64) error - - // GetWebhookEventByID returns a webhook event by its ID. - GetWebhookEventByID(ctx context.Context, h db.Handler, id int64) (models.WebhookEvent, error) - // GetWebhookEventsByWebhookID returns all webhook events for a webhook. - GetWebhookEventsByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookEvent, error) - // CreateWebhookEvents creates webhook events for a webhook. - CreateWebhookEvents(ctx context.Context, h db.Handler, webhookID int64, events []int) error - // DeleteWebhookEventsByWebhookID deletes all webhook events for a webhook. - DeleteWebhookEventsByID(ctx context.Context, h db.Handler, ids []int64) error - - // GetWebhookDeliveryByID returns a webhook delivery by its ID. - GetWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) (models.WebhookDelivery, error) - // GetWebhookDeliveriesByWebhookID returns all webhook deliveries for a webhook. - GetWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error) - // ListWebhookDeliveriesByWebhookID returns all webhook deliveries for a webhook. - // This only returns the delivery ID, response status, and event. - ListWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error) - // CreateWebhookDelivery creates a webhook delivery. - CreateWebhookDelivery(ctx context.Context, h db.Handler, id uuid.UUID, webhookID int64, event int, url string, method string, requestError error, requestHeaders string, requestBody string, responseStatus int, responseHeaders string, responseBody string) error - // DeleteWebhookDeliveryByID deletes a webhook delivery by its ID. - DeleteWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) error -} diff --git a/server/sync/workqueue.go b/server/sync/workqueue.go deleted file mode 100644 index 1c07e74b1..000000000 --- a/server/sync/workqueue.go +++ /dev/null @@ -1,94 +0,0 @@ -package sync - -import ( - "context" - "sync" - - "golang.org/x/sync/semaphore" -) - -// WorkPool is a pool of work to be done. -type WorkPool struct { - workers int - work sync.Map - sem *semaphore.Weighted - ctx context.Context - logger func(string, ...interface{}) -} - -// WorkPoolOption is a function that configures a WorkPool. -type WorkPoolOption func(*WorkPool) - -// WithWorkPoolLogger sets the logger to use. -func WithWorkPoolLogger(logger func(string, ...interface{})) WorkPoolOption { - return func(wq *WorkPool) { - wq.logger = logger - } -} - -// NewWorkPool creates a new work pool. The workers argument specifies the -// number of concurrent workers to run the work. -// The queue will chunk the work into batches of workers size. -func NewWorkPool(ctx context.Context, workers int, opts ...WorkPoolOption) *WorkPool { - wq := &WorkPool{ - workers: workers, - ctx: ctx, - } - - for _, opt := range opts { - opt(wq) - } - - if wq.workers <= 0 { - wq.workers = 1 - } - - wq.sem = semaphore.NewWeighted(int64(wq.workers)) - - return wq -} - -// Run starts the workers and waits for them to finish. -func (wq *WorkPool) Run() { - wq.work.Range(func(key, value any) bool { - id := key.(string) - fn := value.(func()) - if err := wq.sem.Acquire(wq.ctx, 1); err != nil { - wq.logf("workpool: %v", err) - return false - } - - go func(id string, fn func()) { - defer wq.sem.Release(1) - fn() - wq.work.Delete(id) - }(id, fn) - - return true - }) - - if err := wq.sem.Acquire(wq.ctx, int64(wq.workers)); err != nil { - wq.logf("workpool: %v", err) - } -} - -// Add adds a new job to the pool. -// If the job already exists, it is a no-op. -func (wq *WorkPool) Add(id string, fn func()) { - if _, ok := wq.work.Load(id); ok { - return - } - wq.work.Store(id, fn) -} - -// Status checks if a job is in the queue. -func (wq *WorkPool) Status(id string) bool { - _, ok := wq.work.Load(id) - return ok -} - -func (wq *WorkPool) logf(format string, args ...interface{}) { - if wq.logger != nil { - wq.logger(format, args...) - } -} diff --git a/server/sync/workqueue_test.go b/server/sync/workqueue_test.go deleted file mode 100644 index 615e95dd7..000000000 --- a/server/sync/workqueue_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package sync - -import ( - "context" - "strconv" - "sync" - "testing" -) - -func TestWorkPool(t *testing.T) { - mtx := &sync.Mutex{} - values := make([]int, 0) - wp := NewWorkPool(context.Background(), 3) - for i := 0; i < 10; i++ { - id := strconv.Itoa(i) - i := i - wp.Add(id, func() { - mtx.Lock() - values = append(values, i) - mtx.Unlock() - }) - } - wp.Run() - - if len(values) != 10 { - t.Errorf("expected 10 values, got %d, %v", len(values), values) - } - - for i := range values { - id := strconv.Itoa(i) - if wp.Status(id) { - t.Errorf("expected %s to be false", id) - } - } -} diff --git a/server/task/manager.go b/server/task/manager.go deleted file mode 100644 index 4f8763711..000000000 --- a/server/task/manager.go +++ /dev/null @@ -1,116 +0,0 @@ -package task - -import ( - "context" - "errors" - "sync" - "sync/atomic" -) - -var ( - // ErrNotFound is returned when a process is not found. - ErrNotFound = errors.New("task not found") - - // ErrAlreadyStarted is returned when a process is already started. - ErrAlreadyStarted = errors.New("task already started") -) - -// Task is a task that can be started and stopped. -type Task struct { - id string - fn func(context.Context) error - started atomic.Bool - ctx context.Context - cancel context.CancelFunc - err error -} - -// Manager manages tasks. -type Manager struct { - m sync.Map - ctx context.Context -} - -// NewManager returns a new task manager. -func NewManager(ctx context.Context) *Manager { - return &Manager{ - m: sync.Map{}, - ctx: ctx, - } -} - -// Add adds a task to the manager. -// If the process already exists, it is a no-op. -func (m *Manager) Add(id string, fn func(context.Context) error) { - if m.Exists(id) { - return - } - - ctx, cancel := context.WithCancel(m.ctx) - m.m.Store(id, &Task{ - id: id, - fn: fn, - ctx: ctx, - cancel: cancel, - }) -} - -// Stop stops the task and removes it from the manager. -func (m *Manager) Stop(id string) error { - v, ok := m.m.Load(id) - if !ok { - return ErrNotFound - } - - p := v.(*Task) - p.cancel() - - m.m.Delete(id) - return nil -} - -// Exists checks if a task exists. -func (m *Manager) Exists(id string) bool { - _, ok := m.m.Load(id) - return ok -} - -// Run starts the task if it exists. -// Otherwise, it waits for the process to finish. -func (m *Manager) Run(id string, done chan<- error) { - v, ok := m.m.Load(id) - if !ok { - done <- ErrNotFound - return - } - - p := v.(*Task) - if p.started.Load() { - <-p.ctx.Done() - if p.err != nil { - done <- p.err - return - } - - done <- p.ctx.Err() - } - - p.started.Store(true) - m.m.Store(id, p) - defer p.cancel() - defer m.m.Delete(id) - - errc := make(chan error, 1) - go func(ctx context.Context) { - errc <- p.fn(ctx) - }(p.ctx) - - select { - case <-m.ctx.Done(): - done <- m.ctx.Err() - case err := <-errc: - p.err = err - m.m.Store(id, p) - done <- err - } -} diff --git a/server/test/test.go b/server/test/test.go deleted file mode 100644 index bfaac42c5..000000000 --- a/server/test/test.go +++ /dev/null @@ -1,29 +0,0 @@ -package test - -import ( - "net" - "sync" -) - -var ( - used = map[int]struct{}{} - lock sync.Mutex -) - -// RandomPort returns a random port number. -// This is mainly used for testing. -func RandomPort() int { - addr, _ := net.Listen("tcp", ":0") //nolint:gosec - _ = addr.Close() - port := addr.Addr().(*net.TCPAddr).Port - lock.Lock() - - if _, ok := used[port]; ok { - lock.Unlock() - return RandomPort() - } - - used[port] = struct{}{} - lock.Unlock() - return port -} diff --git a/server/ui/common/common.go b/server/ui/common/common.go deleted file mode 100644 index 884b7d200..000000000 --- a/server/ui/common/common.go +++ /dev/null @@ -1,97 +0,0 @@ -package common - -import ( - "context" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/ui/keymap" - "github.com/charmbracelet/soft-serve/server/ui/styles" - "github.com/charmbracelet/ssh" - zone "github.com/lrstanley/bubblezone" - "github.com/muesli/termenv" -) - -type contextKey struct { - name string -} - -// Keys to use for context.Context. -var ( - ConfigKey = &contextKey{"config"} - RepoKey = &contextKey{"repo"} -) - -// Common is a struct all components should embed. -type Common struct { - ctx context.Context - Width, Height int - Styles *styles.Styles - KeyMap *keymap.KeyMap - Zone *zone.Manager - Output *termenv.Output - Logger *log.Logger -} - -// NewCommon returns a new Common struct. -func NewCommon(ctx context.Context, out *termenv.Output, width, height int) Common { - if ctx == nil { - ctx = context.TODO() - } - return Common{ - ctx: ctx, - Width: width, - Height: height, - Output: out, - Styles: styles.DefaultStyles(), - KeyMap: keymap.DefaultKeyMap(), - Zone: zone.New(), - Logger: log.FromContext(ctx).WithPrefix("ui"), - } -} - -// SetValue sets a value in the context. -func (c *Common) SetValue(key, value interface{}) { - c.ctx = context.WithValue(c.ctx, key, value) -} - -// SetSize sets the width and height of the common struct. -func (c *Common) SetSize(width, height int) { - c.Width = width - c.Height = height -} - -// Context returns the context. -func (c *Common) Context() context.Context { - return c.ctx -} - -// Config returns the server config. -func (c *Common) Config() *config.Config { - return config.FromContext(c.ctx) -} - -// Backend returns the Soft Serve backend. -func (c *Common) Backend() *backend.Backend { - return backend.FromContext(c.ctx) -} - -// Repo returns the repository. -func (c *Common) Repo() *git.Repository { - v := c.ctx.Value(RepoKey) - if r, ok := v.(*git.Repository); ok { - return r - } - return nil -} - -// PublicKey returns the public key. -func (c *Common) PublicKey() ssh.PublicKey { - v := c.ctx.Value(ssh.ContextKeyPublicKey) - if p, ok := v.(ssh.PublicKey); ok { - return p - } - return nil -} diff --git a/server/ui/common/component.go b/server/ui/common/component.go deleted file mode 100644 index 1f4d20df8..000000000 --- a/server/ui/common/component.go +++ /dev/null @@ -1,31 +0,0 @@ -package common - -import ( - "github.com/charmbracelet/bubbles/help" - tea "github.com/charmbracelet/bubbletea" -) - -// Component represents a Bubble Tea model that implements a SetSize function. -type Component interface { - tea.Model - help.KeyMap - SetSize(width, height int) -} - -// TabComponenet represents a model that is mounted to a tab. -// TODO: find a better name -type TabComponent interface { - Component - - // StatusBarValue returns the status bar value component. - StatusBarValue() string - - // StatusBarInfo returns the status bar info component. - StatusBarInfo() string - - // SpinnerID returns the ID of the spinner. - SpinnerID() int - - // TabName returns the name of the tab. - TabName() string -} diff --git a/server/ui/common/error.go b/server/ui/common/error.go deleted file mode 100644 index 753f5f26f..000000000 --- a/server/ui/common/error.go +++ /dev/null @@ -1,20 +0,0 @@ -package common - -import ( - "errors" - - tea "github.com/charmbracelet/bubbletea" -) - -// ErrMissingRepo indicates that the requested repository could not be found. -var ErrMissingRepo = errors.New("missing repo") - -// ErrorMsg is a Bubble Tea message that represents an error. -type ErrorMsg error - -// ErrorCmd returns an ErrorMsg from error. -func ErrorCmd(err error) tea.Cmd { - return func() tea.Msg { - return ErrorMsg(err) - } -} diff --git a/server/ui/common/format.go b/server/ui/common/format.go deleted file mode 100644 index 90d00dc54..000000000 --- a/server/ui/common/format.go +++ /dev/null @@ -1,60 +0,0 @@ -package common - -import ( - "fmt" - "strings" - - "github.com/alecthomas/chroma/lexers" - gansi "github.com/charmbracelet/glamour/ansi" - "github.com/charmbracelet/soft-serve/server/ui/styles" - "github.com/muesli/termenv" -) - -// FormatLineNumber adds line numbers to a string. -func FormatLineNumber(styles *styles.Styles, s string, color bool) (string, int) { - lines := strings.Split(s, "\n") - // NB: len() is not a particularly safe way to count string width (because - // it's counting bytes instead of runes) but in this case it's okay - // because we're only dealing with digits, which are one byte each. - mll := len(fmt.Sprintf("%d", len(lines))) - for i, l := range lines { - digit := fmt.Sprintf("%*d", mll, i+1) - bar := "│" - if color { - digit = styles.Code.LineDigit.Render(digit) - bar = styles.Code.LineBar.Render(bar) - } - if i < len(lines)-1 || len(l) != 0 { - // If the final line was a newline we'll get an empty string for - // the final line, so drop the newline altogether. - lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l) - } - } - return strings.Join(lines, "\n"), mll -} - -// FormatHighlight adds syntax highlighting to a string. -func FormatHighlight(p, c string) (string, error) { - zero := uint(0) - lang := "" - lexer := lexers.Match(p) - if lexer != nil && lexer.Config() != nil { - lang = lexer.Config().Name - } - formatter := &gansi.CodeBlockElement{ - Code: c, - Language: lang, - } - r := strings.Builder{} - styles := StyleConfig() - styles.CodeBlock.Margin = &zero - rctx := gansi.NewRenderContext(gansi.Options{ - Styles: styles, - ColorProfile: termenv.TrueColor, - }) - err := formatter.Render(&r, rctx) - if err != nil { - return "", err - } - return r.String(), nil -} diff --git a/server/ui/common/style.go b/server/ui/common/style.go deleted file mode 100644 index 8b91d9afa..000000000 --- a/server/ui/common/style.go +++ /dev/null @@ -1,27 +0,0 @@ -package common - -import ( - "github.com/charmbracelet/glamour" - gansi "github.com/charmbracelet/glamour/ansi" -) - -func strptr(s string) *string { - return &s -} - -// StyleConfig returns the default Glamour style configuration. -func StyleConfig() gansi.StyleConfig { - noColor := strptr("") - s := glamour.DarkStyleConfig - s.H1.BackgroundColor = noColor - s.H1.Prefix = "# " - s.H1.Suffix = "" - s.H1.Color = strptr("39") - s.Document.StylePrimitive.Color = noColor - s.CodeBlock.Chroma.Text.Color = noColor - s.CodeBlock.Chroma.Name.Color = noColor - // This fixes an issue with the default style config. For example - // highlighting empty spaces with red in Dockerfile type. - s.CodeBlock.Chroma.Error.BackgroundColor = noColor - return s -} diff --git a/server/ui/common/utils.go b/server/ui/common/utils.go deleted file mode 100644 index 49eaf00ce..000000000 --- a/server/ui/common/utils.go +++ /dev/null @@ -1,40 +0,0 @@ -package common - -import ( - "fmt" - "net/url" - - "github.com/charmbracelet/soft-serve/server/utils" - "github.com/muesli/reflow/truncate" -) - -// TruncateString is a convenient wrapper around truncate.TruncateString. -func TruncateString(s string, max int) string { - if max < 0 { - max = 0 - } - return truncate.StringWithTail(s, uint(max), "…") -} - -// RepoURL returns the URL of the repository. -func RepoURL(publicURL, name string) string { - name = utils.SanitizeRepo(name) + ".git" - url, err := url.Parse(publicURL) - if err == nil { - switch url.Scheme { - case "ssh": - port := url.Port() - if port == "" || port == "22" { - return fmt.Sprintf("git@%s:%s", url.Hostname(), name) - } - return fmt.Sprintf("ssh://%s:%s/%s", url.Hostname(), url.Port(), name) - } - } - - return fmt.Sprintf("%s/%s", publicURL, name) -} - -// CloneCmd returns the URL of the repository. -var CloneCmd = func(publicURL, name string) string { - return fmt.Sprintf("git clone %s", RepoURL(publicURL, name)) -} diff --git a/server/ui/components/code/code.go b/server/ui/components/code/code.go deleted file mode 100644 index 5d6d4a91e..000000000 --- a/server/ui/components/code/code.go +++ /dev/null @@ -1,261 +0,0 @@ -package code - -import ( - "math" - "strings" - "sync" - - "github.com/alecthomas/chroma/lexers" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/glamour" - gansi "github.com/charmbracelet/glamour/ansi" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/server/ui/common" - vp "github.com/charmbracelet/soft-serve/server/ui/components/viewport" - "github.com/muesli/termenv" -) - -const ( - defaultTabWidth = 4 - defaultSideNotePercent = 0.3 -) - -// Code is a code snippet. -type Code struct { - *vp.Viewport - common common.Common - sidenote string - content string - extension string - renderContext gansi.RenderContext - renderMutex sync.Mutex - styleConfig gansi.StyleConfig - - SideNotePercent float64 - TabWidth int - ShowLineNumber bool - NoContentStyle lipgloss.Style - UseGlamour bool -} - -// New returns a new Code. -func New(c common.Common, content, extension string) *Code { - r := &Code{ - common: c, - content: content, - extension: extension, - TabWidth: defaultTabWidth, - SideNotePercent: defaultSideNotePercent, - Viewport: vp.New(c), - NoContentStyle: c.Styles.NoContent.Copy().SetString("No Content."), - } - st := common.StyleConfig() - r.styleConfig = st - r.renderContext = gansi.NewRenderContext(gansi.Options{ - ColorProfile: termenv.TrueColor, - Styles: st, - }) - r.SetSize(c.Width, c.Height) - return r -} - -// SetSize implements common.Component. -func (r *Code) SetSize(width, height int) { - r.common.SetSize(width, height) - r.Viewport.SetSize(width, height) -} - -// SetContent sets the content of the Code. -func (r *Code) SetContent(c, ext string) tea.Cmd { - r.content = c - r.extension = ext - return r.Init() -} - -// SetSideNote sets the sidenote of the Code. -func (r *Code) SetSideNote(s string) tea.Cmd { - r.sidenote = s - return r.Init() -} - -// Init implements tea.Model. -func (r *Code) Init() tea.Cmd { - w := r.common.Width - content := r.content - if content == "" { - r.Viewport.Model.SetContent(r.NoContentStyle.String()) - return nil - } - - // FIXME chroma & glamour might break wrapping when using tabs since tab - // width depends on the terminal. This is a workaround to replace tabs with - // 4-spaces. - content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", r.TabWidth)) - - if r.UseGlamour { - md, err := r.glamourize(w, content) - if err != nil { - return common.ErrorCmd(err) - } - content = md - } else { - f, err := r.renderFile(r.extension, content) - if err != nil { - return common.ErrorCmd(err) - } - content = f - if r.ShowLineNumber { - var ml int - content, ml = common.FormatLineNumber(r.common.Styles, content, true) - w -= ml - } - } - - if r.sidenote != "" { - lines := strings.Split(r.sidenote, "\n") - sideNoteWidth := int(math.Ceil(float64(r.Model.Width) * r.SideNotePercent)) - for i, l := range lines { - lines[i] = common.TruncateString(l, sideNoteWidth) - } - content = lipgloss.JoinHorizontal(lipgloss.Left, strings.Join(lines, "\n"), content) - } - - // Fix styles after hard wrapping - // https://github.com/muesli/reflow/issues/43 - // - // TODO: solve this upstream in Glamour/Reflow. - content = lipgloss.NewStyle().Width(w).Render(content) - - r.Viewport.Model.SetContent(content) - - return nil -} - -// Update implements tea.Model. -func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg.(type) { - case tea.WindowSizeMsg: - // Recalculate content width and line wrap. - cmds = append(cmds, r.Init()) - } - v, cmd := r.Viewport.Update(msg) - r.Viewport = v.(*vp.Viewport) - if cmd != nil { - cmds = append(cmds, cmd) - } - return r, tea.Batch(cmds...) -} - -// View implements tea.View. -func (r *Code) View() string { - return r.Viewport.View() -} - -// GotoTop moves the viewport to the top of the log. -func (r *Code) GotoTop() { - r.Viewport.GotoTop() -} - -// GotoBottom moves the viewport to the bottom of the log. -func (r *Code) GotoBottom() { - r.Viewport.GotoBottom() -} - -// HalfViewDown moves the viewport down by half the viewport height. -func (r *Code) HalfViewDown() { - r.Viewport.HalfViewDown() -} - -// HalfViewUp moves the viewport up by half the viewport height. -func (r *Code) HalfViewUp() { - r.Viewport.HalfViewUp() -} - -// ViewUp moves the viewport up by a page. -func (r *Code) ViewUp() []string { - return r.Viewport.ViewUp() -} - -// ViewDown moves the viewport down by a page. -func (r *Code) ViewDown() []string { - return r.Viewport.ViewDown() -} - -// LineUp moves the viewport up by the given number of lines. -func (r *Code) LineUp(n int) []string { - return r.Viewport.LineUp(n) -} - -// LineDown moves the viewport down by the given number of lines. -func (r *Code) LineDown(n int) []string { - return r.Viewport.LineDown(n) -} - -// ScrollPercent returns the viewport's scroll percentage. -func (r *Code) ScrollPercent() float64 { - return r.Viewport.ScrollPercent() -} - -// ScrollPosition returns the viewport's scroll position. -func (r *Code) ScrollPosition() int { - scroll := r.ScrollPercent() * 100 - if scroll < 0 || math.IsNaN(scroll) { - scroll = 0 - } - return int(scroll) -} - -func (r *Code) glamourize(w int, md string) (string, error) { - r.renderMutex.Lock() - defer r.renderMutex.Unlock() - if w > 120 { - w = 120 - } - tr, err := glamour.NewTermRenderer( - glamour.WithStyles(r.styleConfig), - glamour.WithWordWrap(w), - ) - - if err != nil { - return "", err - } - mdt, err := tr.Render(md) - if err != nil { - return "", err - } - return mdt, nil -} - -func (r *Code) renderFile(path, content string) (string, error) { - lexer := lexers.Match(path) - if path == "" { - lexer = lexers.Analyse(content) - } - lang := "" - if lexer != nil && lexer.Config() != nil { - lang = lexer.Config().Name - } - - formatter := &gansi.CodeBlockElement{ - Code: content, - Language: lang, - } - s := strings.Builder{} - rc := r.renderContext - if r.ShowLineNumber { - st := common.StyleConfig() - var m uint - st.CodeBlock.Margin = &m - rc = gansi.NewRenderContext(gansi.Options{ - ColorProfile: termenv.TrueColor, - Styles: st, - }) - } - err := formatter.Render(&s, rc) - if err != nil { - return "", err - } - - return s.String(), nil -} diff --git a/server/ui/components/footer/footer.go b/server/ui/components/footer/footer.go deleted file mode 100644 index 0022d0fe2..000000000 --- a/server/ui/components/footer/footer.go +++ /dev/null @@ -1,96 +0,0 @@ -package footer - -import ( - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/server/ui/common" -) - -// ToggleFooterMsg is a message sent to show/hide the footer. -type ToggleFooterMsg struct{} - -// Footer is a Bubble Tea model that displays help and other info. -type Footer struct { - common common.Common - help help.Model - keymap help.KeyMap -} - -// New creates a new Footer. -func New(c common.Common, keymap help.KeyMap) *Footer { - h := help.New() - h.Styles.ShortKey = c.Styles.HelpKey - h.Styles.ShortDesc = c.Styles.HelpValue - h.Styles.FullKey = c.Styles.HelpKey - h.Styles.FullDesc = c.Styles.HelpValue - f := &Footer{ - common: c, - help: h, - keymap: keymap, - } - f.SetSize(c.Width, c.Height) - return f -} - -// SetSize implements common.Component. -func (f *Footer) SetSize(width, height int) { - f.common.SetSize(width, height) - f.help.Width = width - - f.common.Styles.Footer.GetHorizontalFrameSize() -} - -// Init implements tea.Model. -func (f *Footer) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model. -func (f *Footer) Update(_ tea.Msg) (tea.Model, tea.Cmd) { - return f, nil -} - -// View implements tea.Model. -func (f *Footer) View() string { - if f.keymap == nil { - return "" - } - s := f.common.Styles.Footer.Copy(). - Width(f.common.Width) - helpView := f.help.View(f.keymap) - return f.common.Zone.Mark( - "footer", - s.Render(helpView), - ) -} - -// ShortHelp returns the short help key bindings. -func (f *Footer) ShortHelp() []key.Binding { - return f.keymap.ShortHelp() -} - -// FullHelp returns the full help key bindings. -func (f *Footer) FullHelp() [][]key.Binding { - return f.keymap.FullHelp() -} - -// ShowAll returns whether the full help is shown. -func (f *Footer) ShowAll() bool { - return f.help.ShowAll -} - -// SetShowAll sets whether the full help is shown. -func (f *Footer) SetShowAll(show bool) { - f.help.ShowAll = show -} - -// Height returns the height of the footer. -func (f *Footer) Height() int { - return lipgloss.Height(f.View()) -} - -// ToggleFooterCmd sends a ToggleFooterMsg to show/hide the help footer. -func ToggleFooterCmd() tea.Msg { - return ToggleFooterMsg{} -} diff --git a/server/ui/components/header/header.go b/server/ui/components/header/header.go deleted file mode 100644 index 66870968a..000000000 --- a/server/ui/components/header/header.go +++ /dev/null @@ -1,42 +0,0 @@ -package header - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/soft-serve/server/ui/common" -) - -// Header represents a header component. -type Header struct { - common common.Common - text string -} - -// New creates a new header component. -func New(c common.Common, text string) *Header { - return &Header{ - common: c, - text: text, - } -} - -// SetSize implements common.Component. -func (h *Header) SetSize(width, height int) { - h.common.SetSize(width, height) -} - -// Init implements tea.Model. -func (h *Header) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model. -func (h *Header) Update(_ tea.Msg) (tea.Model, tea.Cmd) { - return h, nil -} - -// View implements tea.Model. -func (h *Header) View() string { - return h.common.Styles.ServerName.Render(strings.TrimSpace(h.text)) -} diff --git a/server/ui/components/selector/selector.go b/server/ui/components/selector/selector.go deleted file mode 100644 index 22dcfefaf..000000000 --- a/server/ui/components/selector/selector.go +++ /dev/null @@ -1,319 +0,0 @@ -package selector - -import ( - "sync" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/soft-serve/server/ui/common" -) - -// Selector is a list of items that can be selected. -type Selector struct { - *list.Model - common common.Common - active int - filterState list.FilterState - - // XXX: we use a mutex to support concurrent access to the model. This is - // needed to implement pagination for the Log component. list.Model does - // not support item pagination so we hack it ourselves on top of - // list.Model. - mtx sync.RWMutex -} - -// IdentifiableItem is an item that can be identified by a string. Implements -// list.DefaultItem. -type IdentifiableItem interface { - list.DefaultItem - ID() string -} - -// ItemDelegate is a wrapper around list.ItemDelegate. -type ItemDelegate interface { - list.ItemDelegate -} - -// SelectMsg is a message that is sent when an item is selected. -type SelectMsg struct{ IdentifiableItem } - -// ActiveMsg is a message that is sent when an item is active but not selected. -type ActiveMsg struct{ IdentifiableItem } - -// New creates a new selector. -func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate) *Selector { - itms := make([]list.Item, len(items)) - for i, item := range items { - itms[i] = item - } - l := list.New(itms, delegate, common.Width, common.Height) - l.Styles.NoItems = common.Styles.NoContent - s := &Selector{ - Model: &l, - common: common, - } - s.SetSize(common.Width, common.Height) - return s -} - -// PerPage returns the number of items per page. -func (s *Selector) PerPage() int { - s.mtx.RLock() - defer s.mtx.RUnlock() - return s.Model.Paginator.PerPage -} - -// SetPage sets the current page. -func (s *Selector) SetPage(page int) { - s.mtx.Lock() - defer s.mtx.Unlock() - s.Model.Paginator.Page = page -} - -// Page returns the current page. -func (s *Selector) Page() int { - s.mtx.RLock() - defer s.mtx.RUnlock() - return s.Model.Paginator.Page -} - -// TotalPages returns the total number of pages. -func (s *Selector) TotalPages() int { - s.mtx.RLock() - defer s.mtx.RUnlock() - return s.Model.Paginator.TotalPages -} - -// SetTotalPages sets the total number of pages given the number of items. -func (s *Selector) SetTotalPages(items int) int { - s.mtx.Lock() - defer s.mtx.Unlock() - return s.Model.Paginator.SetTotalPages(items) -} - -// SelectedItem returns the currently selected item. -func (s *Selector) SelectedItem() IdentifiableItem { - s.mtx.RLock() - defer s.mtx.RUnlock() - item := s.Model.SelectedItem() - i, ok := item.(IdentifiableItem) - if !ok { - return nil - } - return i -} - -// Select selects the item at the given index. -func (s *Selector) Select(index int) { - s.mtx.RLock() - defer s.mtx.RUnlock() - s.Model.Select(index) -} - -// SetShowTitle sets the show title flag. -func (s *Selector) SetShowTitle(show bool) { - s.mtx.Lock() - defer s.mtx.Unlock() - s.Model.SetShowTitle(show) -} - -// SetShowHelp sets the show help flag. -func (s *Selector) SetShowHelp(show bool) { - s.mtx.Lock() - defer s.mtx.Unlock() - s.Model.SetShowHelp(show) -} - -// SetShowStatusBar sets the show status bar flag. -func (s *Selector) SetShowStatusBar(show bool) { - s.mtx.Lock() - defer s.mtx.Unlock() - s.Model.SetShowStatusBar(show) -} - -// DisableQuitKeybindings disables the quit keybindings. -func (s *Selector) DisableQuitKeybindings() { - s.mtx.Lock() - defer s.mtx.Unlock() - s.Model.DisableQuitKeybindings() -} - -// SetShowFilter sets the show filter flag. -func (s *Selector) SetShowFilter(show bool) { - s.mtx.Lock() - defer s.mtx.Unlock() - s.Model.SetShowFilter(show) -} - -// SetShowPagination sets the show pagination flag. -func (s *Selector) SetShowPagination(show bool) { - s.mtx.Lock() - defer s.mtx.Unlock() - s.Model.SetShowPagination(show) -} - -// SetFilteringEnabled sets the filtering enabled flag. -func (s *Selector) SetFilteringEnabled(enabled bool) { - s.mtx.Lock() - defer s.mtx.Unlock() - s.Model.SetFilteringEnabled(enabled) -} - -// SetSize implements common.Component. -func (s *Selector) SetSize(width, height int) { - s.mtx.Lock() - defer s.mtx.Unlock() - s.common.SetSize(width, height) - s.Model.SetSize(width, height) -} - -// SetItems sets the items in the selector. -func (s *Selector) SetItems(items []IdentifiableItem) tea.Cmd { - its := make([]list.Item, len(items)) - for i, item := range items { - its[i] = item - } - s.mtx.Lock() - defer s.mtx.Unlock() - return s.Model.SetItems(its) -} - -// Index returns the index of the selected item. -func (s *Selector) Index() int { - s.mtx.RLock() - defer s.mtx.RUnlock() - return s.Model.Index() -} - -// Items returns the items in the selector. -func (s *Selector) Items() []list.Item { - s.mtx.RLock() - defer s.mtx.RUnlock() - return s.Model.Items() -} - -// VisibleItems returns all the visible items in the selector. -func (s *Selector) VisibleItems() []list.Item { - s.mtx.RLock() - defer s.mtx.RUnlock() - return s.Model.VisibleItems() -} - -// FilterState returns the filter state. -func (s *Selector) FilterState() list.FilterState { - s.mtx.RLock() - defer s.mtx.RUnlock() - return s.Model.FilterState() -} - -// CursorUp moves the cursor up. -func (s *Selector) CursorUp() { - s.mtx.Lock() - defer s.mtx.Unlock() - s.Model.CursorUp() -} - -// CursorDown moves the cursor down. -func (s *Selector) CursorDown() { - s.mtx.Lock() - defer s.mtx.Unlock() - s.Model.CursorDown() -} - -// Init implements tea.Model. -func (s *Selector) Init() tea.Cmd { - return s.activeCmd -} - -// Update implements tea.Model. -func (s *Selector) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case tea.MouseMsg: - switch msg.Type { - case tea.MouseWheelUp: - s.CursorUp() - case tea.MouseWheelDown: - s.CursorDown() - case tea.MouseLeft: - curIdx := s.Index() - for i, item := range s.Items() { - item, _ := item.(IdentifiableItem) - // Check each item to see if it's in bounds. - if item != nil && s.common.Zone.Get(item.ID()).InBounds(msg) { - if i == curIdx { - cmds = append(cmds, s.SelectItemCmd) - } else { - s.Select(i) - } - break - } - } - } - case tea.KeyMsg: - filterState := s.FilterState() - switch { - case key.Matches(msg, s.common.KeyMap.Help): - if filterState == list.Filtering { - return s, tea.Batch(cmds...) - } - case key.Matches(msg, s.common.KeyMap.Select): - if filterState != list.Filtering { - cmds = append(cmds, s.SelectItemCmd) - } - } - case list.FilterMatchesMsg: - cmds = append(cmds, s.activeFilterCmd) - } - m, cmd := s.Model.Update(msg) - s.mtx.Lock() - s.Model = &m - s.mtx.Unlock() - if cmd != nil { - cmds = append(cmds, cmd) - } - // Track filter state and update active item when filter state changes. - filterState := s.FilterState() - if s.filterState != filterState { - cmds = append(cmds, s.activeFilterCmd) - } - s.filterState = filterState - // Send ActiveMsg when index change. - if s.active != s.Index() { - cmds = append(cmds, s.activeCmd) - } - s.active = s.Index() - return s, tea.Batch(cmds...) -} - -// View implements tea.Model. -func (s *Selector) View() string { - return s.Model.View() -} - -// SelectItemCmd is a command that selects the currently active item. -func (s *Selector) SelectItemCmd() tea.Msg { - return SelectMsg{s.SelectedItem()} -} - -func (s *Selector) activeCmd() tea.Msg { - item := s.SelectedItem() - return ActiveMsg{item} -} - -func (s *Selector) activeFilterCmd() tea.Msg { - // Here we use VisibleItems because when list.FilterMatchesMsg is sent, - // VisibleItems is the only way to get the list of filtered items. The list - // bubble should export something like list.FilterMatchesMsg.Items(). - items := s.VisibleItems() - if len(items) == 0 { - return nil - } - item := items[0] - i, ok := item.(IdentifiableItem) - if !ok { - return nil - } - return ActiveMsg{i} -} diff --git a/server/ui/components/statusbar/statusbar.go b/server/ui/components/statusbar/statusbar.go deleted file mode 100644 index f60e07795..000000000 --- a/server/ui/components/statusbar/statusbar.go +++ /dev/null @@ -1,93 +0,0 @@ -package statusbar - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/muesli/reflow/truncate" -) - -// Model is a status bar model. -type Model struct { - common common.Common - key string - value string - info string - extra string -} - -// New creates a new status bar component. -func New(c common.Common) *Model { - s := &Model{ - common: c, - } - return s -} - -// SetSize implements common.Component. -func (s *Model) SetSize(width, height int) { - s.common.Width = width - s.common.Height = height -} - -// SetStatus sets the status bar status. -func (s *Model) SetStatus(key, value, info, extra string) { - if key != "" { - s.key = key - } - if value != "" { - s.value = value - } - if info != "" { - s.info = info - } - if extra != "" { - s.extra = extra - } -} - -// Init implements tea.Model. -func (s *Model) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model. -func (s *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - s.SetSize(msg.Width, msg.Height) - } - return s, nil -} - -// View implements tea.Model. -func (s *Model) View() string { - st := s.common.Styles - w := lipgloss.Width - help := s.common.Zone.Mark( - "repo-help", - st.StatusBarHelp.Render("? Help"), - ) - key := st.StatusBarKey.Render(s.key) - info := "" - if s.info != "" { - info = st.StatusBarInfo.Render(s.info) - } - branch := st.StatusBarBranch.Render(s.extra) - maxWidth := s.common.Width - w(key) - w(info) - w(branch) - w(help) - v := truncate.StringWithTail(s.value, uint(maxWidth-st.StatusBarValue.GetHorizontalFrameSize()), "…") - value := st.StatusBarValue. - Width(maxWidth). - Render(v) - - return lipgloss.NewStyle().MaxWidth(s.common.Width). - Render( - lipgloss.JoinHorizontal(lipgloss.Top, - key, - value, - info, - branch, - help, - ), - ) -} diff --git a/server/ui/components/tabs/tabs.go b/server/ui/components/tabs/tabs.go deleted file mode 100644 index 4adff6d9d..000000000 --- a/server/ui/components/tabs/tabs.go +++ /dev/null @@ -1,122 +0,0 @@ -package tabs - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/server/ui/common" -) - -// SelectTabMsg is a message that contains the index of the tab to select. -type SelectTabMsg int - -// ActiveTabMsg is a message that contains the index of the current active tab. -type ActiveTabMsg int - -// Tabs is bubbletea component that displays a list of tabs. -type Tabs struct { - common common.Common - tabs []string - activeTab int - TabSeparator lipgloss.Style - TabInactive lipgloss.Style - TabActive lipgloss.Style - TabDot lipgloss.Style - UseDot bool -} - -// New creates a new Tabs component. -func New(c common.Common, tabs []string) *Tabs { - r := &Tabs{ - common: c, - tabs: tabs, - activeTab: 0, - TabSeparator: c.Styles.TabSeparator, - TabInactive: c.Styles.TabInactive, - TabActive: c.Styles.TabActive, - } - return r -} - -// SetSize implements common.Component. -func (t *Tabs) SetSize(width, height int) { - t.common.SetSize(width, height) -} - -// Init implements tea.Model. -func (t *Tabs) Init() tea.Cmd { - t.activeTab = 0 - return nil -} - -// Update implements tea.Model. -func (t *Tabs) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "tab": - t.activeTab = (t.activeTab + 1) % len(t.tabs) - cmds = append(cmds, t.activeTabCmd) - case "shift+tab": - t.activeTab = (t.activeTab - 1 + len(t.tabs)) % len(t.tabs) - cmds = append(cmds, t.activeTabCmd) - } - case tea.MouseMsg: - if msg.Type == tea.MouseLeft { - for i, tab := range t.tabs { - if t.common.Zone.Get(tab).InBounds(msg) { - t.activeTab = i - cmds = append(cmds, t.activeTabCmd) - } - } - } - case SelectTabMsg: - tab := int(msg) - if tab >= 0 && tab < len(t.tabs) { - t.activeTab = int(msg) - } - } - return t, tea.Batch(cmds...) -} - -// View implements tea.Model. -func (t *Tabs) View() string { - s := strings.Builder{} - sep := t.TabSeparator - for i, tab := range t.tabs { - style := t.TabInactive.Copy() - prefix := " " - if i == t.activeTab { - style = t.TabActive.Copy() - prefix = t.TabDot.Render("• ") - } - if t.UseDot { - s.WriteString(prefix) - } - s.WriteString( - t.common.Zone.Mark( - tab, - style.Render(tab), - ), - ) - if i != len(t.tabs)-1 { - s.WriteString(sep.String()) - } - } - return lipgloss.NewStyle(). - MaxWidth(t.common.Width). - Render(s.String()) -} - -func (t *Tabs) activeTabCmd() tea.Msg { - return ActiveTabMsg(t.activeTab) -} - -// SelectTabCmd is a bubbletea command that selects the tab at the given index. -func SelectTabCmd(tab int) tea.Cmd { - return func() tea.Msg { - return SelectTabMsg(tab) - } -} diff --git a/server/ui/components/viewport/viewport.go b/server/ui/components/viewport/viewport.go deleted file mode 100644 index 35fe8f24d..000000000 --- a/server/ui/components/viewport/viewport.go +++ /dev/null @@ -1,107 +0,0 @@ -package viewport - -import ( - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/soft-serve/server/ui/common" -) - -// Viewport represents a viewport component. -type Viewport struct { - common common.Common - *viewport.Model -} - -// New returns a new Viewport. -func New(c common.Common) *Viewport { - vp := viewport.New(c.Width, c.Height) - vp.MouseWheelEnabled = true - return &Viewport{ - common: c, - Model: &vp, - } -} - -// SetSize implements common.Component. -func (v *Viewport) SetSize(width, height int) { - v.common.SetSize(width, height) - v.Model.Width = width - v.Model.Height = height -} - -// Init implements tea.Model. -func (v *Viewport) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model. -func (v *Viewport) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, v.common.KeyMap.GotoTop): - v.GotoTop() - case key.Matches(msg, v.common.KeyMap.GotoBottom): - v.GotoBottom() - } - } - vp, cmd := v.Model.Update(msg) - v.Model = &vp - return v, cmd -} - -// View implements tea.Model. -func (v *Viewport) View() string { - return v.Model.View() -} - -// SetContent sets the viewport's content. -func (v *Viewport) SetContent(content string) { - v.Model.SetContent(content) -} - -// GotoTop moves the viewport to the top of the log. -func (v *Viewport) GotoTop() { - v.Model.GotoTop() -} - -// GotoBottom moves the viewport to the bottom of the log. -func (v *Viewport) GotoBottom() { - v.Model.GotoBottom() -} - -// HalfViewDown moves the viewport down by half the viewport height. -func (v *Viewport) HalfViewDown() { - v.Model.HalfViewDown() -} - -// HalfViewUp moves the viewport up by half the viewport height. -func (v *Viewport) HalfViewUp() { - v.Model.HalfViewUp() -} - -// ViewUp moves the viewport up by a page. -func (v *Viewport) ViewUp() []string { - return v.Model.ViewUp() -} - -// ViewDown moves the viewport down by a page. -func (v *Viewport) ViewDown() []string { - return v.Model.ViewDown() -} - -// LineUp moves the viewport up by the given number of lines. -func (v *Viewport) LineUp(n int) []string { - return v.Model.LineUp(n) -} - -// LineDown moves the viewport down by the given number of lines. -func (v *Viewport) LineDown(n int) []string { - return v.Model.LineDown(n) -} - -// ScrollPercent returns the viewport's scroll percentage. -func (v *Viewport) ScrollPercent() float64 { - return v.Model.ScrollPercent() -} diff --git a/server/ui/keymap/keymap.go b/server/ui/keymap/keymap.go deleted file mode 100644 index ae09eaad7..000000000 --- a/server/ui/keymap/keymap.go +++ /dev/null @@ -1,230 +0,0 @@ -package keymap - -import "github.com/charmbracelet/bubbles/key" - -// KeyMap is a map of key bindings for the UI. -type KeyMap struct { - Quit key.Binding - Up key.Binding - Down key.Binding - UpDown key.Binding - LeftRight key.Binding - Arrows key.Binding - GotoTop key.Binding - GotoBottom key.Binding - Select key.Binding - Section key.Binding - Back key.Binding - PrevPage key.Binding - NextPage key.Binding - Help key.Binding - - SelectItem key.Binding - BackItem key.Binding - - Copy key.Binding -} - -// DefaultKeyMap returns the default key map. -func DefaultKeyMap() *KeyMap { - km := new(KeyMap) - - km.Quit = key.NewBinding( - key.WithKeys( - "q", - "ctrl+c", - ), - key.WithHelp( - "q", - "quit", - ), - ) - - km.Up = key.NewBinding( - key.WithKeys( - "up", - "k", - ), - key.WithHelp( - "↑", - "up", - ), - ) - - km.Down = key.NewBinding( - key.WithKeys( - "down", - "j", - ), - key.WithHelp( - "↓", - "down", - ), - ) - - km.UpDown = key.NewBinding( - key.WithKeys( - "up", - "down", - "k", - "j", - ), - key.WithHelp( - "↑↓", - "navigate", - ), - ) - - km.LeftRight = key.NewBinding( - key.WithKeys( - "left", - "h", - "right", - "l", - ), - key.WithHelp( - "←→", - "navigate", - ), - ) - - km.Arrows = key.NewBinding( - key.WithKeys( - "up", - "right", - "down", - "left", - "k", - "j", - "h", - "l", - ), - key.WithHelp( - "↑←↓→", - "navigate", - ), - ) - - km.GotoTop = key.NewBinding( - key.WithKeys( - "home", - "g", - ), - key.WithHelp( - "g/home", - "goto top", - ), - ) - - km.GotoBottom = key.NewBinding( - key.WithKeys( - "end", - "G", - ), - key.WithHelp( - "G/end", - "goto bottom", - ), - ) - - km.Select = key.NewBinding( - key.WithKeys( - "enter", - ), - key.WithHelp( - "enter", - "select", - ), - ) - - km.Section = key.NewBinding( - key.WithKeys( - "tab", - "shift+tab", - ), - key.WithHelp( - "tab", - "section", - ), - ) - - km.Back = key.NewBinding( - key.WithKeys( - "esc", - ), - key.WithHelp( - "esc", - "back", - ), - ) - - km.PrevPage = key.NewBinding( - key.WithKeys( - "pgup", - "b", - "u", - ), - key.WithHelp( - "pgup", - "prev page", - ), - ) - - km.NextPage = key.NewBinding( - key.WithKeys( - "pgdown", - "f", - "d", - ), - key.WithHelp( - "pgdn", - "next page", - ), - ) - - km.Help = key.NewBinding( - key.WithKeys( - "?", - ), - key.WithHelp( - "?", - "toggle help", - ), - ) - - km.SelectItem = key.NewBinding( - key.WithKeys( - "l", - "right", - ), - key.WithHelp( - "→", - "select", - ), - ) - - km.BackItem = key.NewBinding( - key.WithKeys( - "h", - "left", - "backspace", - ), - key.WithHelp( - "←", - "back", - ), - ) - - km.Copy = key.NewBinding( - key.WithKeys( - "c", - "ctrl+c", - ), - key.WithHelp( - "c", - "copy text", - ), - ) - - return km -} diff --git a/server/ui/pages/repo/empty.go b/server/ui/pages/repo/empty.go deleted file mode 100644 index 78ce98726..000000000 --- a/server/ui/pages/repo/empty.go +++ /dev/null @@ -1,40 +0,0 @@ -package repo - -import ( - "fmt" - - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/ui/common" -) - -func defaultEmptyRepoMsg(cfg *config.Config, repo string) string { - return fmt.Sprintf(`# Quick Start - -Get started by cloning this repository, add your files, commit, and push. - -## Clone this repository. - -`+"```"+`sh -git clone %[1]s -`+"```"+` - -## Creating a new repository on the command line - -`+"```"+`sh -touch README.md -git init -git add README.md -git branch -M main -git commit -m "first commit" -git remote add origin %[1]s -git push -u origin main -`+"```"+` - -## Pushing an existing repository from the command line - -`+"```"+`sh -git remote add origin %[1]s -git push -u origin main -`+"```"+` -`, common.RepoURL(cfg.SSH.PublicURL, repo)) -} diff --git a/server/ui/pages/repo/files.go b/server/ui/pages/repo/files.go deleted file mode 100644 index 38521a7be..000000000 --- a/server/ui/pages/repo/files.go +++ /dev/null @@ -1,548 +0,0 @@ -package repo - -import ( - "errors" - "fmt" - "path/filepath" - "strings" - - "github.com/alecthomas/chroma/lexers" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/charmbracelet/soft-serve/server/ui/components/code" - "github.com/charmbracelet/soft-serve/server/ui/components/selector" - gitm "github.com/gogs/git-module" -) - -type filesView int - -const ( - filesViewLoading filesView = iota - filesViewFiles - filesViewContent -) - -var ( - errNoFileSelected = errors.New("no file selected") - errBinaryFile = errors.New("binary file") - errInvalidFile = errors.New("invalid file") -) - -var ( - lineNo = key.NewBinding( - key.WithKeys("l"), - key.WithHelp("l", "toggle line numbers"), - ) - blameView = key.NewBinding( - key.WithKeys("b"), - key.WithHelp("b", "toggle blame view"), - ) - preview = key.NewBinding( - key.WithKeys("p"), - key.WithHelp("p", "toggle preview"), - ) -) - -// FileItemsMsg is a message that contains a list of files. -type FileItemsMsg []selector.IdentifiableItem - -// FileContentMsg is a message that contains the content of a file. -type FileContentMsg struct { - content string - ext string -} - -// FileBlameMsg is a message that contains the blame of a file. -type FileBlameMsg *gitm.Blame - -// Files is the model for the files view. -type Files struct { - common common.Common - selector *selector.Selector - ref *git.Reference - activeView filesView - repo proto.Repository - code *code.Code - path string - currentItem *FileItem - currentContent FileContentMsg - currentBlame FileBlameMsg - lastSelected []int - lineNumber bool - spinner spinner.Model - cursor int - blameView bool -} - -// NewFiles creates a new files model. -func NewFiles(common common.Common) *Files { - f := &Files{ - common: common, - code: code.New(common, "", ""), - activeView: filesViewLoading, - lastSelected: make([]int, 0), - lineNumber: true, - } - selector := selector.New(common, []selector.IdentifiableItem{}, FileItemDelegate{&common}) - selector.SetShowFilter(false) - selector.SetShowHelp(false) - selector.SetShowPagination(false) - selector.SetShowStatusBar(false) - selector.SetShowTitle(false) - selector.SetFilteringEnabled(false) - selector.DisableQuitKeybindings() - selector.KeyMap.NextPage = common.KeyMap.NextPage - selector.KeyMap.PrevPage = common.KeyMap.PrevPage - f.selector = selector - f.code.ShowLineNumber = f.lineNumber - s := spinner.New(spinner.WithSpinner(spinner.Dot), - spinner.WithStyle(common.Styles.Spinner)) - f.spinner = s - return f -} - -// TabName returns the tab name. -func (f *Files) TabName() string { - return "Files" -} - -// SetSize implements common.Component. -func (f *Files) SetSize(width, height int) { - f.common.SetSize(width, height) - f.selector.SetSize(width, height) - f.code.SetSize(width, height) -} - -// ShortHelp implements help.KeyMap. -func (f *Files) ShortHelp() []key.Binding { - k := f.selector.KeyMap - switch f.activeView { - case filesViewFiles: - return []key.Binding{ - f.common.KeyMap.SelectItem, - f.common.KeyMap.BackItem, - k.CursorUp, - k.CursorDown, - } - case filesViewContent: - b := []key.Binding{ - f.common.KeyMap.UpDown, - f.common.KeyMap.BackItem, - } - return b - default: - return []key.Binding{} - } -} - -// FullHelp implements help.KeyMap. -func (f *Files) FullHelp() [][]key.Binding { - b := make([][]key.Binding, 0) - copyKey := f.common.KeyMap.Copy - actionKeys := []key.Binding{ - copyKey, - } - if !f.code.UseGlamour { - actionKeys = append(actionKeys, lineNo) - } - actionKeys = append(actionKeys, blameView) - if f.isSelectedMarkdown() && !f.blameView { - actionKeys = append(actionKeys, preview) - } - switch f.activeView { - case filesViewFiles: - copyKey.SetHelp("c", "copy name") - k := f.selector.KeyMap - b = append(b, [][]key.Binding{ - { - f.common.KeyMap.SelectItem, - f.common.KeyMap.BackItem, - }, - { - k.CursorUp, - k.CursorDown, - k.NextPage, - k.PrevPage, - }, - { - k.GoToStart, - k.GoToEnd, - }, - }...) - case filesViewContent: - copyKey.SetHelp("c", "copy content") - k := f.code.KeyMap - b = append(b, []key.Binding{ - f.common.KeyMap.BackItem, - }) - b = append(b, [][]key.Binding{ - { - k.PageDown, - k.PageUp, - k.HalfPageDown, - k.HalfPageUp, - }, - { - k.Down, - k.Up, - f.common.KeyMap.GotoTop, - f.common.KeyMap.GotoBottom, - }, - }...) - } - return append(b, actionKeys) -} - -// Init implements tea.Model. -func (f *Files) Init() tea.Cmd { - f.path = "" - f.currentItem = nil - f.activeView = filesViewLoading - f.lastSelected = make([]int, 0) - f.blameView = false - f.currentBlame = nil - f.code.UseGlamour = false - return tea.Batch(f.spinner.Tick, f.updateFilesCmd) -} - -// Update implements tea.Model. -func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case RepoMsg: - f.repo = msg - case RefMsg: - f.ref = msg - f.selector.Select(0) - cmds = append(cmds, f.Init()) - case FileItemsMsg: - cmds = append(cmds, - f.selector.SetItems(msg), - ) - f.activeView = filesViewFiles - if f.cursor >= 0 { - f.selector.Select(f.cursor) - f.cursor = -1 - } - case FileContentMsg: - f.activeView = filesViewContent - f.currentContent = msg - f.code.UseGlamour = f.isSelectedMarkdown() - cmds = append(cmds, f.code.SetContent(msg.content, msg.ext)) - f.code.GotoTop() - case FileBlameMsg: - f.currentBlame = msg - f.activeView = filesViewContent - f.code.UseGlamour = false - f.code.SetSideNote(renderBlame(f.common, f.currentItem, msg)) - case selector.SelectMsg: - switch sel := msg.IdentifiableItem.(type) { - case FileItem: - f.currentItem = &sel - f.path = filepath.Join(f.path, sel.entry.Name()) - if sel.entry.IsTree() { - cmds = append(cmds, f.selectTreeCmd) - } else { - cmds = append(cmds, f.selectFileCmd) - } - } - case GoBackMsg: - switch f.activeView { - case filesViewFiles, filesViewContent: - cmds = append(cmds, f.deselectItemCmd()) - } - case tea.KeyMsg: - switch f.activeView { - case filesViewFiles: - switch { - case key.Matches(msg, f.common.KeyMap.SelectItem): - cmds = append(cmds, f.selector.SelectItemCmd) - case key.Matches(msg, f.common.KeyMap.BackItem): - cmds = append(cmds, f.deselectItemCmd()) - } - case filesViewContent: - switch { - case key.Matches(msg, f.common.KeyMap.BackItem): - cmds = append(cmds, f.deselectItemCmd()) - case key.Matches(msg, f.common.KeyMap.Copy): - cmds = append(cmds, copyCmd(f.currentContent.content, "File contents copied to clipboard")) - case key.Matches(msg, lineNo) && !f.code.UseGlamour: - f.lineNumber = !f.lineNumber - f.code.ShowLineNumber = f.lineNumber - cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext)) - case key.Matches(msg, blameView): - f.activeView = filesViewLoading - f.blameView = !f.blameView - if f.blameView { - cmds = append(cmds, f.fetchBlame) - } else { - f.activeView = filesViewContent - cmds = append(cmds, f.code.SetSideNote("")) - } - cmds = append(cmds, f.spinner.Tick) - case key.Matches(msg, preview) && f.isSelectedMarkdown() && !f.blameView: - f.code.UseGlamour = !f.code.UseGlamour - cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext)) - } - } - case tea.WindowSizeMsg: - f.SetSize(msg.Width, msg.Height) - switch f.activeView { - case filesViewFiles: - if f.repo != nil { - cmds = append(cmds, f.updateFilesCmd) - } - case filesViewContent: - if f.currentContent.content != "" { - m, cmd := f.code.Update(msg) - f.code = m.(*code.Code) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - } - case EmptyRepoMsg: - f.ref = nil - f.path = "" - f.currentItem = nil - f.activeView = filesViewFiles - f.lastSelected = make([]int, 0) - f.selector.Select(0) - cmds = append(cmds, f.setItems([]selector.IdentifiableItem{})) - case spinner.TickMsg: - if f.activeView == filesViewLoading && f.spinner.ID() == msg.ID { - s, cmd := f.spinner.Update(msg) - f.spinner = s - if cmd != nil { - cmds = append(cmds, cmd) - } - } - } - switch f.activeView { - case filesViewFiles: - m, cmd := f.selector.Update(msg) - f.selector = m.(*selector.Selector) - if cmd != nil { - cmds = append(cmds, cmd) - } - case filesViewContent: - m, cmd := f.code.Update(msg) - f.code = m.(*code.Code) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - return f, tea.Batch(cmds...) -} - -// View implements tea.Model. -func (f *Files) View() string { - switch f.activeView { - case filesViewLoading: - return renderLoading(f.common, f.spinner) - case filesViewFiles: - return f.selector.View() - case filesViewContent: - return f.code.View() - default: - return "" - } -} - -// SpinnerID implements common.TabComponent. -func (f *Files) SpinnerID() int { - return f.spinner.ID() -} - -// StatusBarValue returns the status bar value. -func (f *Files) StatusBarValue() string { - p := f.path - if p == "." || p == "" { - return " " - } - return p -} - -// StatusBarInfo returns the status bar info. -func (f *Files) StatusBarInfo() string { - switch f.activeView { - case filesViewFiles: - return fmt.Sprintf("# %d/%d", f.selector.Index()+1, len(f.selector.VisibleItems())) - case filesViewContent: - return fmt.Sprintf("☰ %d%%", f.code.ScrollPosition()) - default: - return "" - } -} - -func (f *Files) updateFilesCmd() tea.Msg { - files := make([]selector.IdentifiableItem, 0) - dirs := make([]selector.IdentifiableItem, 0) - if f.ref == nil { - return nil - } - r, err := f.repo.Open() - if err != nil { - return common.ErrorCmd(err) - } - path := f.path - ref := f.ref - t, err := r.TreePath(ref, path) - if err != nil { - return common.ErrorCmd(err) - } - ents, err := t.Entries() - if err != nil { - return common.ErrorCmd(err) - } - ents.Sort() - for _, e := range ents { - if e.IsTree() { - dirs = append(dirs, FileItem{entry: e}) - } else { - files = append(files, FileItem{entry: e}) - } - } - return FileItemsMsg(append(dirs, files...)) -} - -func (f *Files) selectTreeCmd() tea.Msg { - if f.currentItem != nil && f.currentItem.entry.IsTree() { - f.lastSelected = append(f.lastSelected, f.selector.Index()) - f.cursor = 0 - return f.updateFilesCmd() - } - return common.ErrorMsg(errNoFileSelected) -} - -func (f *Files) selectFileCmd() tea.Msg { - i := f.currentItem - if i != nil && !i.entry.IsTree() { - fi := i.entry.File() - if i.Mode().IsDir() || f == nil { - return common.ErrorMsg(errInvalidFile) - } - - var err error - var bin bool - - r, err := f.repo.Open() - if err == nil { - attrs, err := r.CheckAttributes(f.ref, fi.Path()) - if err == nil { - for _, attr := range attrs { - if (attr.Name == "binary" && attr.Value == "set") || - (attr.Name == "text" && attr.Value == "unset") { - bin = true - break - } - } - } - } - - if !bin { - bin, err = fi.IsBinary() - if err != nil { - f.path = filepath.Dir(f.path) - return common.ErrorMsg(err) - } - } - - if bin { - f.path = filepath.Dir(f.path) - return common.ErrorMsg(errBinaryFile) - } - - c, err := fi.Bytes() - if err != nil { - f.path = filepath.Dir(f.path) - return common.ErrorMsg(err) - } - - f.lastSelected = append(f.lastSelected, f.selector.Index()) - return FileContentMsg{string(c), i.entry.Name()} - } - - return common.ErrorMsg(errNoFileSelected) -} - -func (f *Files) fetchBlame() tea.Msg { - r, err := f.repo.Open() - if err != nil { - return common.ErrorMsg(err) - } - - b, err := r.BlameFile(f.ref.ID, f.currentItem.entry.File().Path()) - if err != nil { - return common.ErrorMsg(err) - } - - return FileBlameMsg(b) -} - -func renderBlame(c common.Common, f *FileItem, b *gitm.Blame) string { - if f == nil || f.entry.IsTree() || b == nil { - return "" - } - - lines := make([]string, 0) - i := 1 - var prev string - for { - commit := b.Line(i) - if commit == nil { - break - } - line := fmt.Sprintf("%s %s", - c.Styles.Tree.Blame.Hash.Render(commit.ID.String()[:7]), - c.Styles.Tree.Blame.Message.Render(commit.Summary()), - ) - if line != prev { - lines = append(lines, line) - } else { - lines = append(lines, "") - } - prev = line - i++ - } - - return strings.Join(lines, "\n") -} - -func (f *Files) deselectItemCmd() tea.Cmd { - f.path = filepath.Dir(f.path) - index := 0 - if len(f.lastSelected) > 0 { - index = f.lastSelected[len(f.lastSelected)-1] - f.lastSelected = f.lastSelected[:len(f.lastSelected)-1] - } - f.cursor = index - f.activeView = filesViewFiles - f.code.SetSideNote("") - f.blameView = false - f.currentBlame = nil - f.code.UseGlamour = false - return f.updateFilesCmd -} - -func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd { - return func() tea.Msg { - return FileItemsMsg(items) - } -} - -func (f *Files) isSelectedMarkdown() bool { - var lang string - lexer := lexers.Match(f.currentContent.ext) - if lexer == nil { - lexer = lexers.Analyse(f.currentContent.content) - } - if lexer != nil && lexer.Config() != nil { - lang = lexer.Config().Name - } - return lang == "markdown" -} diff --git a/server/ui/pages/repo/filesitem.go b/server/ui/pages/repo/filesitem.go deleted file mode 100644 index c4f358b50..000000000 --- a/server/ui/pages/repo/filesitem.go +++ /dev/null @@ -1,155 +0,0 @@ -package repo - -import ( - "fmt" - "io" - "io/fs" - "strings" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/dustin/go-humanize" -) - -// FileItem is a list item for a file. -type FileItem struct { - entry *git.TreeEntry -} - -// ID returns the ID of the file item. -func (i FileItem) ID() string { - return i.entry.Name() -} - -// Title returns the title of the file item. -func (i FileItem) Title() string { - return i.entry.Name() -} - -// Description returns the description of the file item. -func (i FileItem) Description() string { - return "" -} - -// Mode returns the mode of the file item. -func (i FileItem) Mode() fs.FileMode { - return i.entry.Mode() -} - -// FilterValue implements list.Item. -func (i FileItem) FilterValue() string { return i.Title() } - -// FileItems is a list of file items. -type FileItems []FileItem - -// Len implements sort.Interface. -func (cl FileItems) Len() int { return len(cl) } - -// Swap implements sort.Interface. -func (cl FileItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } - -// Less implements sort.Interface. -func (cl FileItems) Less(i, j int) bool { - if cl[i].entry.IsTree() && cl[j].entry.IsTree() { - return cl[i].Title() < cl[j].Title() - } else if cl[i].entry.IsTree() { - return true - } else if cl[j].entry.IsTree() { - return false - } - return cl[i].Title() < cl[j].Title() -} - -// FileItemDelegate is the delegate for the file item list. -type FileItemDelegate struct { - common *common.Common -} - -// Height returns the height of the file item list. Implements list.ItemDelegate. -func (d FileItemDelegate) Height() int { return 1 } - -// Spacing returns the spacing of the file item list. Implements list.ItemDelegate. -func (d FileItemDelegate) Spacing() int { return 0 } - -// Update implements list.ItemDelegate. -func (d FileItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { - item, ok := m.SelectedItem().(FileItem) - if !ok { - return nil - } - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, d.common.KeyMap.Copy): - return copyCmd(item.entry.Name(), fmt.Sprintf("File name %q copied to clipboard", item.entry.Name())) - } - } - return nil -} - -// Render implements list.ItemDelegate. -func (d FileItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - i, ok := listItem.(FileItem) - if !ok { - return - } - - s := d.common.Styles.Tree - - name := i.Title() - size := humanize.Bytes(uint64(i.entry.Size())) - size = strings.ReplaceAll(size, " ", "") - sizeLen := lipgloss.Width(size) - if i.entry.IsTree() { - size = strings.Repeat(" ", sizeLen) - if index == m.Index() { - name = s.Active.FileDir.Render(name) - } else { - name = s.Normal.FileDir.Render(name) - } - } - var nameStyle, sizeStyle, modeStyle lipgloss.Style - mode := i.Mode() - if index == m.Index() { - nameStyle = s.Active.FileName - sizeStyle = s.Active.FileSize - modeStyle = s.Active.FileMode - fmt.Fprint(w, s.Selector.Render(">")) - } else { - nameStyle = s.Normal.FileName - sizeStyle = s.Normal.FileSize - modeStyle = s.Normal.FileMode - fmt.Fprint(w, s.Selector.Render(" ")) - } - sizeStyle = sizeStyle.Copy(). - Width(8). - Align(lipgloss.Right). - MarginLeft(1) - leftMargin := s.Selector.GetMarginLeft() + - s.Selector.GetWidth() + - s.Normal.FileMode.GetMarginLeft() + - s.Normal.FileMode.GetWidth() + - nameStyle.GetMarginLeft() + - sizeStyle.GetHorizontalFrameSize() - name = common.TruncateString(name, m.Width()-leftMargin) - name = nameStyle.Render(name) - size = sizeStyle.Render(size) - modeStr := modeStyle.Render(mode.String()) - truncate := lipgloss.NewStyle().MaxWidth(m.Width() - - s.Selector.GetHorizontalFrameSize() - - s.Selector.GetWidth()) - fmt.Fprint(w, - d.common.Zone.Mark( - i.ID(), - truncate.Render(fmt.Sprintf("%s%s%s", - modeStr, - size, - name, - )), - ), - ) -} diff --git a/server/ui/pages/repo/log.go b/server/ui/pages/repo/log.go deleted file mode 100644 index 5ea585431..000000000 --- a/server/ui/pages/repo/log.go +++ /dev/null @@ -1,534 +0,0 @@ -package repo - -import ( - "fmt" - "strings" - "time" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - gansi "github.com/charmbracelet/glamour/ansi" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/charmbracelet/soft-serve/server/ui/components/footer" - "github.com/charmbracelet/soft-serve/server/ui/components/selector" - "github.com/charmbracelet/soft-serve/server/ui/components/viewport" - "github.com/charmbracelet/soft-serve/server/ui/styles" - "github.com/muesli/reflow/wrap" - "github.com/muesli/termenv" -) - -var waitBeforeLoading = time.Millisecond * 100 - -type logView int - -const ( - logViewLoading logView = iota - logViewCommits - logViewDiff -) - -// LogCountMsg is a message that contains the number of commits in a repo. -type LogCountMsg int64 - -// LogItemsMsg is a message that contains a slice of LogItem. -type LogItemsMsg []selector.IdentifiableItem - -// LogCommitMsg is a message that contains a git commit. -type LogCommitMsg *git.Commit - -// LogDiffMsg is a message that contains a git diff. -type LogDiffMsg *git.Diff - -// Log is a model that displays a list of commits and their diffs. -type Log struct { - common common.Common - selector *selector.Selector - vp *viewport.Viewport - activeView logView - repo proto.Repository - ref *git.Reference - count int64 - nextPage int - activeCommit *git.Commit - selectedCommit *git.Commit - currentDiff *git.Diff - loadingTime time.Time - spinner spinner.Model -} - -// NewLog creates a new Log model. -func NewLog(common common.Common) *Log { - l := &Log{ - common: common, - vp: viewport.New(common), - activeView: logViewCommits, - } - selector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{&common}) - selector.SetShowFilter(false) - selector.SetShowHelp(false) - selector.SetShowPagination(false) - selector.SetShowStatusBar(false) - selector.SetShowTitle(false) - selector.SetFilteringEnabled(false) - selector.DisableQuitKeybindings() - selector.KeyMap.NextPage = common.KeyMap.NextPage - selector.KeyMap.PrevPage = common.KeyMap.PrevPage - l.selector = selector - s := spinner.New(spinner.WithSpinner(spinner.Dot), - spinner.WithStyle(common.Styles.Spinner)) - l.spinner = s - return l -} - -// TabName returns the name of the tab. -func (l *Log) TabName() string { - return "Commits" -} - -// SetSize implements common.Component. -func (l *Log) SetSize(width, height int) { - l.common.SetSize(width, height) - l.selector.SetSize(width, height) - l.vp.SetSize(width, height) -} - -// ShortHelp implements help.KeyMap. -func (l *Log) ShortHelp() []key.Binding { - switch l.activeView { - case logViewCommits: - copyKey := l.common.KeyMap.Copy - copyKey.SetHelp("c", "copy hash") - return []key.Binding{ - l.common.KeyMap.UpDown, - l.common.KeyMap.SelectItem, - copyKey, - } - case logViewDiff: - return []key.Binding{ - l.common.KeyMap.UpDown, - l.common.KeyMap.BackItem, - l.common.KeyMap.GotoTop, - l.common.KeyMap.GotoBottom, - } - default: - return []key.Binding{} - } -} - -// FullHelp implements help.KeyMap. -func (l *Log) FullHelp() [][]key.Binding { - k := l.selector.KeyMap - b := make([][]key.Binding, 0) - switch l.activeView { - case logViewCommits: - copyKey := l.common.KeyMap.Copy - copyKey.SetHelp("c", "copy hash") - b = append(b, []key.Binding{ - l.common.KeyMap.SelectItem, - l.common.KeyMap.BackItem, - }) - b = append(b, [][]key.Binding{ - { - copyKey, - k.CursorUp, - k.CursorDown, - }, - { - k.NextPage, - k.PrevPage, - k.GoToStart, - k.GoToEnd, - }, - }...) - case logViewDiff: - k := l.vp.KeyMap - b = append(b, []key.Binding{ - l.common.KeyMap.BackItem, - }) - b = append(b, [][]key.Binding{ - { - k.PageDown, - k.PageUp, - k.HalfPageDown, - k.HalfPageUp, - }, - { - k.Down, - k.Up, - l.common.KeyMap.GotoTop, - l.common.KeyMap.GotoBottom, - }, - }...) - } - return b -} - -func (l *Log) startLoading() tea.Cmd { - l.loadingTime = time.Now() - l.activeView = logViewLoading - return l.spinner.Tick -} - -// Init implements tea.Model. -func (l *Log) Init() tea.Cmd { - l.activeView = logViewCommits - l.nextPage = 0 - l.count = 0 - l.activeCommit = nil - l.selectedCommit = nil - return tea.Batch( - l.countCommitsCmd, - // start loading on init - l.startLoading(), - ) -} - -// Update implements tea.Model. -func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case RepoMsg: - l.repo = msg - case RefMsg: - l.ref = msg - l.selector.Select(0) - cmds = append(cmds, l.Init()) - case LogCountMsg: - l.count = int64(msg) - l.selector.SetTotalPages(int(msg)) - l.selector.SetItems(make([]selector.IdentifiableItem, l.count)) - cmds = append(cmds, l.updateCommitsCmd) - case LogItemsMsg: - // stop loading after receiving items - l.activeView = logViewCommits - cmds = append(cmds, l.selector.SetItems(msg)) - l.selector.SetPage(l.nextPage) - l.SetSize(l.common.Width, l.common.Height) - i := l.selector.SelectedItem() - if i != nil { - l.activeCommit = i.(LogItem).Commit - } - case tea.KeyMsg, tea.MouseMsg: - switch l.activeView { - case logViewCommits: - switch kmsg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(kmsg, l.common.KeyMap.SelectItem): - cmds = append(cmds, l.selector.SelectItemCmd) - } - } - // XXX: This is a hack for loading commits on demand based on - // list.Pagination. - curPage := l.selector.Page() - s, cmd := l.selector.Update(msg) - m := s.(*selector.Selector) - l.selector = m - if m.Page() != curPage { - l.nextPage = m.Page() - l.selector.SetPage(curPage) - cmds = append(cmds, - l.updateCommitsCmd, - l.startLoading(), - ) - } - cmds = append(cmds, cmd) - case logViewDiff: - switch kmsg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(kmsg, l.common.KeyMap.BackItem): - l.goBack() - } - } - } - case GoBackMsg: - l.goBack() - case selector.ActiveMsg: - switch sel := msg.IdentifiableItem.(type) { - case LogItem: - l.activeCommit = sel.Commit - } - case selector.SelectMsg: - switch sel := msg.IdentifiableItem.(type) { - case LogItem: - cmds = append(cmds, - l.selectCommitCmd(sel.Commit), - l.startLoading(), - ) - } - case LogCommitMsg: - l.selectedCommit = msg - cmds = append(cmds, l.loadDiffCmd) - case LogDiffMsg: - l.currentDiff = msg - l.vp.SetContent( - lipgloss.JoinVertical(lipgloss.Top, - l.renderCommit(l.selectedCommit), - renderSummary(msg, l.common.Styles, l.common.Width), - renderDiff(msg, l.common.Width), - ), - ) - l.vp.GotoTop() - l.activeView = logViewDiff - case footer.ToggleFooterMsg: - cmds = append(cmds, l.updateCommitsCmd) - case tea.WindowSizeMsg: - l.SetSize(msg.Width, msg.Height) - if l.selectedCommit != nil && l.currentDiff != nil { - l.vp.SetContent( - lipgloss.JoinVertical(lipgloss.Top, - l.renderCommit(l.selectedCommit), - renderSummary(l.currentDiff, l.common.Styles, l.common.Width), - renderDiff(l.currentDiff, l.common.Width), - ), - ) - } - if l.repo != nil && l.ref != nil { - cmds = append(cmds, - l.updateCommitsCmd, - // start loading on resize since the number of commits per page - // might change and we'd need to load more commits. - l.startLoading(), - ) - } - case EmptyRepoMsg: - l.ref = nil - l.activeView = logViewCommits - l.nextPage = 0 - l.count = 0 - l.activeCommit = nil - l.selectedCommit = nil - l.selector.Select(0) - cmds = append(cmds, - l.setItems([]selector.IdentifiableItem{}), - ) - case spinner.TickMsg: - if l.activeView == logViewLoading && l.spinner.ID() == msg.ID { - s, cmd := l.spinner.Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - l.spinner = s - } - } - switch l.activeView { - case logViewDiff: - vp, cmd := l.vp.Update(msg) - l.vp = vp.(*viewport.Viewport) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - return l, tea.Batch(cmds...) -} - -// View implements tea.Model. -func (l *Log) View() string { - switch l.activeView { - case logViewLoading: - if l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) { - msg := fmt.Sprintf("%s loading commit", l.spinner.View()) - if l.selectedCommit == nil { - msg += "s" - } - msg += "…" - return l.common.Styles.SpinnerContainer.Copy(). - Height(l.common.Height). - Render(msg) - } - fallthrough - case logViewCommits: - return l.selector.View() - case logViewDiff: - return l.vp.View() - default: - return "" - } -} - -// SpinnerID implements common.TabComponent. -func (l *Log) SpinnerID() int { - return l.spinner.ID() -} - -// StatusBarValue returns the status bar value. -func (l *Log) StatusBarValue() string { - if l.activeView == logViewLoading { - return "" - } - c := l.activeCommit - if c == nil { - return "" - } - who := c.Author.Name - if email := c.Author.Email; email != "" { - who += " <" + email + ">" - } - value := c.ID.String()[:7] - if who != "" { - value += " by " + who - } - return value -} - -// StatusBarInfo returns the status bar info. -func (l *Log) StatusBarInfo() string { - switch l.activeView { - case logViewLoading: - if l.count == 0 { - return "" - } - fallthrough - case logViewCommits: - // We're using l.nextPage instead of l.selector.Paginator.Page because - // of the paginator hack above. - return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages()) - case logViewDiff: - return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100) - default: - return "" - } -} - -func (l *Log) goBack() { - if l.activeView == logViewDiff { - l.activeView = logViewCommits - l.selectedCommit = nil - } -} - -func (l *Log) countCommitsCmd() tea.Msg { - if l.ref == nil { - return nil - } - r, err := l.repo.Open() - if err != nil { - return common.ErrorMsg(err) - } - count, err := r.CountCommits(l.ref) - if err != nil { - l.common.Logger.Debugf("ui: error counting commits: %v", err) - return common.ErrorMsg(err) - } - return LogCountMsg(count) -} - -func (l *Log) updateCommitsCmd() tea.Msg { - if l.ref == nil { - return nil - } - r, err := l.repo.Open() - if err != nil { - return common.ErrorMsg(err) - } - - count := l.count - if count == 0 { - return LogItemsMsg([]selector.IdentifiableItem{}) - } - - page := l.nextPage - limit := l.selector.PerPage() - skip := page * limit - ref := l.ref - items := make([]selector.IdentifiableItem, count) - // CommitsByPage pages start at 1 - cc, err := r.CommitsByPage(ref, page+1, limit) - if err != nil { - l.common.Logger.Debugf("ui: error loading commits: %v", err) - return common.ErrorMsg(err) - } - for i, c := range cc { - idx := i + skip - if int64(idx) >= count { - break - } - items[idx] = LogItem{Commit: c} - } - return LogItemsMsg(items) -} - -func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd { - return func() tea.Msg { - return LogCommitMsg(commit) - } -} - -func (l *Log) loadDiffCmd() tea.Msg { - if l.selectedCommit == nil { - return nil - } - r, err := l.repo.Open() - if err != nil { - l.common.Logger.Debugf("ui: error loading diff repository: %v", err) - return common.ErrorMsg(err) - } - diff, err := r.Diff(l.selectedCommit) - if err != nil { - l.common.Logger.Debugf("ui: error loading diff: %v", err) - return common.ErrorMsg(err) - } - return LogDiffMsg(diff) -} - -func renderCtx() gansi.RenderContext { - return gansi.NewRenderContext(gansi.Options{ - ColorProfile: termenv.TrueColor, - Styles: common.StyleConfig(), - }) -} - -func (l *Log) renderCommit(c *git.Commit) string { - s := strings.Builder{} - // FIXME: lipgloss prints empty lines when CRLF is used - // sanitize commit message from CRLF - msg := strings.ReplaceAll(c.Message, "\r\n", "\n") - s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n", - l.common.Styles.Log.CommitHash.Render("commit "+c.ID.String()), - l.common.Styles.Log.CommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)), - l.common.Styles.Log.CommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)), - l.common.Styles.Log.CommitBody.Render(msg), - )) - return wrap.String(s.String(), l.common.Width-2) -} - -func renderSummary(diff *git.Diff, styles *styles.Styles, width int) string { - stats := strings.Split(diff.Stats().String(), "\n") - for i, line := range stats { - ch := strings.Split(line, "|") - if len(ch) > 1 { - adddel := ch[len(ch)-1] - adddel = strings.ReplaceAll(adddel, "+", styles.Log.CommitStatsAdd.Render("+")) - adddel = strings.ReplaceAll(adddel, "-", styles.Log.CommitStatsDel.Render("-")) - stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel - } - } - return wrap.String(strings.Join(stats, "\n"), width-2) -} - -func renderDiff(diff *git.Diff, width int) string { - var s strings.Builder - var pr strings.Builder - diffChroma := &gansi.CodeBlockElement{ - Code: diff.Patch(), - Language: "diff", - } - err := diffChroma.Render(&pr, renderCtx()) - if err != nil { - s.WriteString(fmt.Sprintf("\n%s", err.Error())) - } else { - s.WriteString(fmt.Sprintf("\n%s", pr.String())) - } - return wrap.String(s.String(), width) -} - -func (l *Log) setItems(items []selector.IdentifiableItem) tea.Cmd { - return func() tea.Msg { - return LogItemsMsg(items) - } -} diff --git a/server/ui/pages/repo/logitem.go b/server/ui/pages/repo/logitem.go deleted file mode 100644 index bd29ec8d0..000000000 --- a/server/ui/pages/repo/logitem.go +++ /dev/null @@ -1,147 +0,0 @@ -package repo - -import ( - "fmt" - "io" - "strings" - "time" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/muesli/reflow/truncate" -) - -// LogItem is a item in the log list that displays a git commit. -type LogItem struct { - *git.Commit -} - -// ID implements selector.IdentifiableItem. -func (i LogItem) ID() string { - return i.Hash() -} - -// Hash returns the commit hash. -func (i LogItem) Hash() string { - return i.Commit.ID.String() -} - -// Title returns the item title. Implements list.DefaultItem. -func (i LogItem) Title() string { - if i.Commit != nil { - return strings.Split(i.Commit.Message, "\n")[0] - } - return "" -} - -// Description returns the item description. Implements list.DefaultItem. -func (i LogItem) Description() string { return "" } - -// FilterValue implements list.Item. -func (i LogItem) FilterValue() string { return i.Title() } - -// LogItemDelegate is the delegate for LogItem. -type LogItemDelegate struct { - common *common.Common -} - -// Height returns the item height. Implements list.ItemDelegate. -func (d LogItemDelegate) Height() int { return 2 } - -// Spacing returns the item spacing. Implements list.ItemDelegate. -func (d LogItemDelegate) Spacing() int { return 1 } - -// Update updates the item. Implements list.ItemDelegate. -func (d LogItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { - item, ok := m.SelectedItem().(LogItem) - if !ok { - return nil - } - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, d.common.KeyMap.Copy): - return copyCmd(item.Hash(), "Commit hash copied to clipboard") - } - } - return nil -} - -// Render renders the item. Implements list.ItemDelegate. -func (d LogItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - i, ok := listItem.(LogItem) - if !ok { - return - } - if i.Commit == nil { - return - } - - styles := d.common.Styles.LogItem.Normal - if index == m.Index() { - styles = d.common.Styles.LogItem.Active - } - - horizontalFrameSize := styles.Base.GetHorizontalFrameSize() - - hash := i.Commit.ID.String()[:7] - title := styles.Title.Render( - common.TruncateString(i.Title(), - m.Width()- - horizontalFrameSize- - // 9 is the length of the hash (7) + the left padding (1) + the - // title truncation symbol (1) - 9), - ) - hashStyle := styles.Hash.Copy(). - Align(lipgloss.Right). - PaddingLeft(1). - Width(m.Width() - - horizontalFrameSize - - lipgloss.Width(title) - 1) // 1 is for the left padding - if index == m.Index() { - hashStyle = hashStyle.Bold(true) - } - hash = hashStyle.Render(hash) - if m.Width()-horizontalFrameSize-hashStyle.GetHorizontalFrameSize()-hashStyle.GetWidth() <= 0 { - hash = "" - title = styles.Title.Render( - common.TruncateString(i.Title(), - m.Width()-horizontalFrameSize), - ) - } - author := i.Author.Name - committer := i.Committer.Name - who := "" - if author != "" && committer != "" { - who = styles.Keyword.Render(committer) + styles.Desc.Render(" committed") - if author != committer { - who = styles.Keyword.Render(author) + styles.Desc.Render(" authored and ") + who - } - who += " " - } - date := i.Committer.When.Format("Jan 02") - if i.Committer.When.Year() != time.Now().Year() { - date += fmt.Sprintf(" %d", i.Committer.When.Year()) - } - who += styles.Desc.Render("on ") + styles.Keyword.Render(date) - who = common.TruncateString(who, m.Width()-horizontalFrameSize) - fmt.Fprint(w, - d.common.Zone.Mark( - i.ID(), - styles.Base.Render( - lipgloss.JoinVertical(lipgloss.Top, - truncate.String(fmt.Sprintf("%s%s", - title, - hash, - ), uint(m.Width()-horizontalFrameSize)), - who, - ), - ), - ), - ) -} diff --git a/server/ui/pages/repo/readme.go b/server/ui/pages/repo/readme.go deleted file mode 100644 index fe7207326..000000000 --- a/server/ui/pages/repo/readme.go +++ /dev/null @@ -1,167 +0,0 @@ -package repo - -import ( - "fmt" - "path/filepath" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/charmbracelet/soft-serve/server/ui/components/code" -) - -// ReadmeMsg is a message sent when the readme is loaded. -type ReadmeMsg struct { - Content string - Path string -} - -// Readme is the readme component page. -type Readme struct { - common common.Common - code *code.Code - ref RefMsg - repo proto.Repository - readmePath string - spinner spinner.Model - isLoading bool -} - -// NewReadme creates a new readme model. -func NewReadme(common common.Common) *Readme { - readme := code.New(common, "", "") - readme.NoContentStyle = readme.NoContentStyle.Copy().SetString("No readme found.") - readme.UseGlamour = true - s := spinner.New(spinner.WithSpinner(spinner.Dot), - spinner.WithStyle(common.Styles.Spinner)) - return &Readme{ - code: readme, - common: common, - spinner: s, - isLoading: true, - } -} - -// TabName returns the name of the tab. -func (r *Readme) TabName() string { - return "Readme" -} - -// SetSize implements common.Component. -func (r *Readme) SetSize(width, height int) { - r.common.SetSize(width, height) - r.code.SetSize(width, height) -} - -// ShortHelp implements help.KeyMap. -func (r *Readme) ShortHelp() []key.Binding { - b := []key.Binding{ - r.common.KeyMap.UpDown, - } - return b -} - -// FullHelp implements help.KeyMap. -func (r *Readme) FullHelp() [][]key.Binding { - k := r.code.KeyMap - b := [][]key.Binding{ - { - k.PageDown, - k.PageUp, - k.HalfPageDown, - k.HalfPageUp, - }, - { - k.Down, - k.Up, - r.common.KeyMap.GotoTop, - r.common.KeyMap.GotoBottom, - }, - } - return b -} - -// Init implements tea.Model. -func (r *Readme) Init() tea.Cmd { - r.isLoading = true - return tea.Batch(r.spinner.Tick, r.updateReadmeCmd) -} - -// Update implements tea.Model. -func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case RepoMsg: - r.repo = msg - case RefMsg: - r.ref = msg - cmds = append(cmds, r.Init()) - case tea.WindowSizeMsg: - r.SetSize(msg.Width, msg.Height) - case EmptyRepoMsg: - cmds = append(cmds, - r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(), - r.repo.Name()), ".md"), - ) - case ReadmeMsg: - r.isLoading = false - r.readmePath = msg.Path - r.code.GotoTop() - cmds = append(cmds, r.code.SetContent(msg.Content, msg.Path)) - case spinner.TickMsg: - if r.isLoading && r.spinner.ID() == msg.ID { - s, cmd := r.spinner.Update(msg) - r.spinner = s - if cmd != nil { - cmds = append(cmds, cmd) - } - } - } - c, cmd := r.code.Update(msg) - r.code = c.(*code.Code) - if cmd != nil { - cmds = append(cmds, cmd) - } - return r, tea.Batch(cmds...) -} - -// View implements tea.Model. -func (r *Readme) View() string { - if r.isLoading { - return renderLoading(r.common, r.spinner) - } - return r.code.View() -} - -// SpinnerID implements common.TabComponent. -func (r *Readme) SpinnerID() int { - return r.spinner.ID() -} - -// StatusBarValue implements statusbar.StatusBar. -func (r *Readme) StatusBarValue() string { - dir := filepath.Dir(r.readmePath) - if dir == "." || dir == "" { - return " " - } - return dir -} - -// StatusBarInfo implements statusbar.StatusBar. -func (r *Readme) StatusBarInfo() string { - return fmt.Sprintf("☰ %d%%", r.code.ScrollPosition()) -} - -func (r *Readme) updateReadmeCmd() tea.Msg { - m := ReadmeMsg{} - if r.repo == nil { - return common.ErrorMsg(common.ErrMissingRepo) - } - rm, rp, _ := backend.Readme(r.repo, r.ref) - m.Content = rm - m.Path = rp - return m -} diff --git a/server/ui/pages/repo/refs.go b/server/ui/pages/repo/refs.go deleted file mode 100644 index e89eee409..000000000 --- a/server/ui/pages/repo/refs.go +++ /dev/null @@ -1,276 +0,0 @@ -package repo - -import ( - "fmt" - "sort" - "strings" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/charmbracelet/soft-serve/server/ui/components/selector" -) - -// RefMsg is a message that contains a git.Reference. -type RefMsg *git.Reference - -// RefItemsMsg is a message that contains a list of RefItem. -type RefItemsMsg struct { - prefix string - items []selector.IdentifiableItem -} - -// Refs is a component that displays a list of references. -type Refs struct { - common common.Common - selector *selector.Selector - repo proto.Repository - ref *git.Reference - activeRef *git.Reference - refPrefix string - spinner spinner.Model - isLoading bool -} - -// NewRefs creates a new Refs component. -func NewRefs(common common.Common, refPrefix string) *Refs { - r := &Refs{ - common: common, - refPrefix: refPrefix, - isLoading: true, - } - s := selector.New(common, []selector.IdentifiableItem{}, RefItemDelegate{&common}) - s.SetShowFilter(false) - s.SetShowHelp(false) - s.SetShowPagination(false) - s.SetShowStatusBar(false) - s.SetShowTitle(false) - s.SetFilteringEnabled(false) - s.DisableQuitKeybindings() - r.selector = s - sp := spinner.New(spinner.WithSpinner(spinner.Dot), - spinner.WithStyle(common.Styles.Spinner)) - r.spinner = sp - return r -} - -// TabName returns the name of the tab. -func (r *Refs) TabName() string { - if r.refPrefix == git.RefsHeads { - return "Branches" - } else if r.refPrefix == git.RefsTags { - return "Tags" - } - return "Refs" -} - -// SetSize implements common.Component. -func (r *Refs) SetSize(width, height int) { - r.common.SetSize(width, height) - r.selector.SetSize(width, height) -} - -// ShortHelp implements help.KeyMap. -func (r *Refs) ShortHelp() []key.Binding { - copyKey := r.common.KeyMap.Copy - copyKey.SetHelp("c", "copy ref") - k := r.selector.KeyMap - return []key.Binding{ - r.common.KeyMap.SelectItem, - k.CursorUp, - k.CursorDown, - copyKey, - } -} - -// FullHelp implements help.KeyMap. -func (r *Refs) FullHelp() [][]key.Binding { - copyKey := r.common.KeyMap.Copy - copyKey.SetHelp("c", "copy ref") - k := r.selector.KeyMap - return [][]key.Binding{ - {r.common.KeyMap.SelectItem}, - { - k.CursorUp, - k.CursorDown, - k.NextPage, - k.PrevPage, - }, - { - k.GoToStart, - k.GoToEnd, - copyKey, - }, - } -} - -// Init implements tea.Model. -func (r *Refs) Init() tea.Cmd { - r.isLoading = true - return tea.Batch(r.spinner.Tick, r.updateItemsCmd) -} - -// Update implements tea.Model. -func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case RepoMsg: - r.selector.Select(0) - r.repo = msg - case RefMsg: - r.ref = msg - cmds = append(cmds, r.Init()) - case tea.WindowSizeMsg: - r.SetSize(msg.Width, msg.Height) - case RefItemsMsg: - if r.refPrefix == msg.prefix { - cmds = append(cmds, r.selector.SetItems(msg.items)) - i := r.selector.SelectedItem() - if i != nil { - r.activeRef = i.(RefItem).Reference - } - r.isLoading = false - } - case selector.ActiveMsg: - switch sel := msg.IdentifiableItem.(type) { - case RefItem: - r.activeRef = sel.Reference - } - case selector.SelectMsg: - switch i := msg.IdentifiableItem.(type) { - case RefItem: - cmds = append(cmds, - switchRefCmd(i.Reference), - switchTabCmd(&Files{}), - ) - } - case tea.KeyMsg: - switch { - case key.Matches(msg, r.common.KeyMap.SelectItem): - cmds = append(cmds, r.selector.SelectItemCmd) - } - case EmptyRepoMsg: - r.ref = nil - cmds = append(cmds, r.setItems([]selector.IdentifiableItem{})) - case spinner.TickMsg: - if r.isLoading && r.spinner.ID() == msg.ID { - s, cmd := r.spinner.Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - r.spinner = s - } - } - m, cmd := r.selector.Update(msg) - r.selector = m.(*selector.Selector) - if cmd != nil { - cmds = append(cmds, cmd) - } - return r, tea.Batch(cmds...) -} - -// View implements tea.Model. -func (r *Refs) View() string { - if r.isLoading { - return renderLoading(r.common, r.spinner) - } - return r.selector.View() -} - -// SpinnerID implements common.TabComponent. -func (r *Refs) SpinnerID() int { - return r.spinner.ID() -} - -// StatusBarValue implements statusbar.StatusBar. -func (r *Refs) StatusBarValue() string { - if r.activeRef == nil { - return "" - } - return r.activeRef.Name().String() -} - -// StatusBarInfo implements statusbar.StatusBar. -func (r *Refs) StatusBarInfo() string { - totalPages := r.selector.TotalPages() - if totalPages <= 1 { - return "p. 1/1" - } - return fmt.Sprintf("p. %d/%d", r.selector.Page()+1, totalPages) -} - -func (r *Refs) updateItemsCmd() tea.Msg { - its := make(RefItems, 0) - rr, err := r.repo.Open() - if err != nil { - return common.ErrorMsg(err) - } - refs, err := rr.References() - if err != nil { - r.common.Logger.Debugf("ui: error getting references: %v", err) - return common.ErrorMsg(err) - } - for _, ref := range refs { - if strings.HasPrefix(ref.Name().String(), r.refPrefix) { - refItem := RefItem{ - Reference: ref, - } - - if ref.IsTag() { - refItem.Tag, _ = rr.Tag(ref.Name().Short()) - if refItem.Tag != nil { - refItem.Commit, _ = refItem.Tag.Commit() - } - } else { - refItem.Commit, _ = rr.CatFileCommit(ref.ID) - } - its = append(its, refItem) - } - } - sort.Sort(its) - items := make([]selector.IdentifiableItem, len(its)) - for i, it := range its { - items[i] = it - } - return RefItemsMsg{ - items: items, - prefix: r.refPrefix, - } -} - -func (r *Refs) setItems(items []selector.IdentifiableItem) tea.Cmd { - return func() tea.Msg { - return RefItemsMsg{ - items: items, - prefix: r.refPrefix, - } - } -} - -func switchRefCmd(ref *git.Reference) tea.Cmd { - return func() tea.Msg { - return RefMsg(ref) - } -} - -// UpdateRefCmd gets the repository's HEAD reference and sends a RefMsg. -func UpdateRefCmd(repo proto.Repository) tea.Cmd { - return func() tea.Msg { - r, err := repo.Open() - if err != nil { - return common.ErrorMsg(err) - } - bs, _ := r.Branches() - if len(bs) == 0 { - return EmptyRepoMsg{} - } - ref, err := r.HEAD() - if err != nil { - return common.ErrorMsg(err) - } - return RefMsg(ref) - } -} diff --git a/server/ui/pages/repo/refsitem.go b/server/ui/pages/repo/refsitem.go deleted file mode 100644 index 4ebec8669..000000000 --- a/server/ui/pages/repo/refsitem.go +++ /dev/null @@ -1,205 +0,0 @@ -package repo - -import ( - "fmt" - "io" - "strings" - "time" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/dustin/go-humanize" - "github.com/muesli/reflow/truncate" -) - -// RefItem is a git reference item. -type RefItem struct { - *git.Reference - *git.Tag - *git.Commit -} - -// ID implements selector.IdentifiableItem. -func (i RefItem) ID() string { - return i.Reference.Name().String() -} - -// Title implements list.DefaultItem. -func (i RefItem) Title() string { - return i.Reference.Name().Short() -} - -// Description implements list.DefaultItem. -func (i RefItem) Description() string { - return "" -} - -// Short returns the short name of the reference. -func (i RefItem) Short() string { - return i.Reference.Name().Short() -} - -// FilterValue implements list.Item. -func (i RefItem) FilterValue() string { return i.Short() } - -// RefItems is a list of git references. -type RefItems []RefItem - -// Len implements sort.Interface. -func (cl RefItems) Len() int { return len(cl) } - -// Swap implements sort.Interface. -func (cl RefItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } - -// Less implements sort.Interface. -func (cl RefItems) Less(i, j int) bool { - if cl[i].Commit != nil && cl[j].Commit != nil { - return cl[i].Commit.Author.When.After(cl[j].Commit.Author.When) - } else if cl[i].Commit != nil && cl[j].Commit == nil { - return true - } - return false -} - -// RefItemDelegate is the delegate for the ref item. -type RefItemDelegate struct { - common *common.Common -} - -// Height implements list.ItemDelegate. -func (d RefItemDelegate) Height() int { return 1 } - -// Spacing implements list.ItemDelegate. -func (d RefItemDelegate) Spacing() int { return 0 } - -// Update implements list.ItemDelegate. -func (d RefItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { - item, ok := m.SelectedItem().(RefItem) - if !ok { - return nil - } - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, d.common.KeyMap.Copy): - return copyCmd(item.ID(), fmt.Sprintf("Reference %q copied to clipboard", item.ID())) - } - } - return nil -} - -// Render implements list.ItemDelegate. -func (d RefItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - i, ok := listItem.(RefItem) - if !ok { - return - } - - isTag := i.Reference.IsTag() - isActive := index == m.Index() - s := d.common.Styles.Ref - st := s.Normal - selector := " " - if isActive { - st = s.Active - selector = s.ItemSelector.String() - } - - horizontalFrameSize := st.Base.GetHorizontalFrameSize() - var itemSt lipgloss.Style - if isTag && isActive { - itemSt = st.ItemTag - } else if isTag { - itemSt = st.ItemTag - } else if isActive { - itemSt = st.Item - } else { - itemSt = st.Item - } - - var sha string - c := i.Commit - if c != nil { - sha = c.ID.String()[:7] - } - - ref := i.Short() - - var desc string - if isTag { - if c != nil { - date := c.Committer.When.Format("Jan 02") - if c.Committer.When.Year() != time.Now().Year() { - date += fmt.Sprintf(" %d", c.Committer.When.Year()) - } - desc += " " + st.ItemDesc.Render(date) - } - - t := i.Tag - if t != nil { - msgSt := st.ItemDesc.Copy().Faint(false) - msg := t.Message() - nl := strings.Index(msg, "\n") - if nl > 0 { - msg = msg[:nl] - } - msg = strings.TrimSpace(msg) - if msg != "" { - msgMargin := m.Width() - - horizontalFrameSize - - lipgloss.Width(selector) - - lipgloss.Width(ref) - - lipgloss.Width(desc) - - lipgloss.Width(sha) - - 3 // 3 is for the paddings and truncation symbol - if msgMargin >= 0 { - msg = common.TruncateString(msg, msgMargin) - desc = " " + msgSt.Render(msg) + desc - } - } - } - } else if c != nil { - onMargin := m.Width() - - horizontalFrameSize - - lipgloss.Width(selector) - - lipgloss.Width(ref) - - lipgloss.Width(desc) - - lipgloss.Width(sha) - - 2 // 2 is for the padding and truncation symbol - if onMargin >= 0 { - on := common.TruncateString("updated "+humanize.Time(c.Committer.When), onMargin) - desc += " " + st.ItemDesc.Render(on) - } - } - - var hash string - ref = itemSt.Render(ref) - hashMargin := m.Width() - - horizontalFrameSize - - lipgloss.Width(selector) - - lipgloss.Width(ref) - - lipgloss.Width(desc) - - lipgloss.Width(sha) - - 1 // 1 is for the left padding - if hashMargin >= 0 { - hash = strings.Repeat(" ", hashMargin) + st.ItemHash.Copy(). - Align(lipgloss.Right). - PaddingLeft(1). - Render(sha) - } - fmt.Fprint(w, - d.common.Zone.Mark( - i.ID(), - st.Base.Render( - lipgloss.JoinHorizontal(lipgloss.Left, - truncate.String(selector+ref+desc+hash, - uint(m.Width()-horizontalFrameSize)), - ), - ), - ), - ) -} diff --git a/server/ui/pages/repo/repo.go b/server/ui/pages/repo/repo.go deleted file mode 100644 index dde1f2fee..000000000 --- a/server/ui/pages/repo/repo.go +++ /dev/null @@ -1,416 +0,0 @@ -package repo - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/charmbracelet/soft-serve/server/ui/components/footer" - "github.com/charmbracelet/soft-serve/server/ui/components/selector" - "github.com/charmbracelet/soft-serve/server/ui/components/statusbar" - "github.com/charmbracelet/soft-serve/server/ui/components/tabs" -) - -type state int - -const ( - loadingState state = iota - readyState -) - -// EmptyRepoMsg is a message to indicate that the repository is empty. -type EmptyRepoMsg struct{} - -// CopyURLMsg is a message to copy the URL of the current repository. -type CopyURLMsg struct{} - -// RepoMsg is a message that contains a git.Repository. -type RepoMsg proto.Repository // nolint:revive - -// GoBackMsg is a message to go back to the previous view. -type GoBackMsg struct{} - -// CopyMsg is a message to indicate copied text. -type CopyMsg struct { - Text string - Message string -} - -// SwitchTabMsg is a message to switch tabs. -type SwitchTabMsg common.TabComponent - -// Repo is a view for a git repository. -type Repo struct { - common common.Common - selectedRepo proto.Repository - activeTab int - tabs *tabs.Tabs - statusbar *statusbar.Model - panes []common.TabComponent - ref *git.Reference - state state - spinner spinner.Model - panesReady []bool -} - -// New returns a new Repo. -func New(c common.Common, comps ...common.TabComponent) *Repo { - sb := statusbar.New(c) - ts := make([]string, 0) - for _, c := range comps { - ts = append(ts, c.TabName()) - } - c.Logger = c.Logger.WithPrefix("ui.repo") - tb := tabs.New(c, ts) - // Make sure the order matches the order of tab constants above. - s := spinner.New(spinner.WithSpinner(spinner.Dot), - spinner.WithStyle(c.Styles.Spinner)) - r := &Repo{ - common: c, - tabs: tb, - statusbar: sb, - panes: comps, - state: loadingState, - spinner: s, - panesReady: make([]bool, len(comps)), - } - return r -} - -func (r *Repo) getMargins() (int, int) { - hh := lipgloss.Height(r.headerView()) - hm := r.common.Styles.Repo.Body.GetVerticalFrameSize() + - hh + - r.common.Styles.Repo.Header.GetVerticalFrameSize() + - r.common.Styles.StatusBar.GetHeight() - return 0, hm -} - -// SetSize implements common.Component. -func (r *Repo) SetSize(width, height int) { - r.common.SetSize(width, height) - _, hm := r.getMargins() - r.tabs.SetSize(width, height-hm) - r.statusbar.SetSize(width, height-hm) - for _, p := range r.panes { - p.SetSize(width, height-hm) - } -} - -func (r *Repo) commonHelp() []key.Binding { - b := make([]key.Binding, 0) - back := r.common.KeyMap.Back - back.SetHelp("esc", "back to menu") - tab := r.common.KeyMap.Section - tab.SetHelp("tab", "switch tab") - b = append(b, back) - b = append(b, tab) - return b -} - -// ShortHelp implements help.KeyMap. -func (r *Repo) ShortHelp() []key.Binding { - b := r.commonHelp() - b = append(b, r.panes[r.activeTab].(help.KeyMap).ShortHelp()...) - return b -} - -// FullHelp implements help.KeyMap. -func (r *Repo) FullHelp() [][]key.Binding { - b := make([][]key.Binding, 0) - b = append(b, r.commonHelp()) - b = append(b, r.panes[r.activeTab].(help.KeyMap).FullHelp()...) - return b -} - -// Init implements tea.View. -func (r *Repo) Init() tea.Cmd { - r.state = loadingState - r.activeTab = 0 - return tea.Batch( - r.tabs.Init(), - r.statusbar.Init(), - r.spinner.Tick, - ) -} - -// Update implements tea.Model. -func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case RepoMsg: - // Set the state to loading when we get a new repository. - r.selectedRepo = msg - cmds = append(cmds, - r.Init(), - // This will set the selected repo in each pane's model. - r.updateModels(msg), - ) - case RefMsg: - r.ref = msg - cmds = append(cmds, r.updateModels(msg)) - r.state = readyState - case tabs.SelectTabMsg: - r.activeTab = int(msg) - t, cmd := r.tabs.Update(msg) - r.tabs = t.(*tabs.Tabs) - if cmd != nil { - cmds = append(cmds, cmd) - } - case tabs.ActiveTabMsg: - r.activeTab = int(msg) - case tea.KeyMsg, tea.MouseMsg: - t, cmd := r.tabs.Update(msg) - r.tabs = t.(*tabs.Tabs) - if cmd != nil { - cmds = append(cmds, cmd) - } - if r.selectedRepo != nil { - urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name()) - cmd := common.CloneCmd(r.common.Config().SSH.PublicURL, r.selectedRepo.Name()) - if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) { - cmds = append(cmds, copyCmd(cmd, "Command copied to clipboard")) - } - } - switch msg := msg.(type) { - case tea.MouseMsg: - switch msg.Type { - case tea.MouseLeft: - switch { - case r.common.Zone.Get("repo-help").InBounds(msg): - cmds = append(cmds, footer.ToggleFooterCmd) - } - case tea.MouseRight: - switch { - case r.common.Zone.Get("repo-main").InBounds(msg): - cmds = append(cmds, goBackCmd) - } - } - } - case CopyMsg: - txt := msg.Text - if cfg := r.common.Config(); cfg != nil { - r.common.Output.Copy(txt) - } - r.statusbar.SetStatus("", msg.Message, "", "") - case ReadmeMsg: - cmds = append(cmds, r.updateTabComponent(&Readme{}, msg)) - case FileItemsMsg, FileContentMsg: - cmds = append(cmds, r.updateTabComponent(&Files{}, msg)) - case LogItemsMsg, LogDiffMsg, LogCountMsg: - cmds = append(cmds, r.updateTabComponent(&Log{}, msg)) - case RefItemsMsg: - cmds = append(cmds, r.updateTabComponent(&Refs{refPrefix: msg.prefix}, msg)) - case StashListMsg, StashPatchMsg: - cmds = append(cmds, r.updateTabComponent(&Stash{}, msg)) - // We have two spinners, one is used to when loading the repository and the - // other is used when loading the log. - // Check if the spinner ID matches the spinner model. - case spinner.TickMsg: - if r.state == loadingState && r.spinner.ID() == msg.ID { - s, cmd := r.spinner.Update(msg) - r.spinner = s - if cmd != nil { - cmds = append(cmds, cmd) - } - } else { - for i, c := range r.panes { - if c.SpinnerID() == msg.ID { - m, cmd := c.Update(msg) - r.panes[i] = m.(common.TabComponent) - if cmd != nil { - cmds = append(cmds, cmd) - } - break - } - } - } - case tea.WindowSizeMsg: - r.SetSize(msg.Width, msg.Height) - cmds = append(cmds, r.updateModels(msg)) - case EmptyRepoMsg: - r.ref = nil - r.state = readyState - cmds = append(cmds, r.updateModels(msg)) - case common.ErrorMsg: - r.state = readyState - case SwitchTabMsg: - for i, c := range r.panes { - if c.TabName() == msg.TabName() { - cmds = append(cmds, tabs.SelectTabCmd(i)) - break - } - } - } - active := r.panes[r.activeTab] - m, cmd := active.Update(msg) - r.panes[r.activeTab] = m.(common.TabComponent) - if cmd != nil { - cmds = append(cmds, cmd) - } - - // Update the status bar on these events - // Must come after we've updated the active tab - switch msg.(type) { - case RepoMsg, RefMsg, tabs.ActiveTabMsg, tea.KeyMsg, tea.MouseMsg, - FileItemsMsg, FileContentMsg, FileBlameMsg, selector.ActiveMsg, - LogItemsMsg, GoBackMsg, LogDiffMsg, EmptyRepoMsg, - StashListMsg, StashPatchMsg: - r.setStatusBarInfo() - } - - s, cmd := r.statusbar.Update(msg) - r.statusbar = s.(*statusbar.Model) - if cmd != nil { - cmds = append(cmds, cmd) - } - - return r, tea.Batch(cmds...) -} - -// View implements tea.Model. -func (r *Repo) View() string { - wm, hm := r.getMargins() - hm += r.common.Styles.Tabs.GetHeight() + - r.common.Styles.Tabs.GetVerticalFrameSize() - s := r.common.Styles.Repo.Base.Copy(). - Width(r.common.Width - wm). - Height(r.common.Height - hm) - mainStyle := r.common.Styles.Repo.Body.Copy(). - Height(r.common.Height - hm) - var main string - var statusbar string - switch r.state { - case loadingState: - main = fmt.Sprintf("%s loading…", r.spinner.View()) - case readyState: - main = r.panes[r.activeTab].View() - statusbar = r.statusbar.View() - } - main = r.common.Zone.Mark( - "repo-main", - mainStyle.Render(main), - ) - view := lipgloss.JoinVertical(lipgloss.Top, - r.headerView(), - r.tabs.View(), - main, - statusbar, - ) - return s.Render(view) -} - -func (r *Repo) headerView() string { - if r.selectedRepo == nil { - return "" - } - truncate := lipgloss.NewStyle().MaxWidth(r.common.Width) - header := r.selectedRepo.ProjectName() - if header == "" { - header = r.selectedRepo.Name() - } - header = r.common.Styles.Repo.HeaderName.Render(header) - desc := strings.TrimSpace(r.selectedRepo.Description()) - if desc != "" { - header = lipgloss.JoinVertical(lipgloss.Top, - header, - r.common.Styles.Repo.HeaderDesc.Render(desc), - ) - } - urlStyle := r.common.Styles.URLStyle.Copy(). - Width(r.common.Width - lipgloss.Width(desc) - 1). - Align(lipgloss.Right) - var url string - if cfg := r.common.Config(); cfg != nil { - url = common.CloneCmd(cfg.SSH.PublicURL, r.selectedRepo.Name()) - } - url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1) - url = r.common.Zone.Mark( - fmt.Sprintf("%s-url", r.selectedRepo.Name()), - urlStyle.Render(url), - ) - - header = lipgloss.JoinHorizontal(lipgloss.Left, header, url) - - style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width) - return style.Render( - truncate.Render(header), - ) -} - -func (r *Repo) setStatusBarInfo() { - if r.selectedRepo == nil { - return - } - - active := r.panes[r.activeTab] - key := r.selectedRepo.Name() - value := active.StatusBarValue() - info := active.StatusBarInfo() - extra := "*" - if r.ref != nil { - extra += " " + r.ref.Name().Short() - } - - r.statusbar.SetStatus(key, value, info, extra) -} - -func (r *Repo) updateTabComponent(c common.TabComponent, msg tea.Msg) tea.Cmd { - cmds := make([]tea.Cmd, 0) - for i, b := range r.panes { - if b.TabName() == c.TabName() { - m, cmd := b.Update(msg) - r.panes[i] = m.(common.TabComponent) - if cmd != nil { - cmds = append(cmds, cmd) - } - break - } - } - return tea.Batch(cmds...) -} - -func (r *Repo) updateModels(msg tea.Msg) tea.Cmd { - cmds := make([]tea.Cmd, 0) - for i, b := range r.panes { - m, cmd := b.Update(msg) - r.panes[i] = m.(common.TabComponent) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - return tea.Batch(cmds...) -} - -func copyCmd(text, msg string) tea.Cmd { - return func() tea.Msg { - return CopyMsg{ - Text: text, - Message: msg, - } - } -} - -func goBackCmd() tea.Msg { - return GoBackMsg{} -} - -func switchTabCmd(m common.TabComponent) tea.Cmd { - return func() tea.Msg { - return SwitchTabMsg(m) - } -} - -func renderLoading(c common.Common, s spinner.Model) string { - msg := fmt.Sprintf("%s loading…", s.View()) - return c.Styles.SpinnerContainer.Copy(). - Height(c.Height). - Render(msg) -} diff --git a/server/ui/pages/repo/stash.go b/server/ui/pages/repo/stash.go deleted file mode 100644 index 0693743f3..000000000 --- a/server/ui/pages/repo/stash.go +++ /dev/null @@ -1,279 +0,0 @@ -package repo - -import ( - "fmt" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/charmbracelet/soft-serve/server/ui/components/code" - "github.com/charmbracelet/soft-serve/server/ui/components/selector" - gitm "github.com/gogs/git-module" -) - -type stashState int - -const ( - stashStateLoading stashState = iota - stashStateList - stashStatePatch -) - -// StashListMsg is a message sent when the stash list is loaded. -type StashListMsg []*gitm.Stash - -// StashPatchMsg is a message sent when the stash patch is loaded. -type StashPatchMsg struct{ *git.Diff } - -// Stash is the stash component page. -type Stash struct { - common common.Common - code *code.Code - ref RefMsg - repo proto.Repository - spinner spinner.Model - list *selector.Selector - state stashState - currentPatch StashPatchMsg -} - -// NewStash creates a new stash model. -func NewStash(common common.Common) *Stash { - code := code.New(common, "", "") - s := spinner.New(spinner.WithSpinner(spinner.Dot), - spinner.WithStyle(common.Styles.Spinner)) - selector := selector.New(common, []selector.IdentifiableItem{}, StashItemDelegate{&common}) - selector.SetShowFilter(false) - selector.SetShowHelp(false) - selector.SetShowPagination(false) - selector.SetShowStatusBar(false) - selector.SetShowTitle(false) - selector.SetFilteringEnabled(false) - selector.DisableQuitKeybindings() - selector.KeyMap.NextPage = common.KeyMap.NextPage - selector.KeyMap.PrevPage = common.KeyMap.PrevPage - return &Stash{ - code: code, - common: common, - spinner: s, - list: selector, - } -} - -// TabName returns the name of the tab. -func (s *Stash) TabName() string { - return "Stash" -} - -// SetSize implements common.Component. -func (s *Stash) SetSize(width, height int) { - s.common.SetSize(width, height) - s.code.SetSize(width, height) - s.list.SetSize(width, height) -} - -// ShortHelp implements help.KeyMap. -func (s *Stash) ShortHelp() []key.Binding { - return []key.Binding{ - s.common.KeyMap.Select, - s.common.KeyMap.Back, - s.common.KeyMap.UpDown, - } -} - -// FullHelp implements help.KeyMap. -func (s *Stash) FullHelp() [][]key.Binding { - b := [][]key.Binding{ - { - s.common.KeyMap.Select, - s.common.KeyMap.Back, - s.common.KeyMap.Copy, - }, - { - s.code.KeyMap.Down, - s.code.KeyMap.Up, - s.common.KeyMap.GotoTop, - s.common.KeyMap.GotoBottom, - }, - } - return b -} - -// StatusBarValue implements common.Component. -func (s *Stash) StatusBarValue() string { - item, ok := s.list.SelectedItem().(StashItem) - if !ok { - return " " - } - idx := s.list.Index() - return fmt.Sprintf("stash@{%d}: %s", idx, item.Title()) -} - -// StatusBarInfo implements common.Component. -func (s *Stash) StatusBarInfo() string { - switch s.state { - case stashStateList: - totalPages := s.list.TotalPages() - if totalPages <= 1 { - return "p. 1/1" - } - return fmt.Sprintf("p. %d/%d", s.list.Page()+1, totalPages) - case stashStatePatch: - return fmt.Sprintf("☰ %d%%", s.code.ScrollPosition()) - default: - return "" - } -} - -// SpinnerID implements common.Component. -func (s *Stash) SpinnerID() int { - return s.spinner.ID() -} - -// Init initializes the model. -func (s *Stash) Init() tea.Cmd { - s.state = stashStateLoading - return tea.Batch(s.spinner.Tick, s.fetchStash) -} - -// Update updates the model. -func (s *Stash) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case RepoMsg: - s.repo = msg - case RefMsg: - s.ref = msg - s.list.Select(0) - cmds = append(cmds, s.Init()) - case tea.WindowSizeMsg: - s.SetSize(msg.Width, msg.Height) - case spinner.TickMsg: - if s.state == stashStateLoading && s.spinner.ID() == msg.ID { - sp, cmd := s.spinner.Update(msg) - s.spinner = sp - if cmd != nil { - cmds = append(cmds, cmd) - } - } - case tea.KeyMsg: - switch s.state { - case stashStateList: - switch { - case key.Matches(msg, s.common.KeyMap.BackItem): - cmds = append(cmds, goBackCmd) - case key.Matches(msg, s.common.KeyMap.Copy): - cmds = append(cmds, copyCmd(s.list.SelectedItem().(StashItem).Title(), "Stash message copied to clipboard")) - } - case stashStatePatch: - switch { - case key.Matches(msg, s.common.KeyMap.BackItem): - cmds = append(cmds, goBackCmd) - case key.Matches(msg, s.common.KeyMap.Copy): - if s.currentPatch.Diff != nil { - patch := s.currentPatch.Diff - cmds = append(cmds, copyCmd(patch.Patch(), "Stash patch copied to clipboard")) - } - } - } - case StashListMsg: - s.state = stashStateList - items := make([]selector.IdentifiableItem, len(msg)) - for i, stash := range msg { - items[i] = StashItem{stash} - } - cmds = append(cmds, s.list.SetItems(items)) - case StashPatchMsg: - s.state = stashStatePatch - s.currentPatch = msg - if msg.Diff != nil { - title := s.common.Styles.Stash.Title.Render(s.list.SelectedItem().(StashItem).Title()) - content := lipgloss.JoinVertical(lipgloss.Top, - title, - "", - renderSummary(msg.Diff, s.common.Styles, s.common.Width), - renderDiff(msg.Diff, s.common.Width), - ) - cmds = append(cmds, s.code.SetContent(content, ".diff")) - s.code.GotoTop() - } - case selector.SelectMsg: - switch msg.IdentifiableItem.(type) { - case StashItem: - cmds = append(cmds, s.fetchStashPatch) - } - case GoBackMsg: - if s.state == stashStateList { - s.list.Select(0) - } - s.state = stashStateList - } - switch s.state { - case stashStateList: - l, cmd := s.list.Update(msg) - s.list = l.(*selector.Selector) - if cmd != nil { - cmds = append(cmds, cmd) - } - case stashStatePatch: - c, cmd := s.code.Update(msg) - s.code = c.(*code.Code) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - return s, tea.Batch(cmds...) -} - -// View returns the view. -func (s *Stash) View() string { - switch s.state { - case stashStateLoading: - return renderLoading(s.common, s.spinner) - case stashStateList: - return s.list.View() - case stashStatePatch: - return s.code.View() - } - return "" -} - -func (s *Stash) fetchStash() tea.Msg { - if s.repo == nil { - return StashListMsg(nil) - } - - r, err := s.repo.Open() - if err != nil { - return common.ErrorMsg(err) - } - - stash, err := r.StashList() - if err != nil { - return common.ErrorMsg(err) - } - - return StashListMsg(stash) -} - -func (s *Stash) fetchStashPatch() tea.Msg { - if s.repo == nil { - return StashPatchMsg{nil} - } - - r, err := s.repo.Open() - if err != nil { - return common.ErrorMsg(err) - } - - diff, err := r.StashDiff(s.list.Index()) - if err != nil { - return common.ErrorMsg(err) - } - - return StashPatchMsg{diff} -} diff --git a/server/ui/pages/repo/stashitem.go b/server/ui/pages/repo/stashitem.go deleted file mode 100644 index 482a40ad3..000000000 --- a/server/ui/pages/repo/stashitem.go +++ /dev/null @@ -1,106 +0,0 @@ -package repo - -import ( - "fmt" - "io" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/soft-serve/server/ui/common" - gitm "github.com/gogs/git-module" -) - -// StashItem represents a stash item. -type StashItem struct{ *gitm.Stash } - -// ID returns the ID of the stash item. -func (i StashItem) ID() string { - return fmt.Sprintf("stash@{%d}", i.Index) -} - -// Title returns the title of the stash item. -func (i StashItem) Title() string { - return i.Message -} - -// Description returns the description of the stash item. -func (i StashItem) Description() string { - return "" -} - -// FilterValue implements list.Item. -func (i StashItem) FilterValue() string { return i.Title() } - -// StashItems is a list of stash items. -type StashItems []StashItem - -// Len implements sort.Interface. -func (cl StashItems) Len() int { return len(cl) } - -// Swap implements sort.Interface. -func (cl StashItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } - -// Less implements sort.Interface. -func (cl StashItems) Less(i, j int) bool { - return cl[i].Index < cl[j].Index -} - -// StashItemDelegate is a delegate for stash items. -type StashItemDelegate struct { - common *common.Common -} - -// Height returns the height of the stash item list. Implements list.ItemDelegate. -func (d StashItemDelegate) Height() int { return 1 } - -// Spacing implements list.ItemDelegate. -func (d StashItemDelegate) Spacing() int { return 0 } - -// Update implements list.ItemDelegate. -func (d StashItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { - item, ok := m.SelectedItem().(StashItem) - if !ok { - return nil - } - - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, d.common.KeyMap.Copy): - return copyCmd(item.Title(), fmt.Sprintf("Stash message %q copied to clipboard", item.Title())) - } - } - - return nil -} - -// Render implements list.ItemDelegate. -func (d StashItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - item, ok := listItem.(StashItem) - if !ok { - return - } - - s := d.common.Styles.Stash - - st := s.Normal.Message - selector := " " - if index == m.Index() { - selector = "> " - st = s.Active.Message - } - - selector = s.Selector.Render(selector) - title := st.Render(item.Title()) - fmt.Fprint(w, d.common.Zone.Mark( - item.ID(), - common.TruncateString(fmt.Sprintf("%s%s", - selector, - title, - ), m.Width()- - s.Selector.GetWidth()- - st.GetHorizontalFrameSize(), - ), - )) -} diff --git a/server/ui/pages/selection/item.go b/server/ui/pages/selection/item.go deleted file mode 100644 index 20a381ff0..000000000 --- a/server/ui/pages/selection/item.go +++ /dev/null @@ -1,217 +0,0 @@ -package selection - -import ( - "fmt" - "io" - "sort" - "strings" - "time" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/dustin/go-humanize" -) - -var _ sort.Interface = Items{} - -// Items is a list of Item. -type Items []Item - -// Len implements sort.Interface. -func (it Items) Len() int { - return len(it) -} - -// Less implements sort.Interface. -func (it Items) Less(i int, j int) bool { - if it[i].lastUpdate == nil && it[j].lastUpdate != nil { - return false - } - if it[i].lastUpdate != nil && it[j].lastUpdate == nil { - return true - } - if it[i].lastUpdate == nil && it[j].lastUpdate == nil { - return it[i].repo.Name() < it[j].repo.Name() - } - return it[i].lastUpdate.After(*it[j].lastUpdate) -} - -// Swap implements sort.Interface. -func (it Items) Swap(i int, j int) { - it[i], it[j] = it[j], it[i] -} - -// Item represents a single item in the selector. -type Item struct { - repo proto.Repository - lastUpdate *time.Time - cmd string -} - -// New creates a new Item. -func NewItem(repo proto.Repository, cfg *config.Config) (Item, error) { - var lastUpdate *time.Time - lu := repo.UpdatedAt() - if !lu.IsZero() { - lastUpdate = &lu - } - return Item{ - repo: repo, - lastUpdate: lastUpdate, - cmd: common.CloneCmd(cfg.SSH.PublicURL, repo.Name()), - }, nil -} - -// ID implements selector.IdentifiableItem. -func (i Item) ID() string { - return i.repo.Name() -} - -// Title returns the item title. Implements list.DefaultItem. -func (i Item) Title() string { - name := i.repo.ProjectName() - if name == "" { - name = i.repo.Name() - } - - return name -} - -// Description returns the item description. Implements list.DefaultItem. -func (i Item) Description() string { return strings.TrimSpace(i.repo.Description()) } - -// FilterValue implements list.Item. -func (i Item) FilterValue() string { return i.Title() } - -// Command returns the item Command view. -func (i Item) Command() string { - return i.cmd -} - -// ItemDelegate is the delegate for the item. -type ItemDelegate struct { - common *common.Common - activePane *pane - copiedIdx int -} - -// NewItemDelegate creates a new ItemDelegate. -func NewItemDelegate(common *common.Common, activePane *pane) *ItemDelegate { - return &ItemDelegate{ - common: common, - activePane: activePane, - copiedIdx: -1, - } -} - -// Width returns the item width. -func (d ItemDelegate) Width() int { - width := d.common.Styles.MenuItem.GetHorizontalFrameSize() + d.common.Styles.MenuItem.GetWidth() - return width -} - -// Height returns the item height. Implements list.ItemDelegate. -func (d *ItemDelegate) Height() int { - height := d.common.Styles.MenuItem.GetVerticalFrameSize() + d.common.Styles.MenuItem.GetHeight() - return height -} - -// Spacing returns the spacing between items. Implements list.ItemDelegate. -func (d *ItemDelegate) Spacing() int { return 1 } - -// Update implements list.ItemDelegate. -func (d *ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { - idx := m.Index() - item, ok := m.SelectedItem().(Item) - if !ok { - return nil - } - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, d.common.KeyMap.Copy): - d.copiedIdx = idx - d.common.Output.Copy(item.Command()) - return m.SetItem(idx, item) - } - } - return nil -} - -// Render implements list.ItemDelegate. -func (d *ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - i := listItem.(Item) - s := strings.Builder{} - var matchedRunes []int - - // Conditions - var ( - isSelected = index == m.Index() - isFiltered = m.FilterState() == list.Filtering || m.FilterState() == list.FilterApplied - ) - - styles := d.common.Styles.RepoSelector.Normal - if isSelected { - styles = d.common.Styles.RepoSelector.Active - } - - title := i.Title() - title = common.TruncateString(title, m.Width()-styles.Base.GetHorizontalFrameSize()) - if i.repo.IsPrivate() { - title += " 🔒" - } - if isSelected { - title += " " - } - var updatedStr string - if i.lastUpdate != nil { - updatedStr = fmt.Sprintf(" Updated %s", humanize.Time(*i.lastUpdate)) - } - if m.Width()-styles.Base.GetHorizontalFrameSize()-lipgloss.Width(updatedStr)-lipgloss.Width(title) <= 0 { - updatedStr = "" - } - updatedStyle := styles.Updated.Copy(). - Align(lipgloss.Right). - Width(m.Width() - styles.Base.GetHorizontalFrameSize() - lipgloss.Width(title)) - updated := updatedStyle.Render(updatedStr) - - if isFiltered && index < len(m.VisibleItems()) { - // Get indices of matched characters - matchedRunes = m.MatchesForItem(index) - } - - if isFiltered { - unmatched := styles.Title.Copy().Inline(true) - matched := unmatched.Copy().Underline(true) - title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched) - } - title = styles.Title.Render(title) - desc := i.Description() - desc = common.TruncateString(desc, m.Width()-styles.Base.GetHorizontalFrameSize()) - desc = styles.Desc.Render(desc) - - s.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, title, updated)) - s.WriteRune('\n') - s.WriteString(desc) - s.WriteRune('\n') - - cmd := i.Command() - cmdStyler := styles.Command.Render - if d.copiedIdx == index { - cmd = "(copied to clipboard)" - cmdStyler = styles.Desc.Render - d.copiedIdx = -1 - } - cmd = common.TruncateString(cmd, m.Width()-styles.Base.GetHorizontalFrameSize()) - s.WriteString(cmdStyler(cmd)) - fmt.Fprint(w, - d.common.Zone.Mark(i.ID(), - styles.Base.Render(s.String()), - ), - ) -} diff --git a/server/ui/pages/selection/selection.go b/server/ui/pages/selection/selection.go deleted file mode 100644 index b84b0a33d..000000000 --- a/server/ui/pages/selection/selection.go +++ /dev/null @@ -1,320 +0,0 @@ -package selection - -import ( - "fmt" - "sort" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/charmbracelet/soft-serve/server/ui/components/code" - "github.com/charmbracelet/soft-serve/server/ui/components/selector" - "github.com/charmbracelet/soft-serve/server/ui/components/tabs" -) - -const ( - defaultNoContent = "No readme found.\n\nCreate a `.soft-serve` repository and add a `README.md` file to display readme." -) - -type pane int - -const ( - selectorPane pane = iota - readmePane - lastPane -) - -func (p pane) String() string { - return []string{ - "Repositories", - "About", - }[p] -} - -// Selection is the model for the selection screen/page. -type Selection struct { - common common.Common - readme *code.Code - selector *selector.Selector - activePane pane - tabs *tabs.Tabs -} - -// New creates a new selection model. -func New(c common.Common) *Selection { - ts := make([]string, lastPane) - for i, b := range []pane{selectorPane, readmePane} { - ts[i] = b.String() - } - t := tabs.New(c, ts) - t.TabSeparator = lipgloss.NewStyle() - t.TabInactive = c.Styles.TopLevelNormalTab.Copy() - t.TabActive = c.Styles.TopLevelActiveTab.Copy() - t.TabDot = c.Styles.TopLevelActiveTabDot.Copy() - t.UseDot = true - sel := &Selection{ - common: c, - activePane: selectorPane, // start with the selector focused - tabs: t, - } - readme := code.New(c, "", "") - readme.NoContentStyle = c.Styles.NoContent.Copy(). - SetString(defaultNoContent) - selector := selector.New(c, - []selector.IdentifiableItem{}, - NewItemDelegate(&c, &sel.activePane)) - selector.SetShowTitle(false) - selector.SetShowHelp(false) - selector.SetShowStatusBar(false) - selector.DisableQuitKeybindings() - sel.selector = selector - sel.readme = readme - return sel -} - -func (s *Selection) getMargins() (wm, hm int) { - wm = 0 - hm = s.common.Styles.Tabs.GetVerticalFrameSize() + - s.common.Styles.Tabs.GetHeight() - if s.activePane == selectorPane && s.IsFiltering() { - // hide tabs when filtering - hm = 0 - } - return -} - -// FilterState returns the current filter state. -func (s *Selection) FilterState() list.FilterState { - return s.selector.FilterState() -} - -// SetSize implements common.Component. -func (s *Selection) SetSize(width, height int) { - s.common.SetSize(width, height) - wm, hm := s.getMargins() - s.tabs.SetSize(width, height-hm) - s.selector.SetSize(width-wm, height-hm) - s.readme.SetSize(width-wm, height-hm-1) // -1 for readme status line -} - -// IsFiltering returns true if the selector is currently filtering. -func (s *Selection) IsFiltering() bool { - return s.FilterState() == list.Filtering -} - -// ShortHelp implements help.KeyMap. -func (s *Selection) ShortHelp() []key.Binding { - k := s.selector.KeyMap - kb := make([]key.Binding, 0) - kb = append(kb, - s.common.KeyMap.UpDown, - s.common.KeyMap.Section, - ) - if s.activePane == selectorPane { - copyKey := s.common.KeyMap.Copy - copyKey.SetHelp("c", "copy command") - kb = append(kb, - s.common.KeyMap.Select, - k.Filter, - k.ClearFilter, - copyKey, - ) - } - return kb -} - -// FullHelp implements help.KeyMap. -func (s *Selection) FullHelp() [][]key.Binding { - b := [][]key.Binding{ - { - s.common.KeyMap.Section, - }, - } - switch s.activePane { - case readmePane: - k := s.readme.KeyMap - b = append(b, []key.Binding{ - k.PageDown, - k.PageUp, - }) - b = append(b, []key.Binding{ - k.HalfPageDown, - k.HalfPageUp, - }) - b = append(b, []key.Binding{ - k.Down, - k.Up, - }) - case selectorPane: - copyKey := s.common.KeyMap.Copy - copyKey.SetHelp("c", "copy command") - k := s.selector.KeyMap - if !s.IsFiltering() { - b[0] = append(b[0], - s.common.KeyMap.Select, - copyKey, - ) - } - b = append(b, []key.Binding{ - k.CursorUp, - k.CursorDown, - }) - b = append(b, []key.Binding{ - k.NextPage, - k.PrevPage, - k.GoToStart, - k.GoToEnd, - }) - b = append(b, []key.Binding{ - k.Filter, - k.ClearFilter, - k.CancelWhileFiltering, - k.AcceptWhileFiltering, - }) - } - return b -} - -// Init implements tea.Model. -func (s *Selection) Init() tea.Cmd { - var readmeCmd tea.Cmd - cfg := s.common.Config() - if cfg == nil { - return nil - } - - ctx := s.common.Context() - be := s.common.Backend() - pk := s.common.PublicKey() - if pk == nil && !be.AllowKeyless(ctx) { - return nil - } - - repos, err := be.Repositories(ctx) - if err != nil { - return common.ErrorCmd(err) - } - sortedItems := make(Items, 0) - for _, r := range repos { - if r.Name() == ".soft-serve" { - readme, path, err := backend.Readme(r, nil) - if err != nil { - continue - } - - readmeCmd = s.readme.SetContent(readme, path) - } - - if r.IsHidden() { - continue - } - al := be.AccessLevelByPublicKey(ctx, r.Name(), pk) - if al >= access.ReadOnlyAccess { - item, err := NewItem(r, cfg) - if err != nil { - s.common.Logger.Debugf("ui: failed to create item for %s: %v", r.Name(), err) - continue - } - sortedItems = append(sortedItems, item) - } - } - sort.Sort(sortedItems) - items := make([]selector.IdentifiableItem, len(sortedItems)) - for i, it := range sortedItems { - items[i] = it - } - return tea.Batch( - s.selector.Init(), - s.selector.SetItems(items), - readmeCmd, - ) -} - -// Update implements tea.Model. -func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case tea.WindowSizeMsg: - r, cmd := s.readme.Update(msg) - s.readme = r.(*code.Code) - if cmd != nil { - cmds = append(cmds, cmd) - } - m, cmd := s.selector.Update(msg) - s.selector = m.(*selector.Selector) - if cmd != nil { - cmds = append(cmds, cmd) - } - case tea.KeyMsg, tea.MouseMsg: - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, s.common.KeyMap.Back): - cmds = append(cmds, s.selector.Init()) - } - } - t, cmd := s.tabs.Update(msg) - s.tabs = t.(*tabs.Tabs) - if cmd != nil { - cmds = append(cmds, cmd) - } - case tabs.ActiveTabMsg: - s.activePane = pane(msg) - } - switch s.activePane { - case readmePane: - r, cmd := s.readme.Update(msg) - s.readme = r.(*code.Code) - if cmd != nil { - cmds = append(cmds, cmd) - } - case selectorPane: - m, cmd := s.selector.Update(msg) - s.selector = m.(*selector.Selector) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - return s, tea.Batch(cmds...) -} - -// View implements tea.Model. -func (s *Selection) View() string { - var view string - wm, hm := s.getMargins() - switch s.activePane { - case selectorPane: - ss := lipgloss.NewStyle(). - Width(s.common.Width - wm). - Height(s.common.Height - hm) - view = ss.Render(s.selector.View()) - case readmePane: - rs := lipgloss.NewStyle(). - Height(s.common.Height - hm) - status := fmt.Sprintf("☰ %.f%%", s.readme.ScrollPercent()*100) - readmeStatus := lipgloss.NewStyle(). - Align(lipgloss.Right). - Width(s.common.Width - wm). - Foreground(s.common.Styles.InactiveBorderColor). - Render(status) - view = rs.Render(lipgloss.JoinVertical(lipgloss.Left, - s.readme.View(), - readmeStatus, - )) - } - if s.activePane != selectorPane || s.FilterState() != list.Filtering { - tabs := s.common.Styles.Tabs.Render(s.tabs.View()) - view = lipgloss.JoinVertical(lipgloss.Left, - tabs, - view, - ) - } - return lipgloss.JoinVertical( - lipgloss.Left, - view, - ) -} diff --git a/server/ui/styles/styles.go b/server/ui/styles/styles.go deleted file mode 100644 index fbaab6015..000000000 --- a/server/ui/styles/styles.go +++ /dev/null @@ -1,521 +0,0 @@ -package styles - -import ( - "github.com/charmbracelet/lipgloss" -) - -// XXX: For now, this is in its own package so that it can be shared between -// different packages without incurring an illegal import cycle. - -// Styles defines styles for the UI. -type Styles struct { - ActiveBorderColor lipgloss.Color - InactiveBorderColor lipgloss.Color - - App lipgloss.Style - ServerName lipgloss.Style - TopLevelNormalTab lipgloss.Style - TopLevelActiveTab lipgloss.Style - TopLevelActiveTabDot lipgloss.Style - - MenuItem lipgloss.Style - MenuLastUpdate lipgloss.Style - - RepoSelector struct { - Normal struct { - Base lipgloss.Style - Title lipgloss.Style - Desc lipgloss.Style - Command lipgloss.Style - Updated lipgloss.Style - } - Active struct { - Base lipgloss.Style - Title lipgloss.Style - Desc lipgloss.Style - Command lipgloss.Style - Updated lipgloss.Style - } - } - - Repo struct { - Base lipgloss.Style - Title lipgloss.Style - Command lipgloss.Style - Body lipgloss.Style - Header lipgloss.Style - HeaderName lipgloss.Style - HeaderDesc lipgloss.Style - } - - Footer lipgloss.Style - Branch lipgloss.Style - HelpKey lipgloss.Style - HelpValue lipgloss.Style - HelpDivider lipgloss.Style - URLStyle lipgloss.Style - - Error lipgloss.Style - ErrorTitle lipgloss.Style - ErrorBody lipgloss.Style - - LogItem struct { - Normal struct { - Base lipgloss.Style - Hash lipgloss.Style - Title lipgloss.Style - Desc lipgloss.Style - Keyword lipgloss.Style - } - Active struct { - Base lipgloss.Style - Hash lipgloss.Style - Title lipgloss.Style - Desc lipgloss.Style - Keyword lipgloss.Style - } - } - - Log struct { - Commit lipgloss.Style - CommitHash lipgloss.Style - CommitAuthor lipgloss.Style - CommitDate lipgloss.Style - CommitBody lipgloss.Style - CommitStatsAdd lipgloss.Style - CommitStatsDel lipgloss.Style - Paginator lipgloss.Style - } - - Ref struct { - Normal struct { - Base lipgloss.Style - Item lipgloss.Style - ItemTag lipgloss.Style - ItemDesc lipgloss.Style - ItemHash lipgloss.Style - } - Active struct { - Base lipgloss.Style - Item lipgloss.Style - ItemTag lipgloss.Style - ItemDesc lipgloss.Style - ItemHash lipgloss.Style - } - ItemSelector lipgloss.Style - Paginator lipgloss.Style - Selector lipgloss.Style - } - - Tree struct { - Normal struct { - FileName lipgloss.Style - FileDir lipgloss.Style - FileMode lipgloss.Style - FileSize lipgloss.Style - } - Active struct { - FileName lipgloss.Style - FileDir lipgloss.Style - FileMode lipgloss.Style - FileSize lipgloss.Style - } - Selector lipgloss.Style - FileContent lipgloss.Style - Paginator lipgloss.Style - Blame struct { - Hash lipgloss.Style - Message lipgloss.Style - } - } - - Stash struct { - Normal struct { - Message lipgloss.Style - } - Active struct { - Message lipgloss.Style - } - Title lipgloss.Style - Selector lipgloss.Style - } - - Spinner lipgloss.Style - SpinnerContainer lipgloss.Style - - NoContent lipgloss.Style - - StatusBar lipgloss.Style - StatusBarKey lipgloss.Style - StatusBarValue lipgloss.Style - StatusBarInfo lipgloss.Style - StatusBarBranch lipgloss.Style - StatusBarHelp lipgloss.Style - - Tabs lipgloss.Style - TabInactive lipgloss.Style - TabActive lipgloss.Style - TabSeparator lipgloss.Style - - Code struct { - LineDigit lipgloss.Style - LineBar lipgloss.Style - } -} - -// DefaultStyles returns default styles for the UI. -func DefaultStyles() *Styles { - highlightColor := lipgloss.Color("210") - highlightColorDim := lipgloss.Color("174") - selectorColor := lipgloss.Color("167") - hashColor := lipgloss.Color("185") - - s := new(Styles) - - s.ActiveBorderColor = lipgloss.Color("62") - s.InactiveBorderColor = lipgloss.Color("241") - - s.App = lipgloss.NewStyle(). - Margin(1, 2) - - s.ServerName = lipgloss.NewStyle(). - Height(1). - MarginLeft(1). - MarginBottom(1). - Padding(0, 1). - Background(lipgloss.Color("57")). - Foreground(lipgloss.Color("229")). - Bold(true) - - s.TopLevelNormalTab = lipgloss.NewStyle(). - MarginRight(2) - - s.TopLevelActiveTab = s.TopLevelNormalTab.Copy(). - Foreground(lipgloss.Color("36")) - - s.TopLevelActiveTabDot = lipgloss.NewStyle(). - Foreground(lipgloss.Color("36")) - - s.RepoSelector.Normal.Base = lipgloss.NewStyle(). - PaddingLeft(1). - Border(lipgloss.Border{Left: " "}, false, false, false, true). - Height(3) - - s.RepoSelector.Normal.Title = lipgloss.NewStyle().Bold(true) - - s.RepoSelector.Normal.Desc = lipgloss.NewStyle(). - Foreground(lipgloss.Color("243")) - - s.RepoSelector.Normal.Command = lipgloss.NewStyle(). - Foreground(lipgloss.Color("132")) - - s.RepoSelector.Normal.Updated = lipgloss.NewStyle(). - Foreground(lipgloss.Color("243")) - - s.RepoSelector.Active.Base = s.RepoSelector.Normal.Base.Copy(). - BorderStyle(lipgloss.Border{Left: "┃"}). - BorderForeground(lipgloss.Color("176")) - - s.RepoSelector.Active.Title = s.RepoSelector.Normal.Title.Copy(). - Foreground(lipgloss.Color("212")) - - s.RepoSelector.Active.Desc = s.RepoSelector.Normal.Desc.Copy(). - Foreground(lipgloss.Color("246")) - - s.RepoSelector.Active.Updated = s.RepoSelector.Normal.Updated.Copy(). - Foreground(lipgloss.Color("212")) - - s.RepoSelector.Active.Command = s.RepoSelector.Normal.Command.Copy(). - Foreground(lipgloss.Color("204")) - - s.MenuItem = lipgloss.NewStyle(). - PaddingLeft(1). - Border(lipgloss.Border{ - Left: " ", - }, false, false, false, true). - Height(3) - - s.MenuLastUpdate = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). - Align(lipgloss.Right) - - s.Repo.Base = lipgloss.NewStyle() - - s.Repo.Title = lipgloss.NewStyle(). - Padding(0, 2) - - s.Repo.Command = lipgloss.NewStyle(). - Foreground(lipgloss.Color("168")) - - s.Repo.Body = lipgloss.NewStyle(). - Margin(1, 0) - - s.Repo.Header = lipgloss.NewStyle(). - MaxHeight(2). - Border(lipgloss.NormalBorder(), false, false, true, false). - BorderForeground(lipgloss.Color("236")) - - s.Repo.HeaderName = lipgloss.NewStyle(). - Foreground(lipgloss.Color("212")). - Bold(true) - - s.Repo.HeaderDesc = lipgloss.NewStyle(). - Foreground(lipgloss.Color("243")) - - s.Footer = lipgloss.NewStyle(). - MarginTop(1). - Padding(0, 1). - Height(1) - - s.Branch = lipgloss.NewStyle(). - Foreground(lipgloss.Color("203")). - Background(lipgloss.Color("236")). - Padding(0, 1) - - s.HelpKey = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")) - - s.HelpValue = lipgloss.NewStyle(). - Foreground(lipgloss.Color("239")) - - s.HelpDivider = lipgloss.NewStyle(). - Foreground(lipgloss.Color("237")). - SetString(" • ") - - s.URLStyle = lipgloss.NewStyle(). - MarginLeft(1). - Foreground(lipgloss.Color("168")) - - s.Error = lipgloss.NewStyle(). - MarginTop(2) - - s.ErrorTitle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("230")). - Background(lipgloss.Color("204")). - Bold(true). - Padding(0, 1) - - s.ErrorBody = lipgloss.NewStyle(). - Foreground(lipgloss.Color("252")). - MarginLeft(2) - - s.LogItem.Normal.Base = lipgloss.NewStyle(). - Border(lipgloss.Border{ - Left: " ", - }, false, false, false, true). - PaddingLeft(1) - - s.LogItem.Active.Base = s.LogItem.Normal.Base.Copy(). - Border(lipgloss.Border{ - Left: "┃", - }, false, false, false, true). - BorderForeground(selectorColor) - - s.LogItem.Active.Hash = s.LogItem.Normal.Hash.Copy(). - Foreground(hashColor) - - s.LogItem.Active.Hash = lipgloss.NewStyle(). - Bold(true). - Foreground(highlightColor) - - s.LogItem.Normal.Title = lipgloss.NewStyle(). - Foreground(lipgloss.Color("105")) - - s.LogItem.Active.Title = lipgloss.NewStyle(). - Foreground(highlightColor). - Bold(true) - - s.LogItem.Normal.Desc = lipgloss.NewStyle(). - Foreground(lipgloss.Color("246")) - - s.LogItem.Active.Desc = lipgloss.NewStyle(). - Foreground(lipgloss.Color("95")) - - s.LogItem.Active.Keyword = s.LogItem.Active.Desc.Copy(). - Foreground(highlightColorDim) - - s.LogItem.Normal.Hash = lipgloss.NewStyle(). - Foreground(hashColor) - - s.LogItem.Active.Hash = lipgloss.NewStyle(). - Foreground(highlightColor) - - s.Log.Commit = lipgloss.NewStyle(). - Margin(0, 2) - - s.Log.CommitHash = lipgloss.NewStyle(). - Foreground(hashColor). - Bold(true) - - s.Log.CommitBody = lipgloss.NewStyle(). - MarginTop(1). - MarginLeft(2) - - s.Log.CommitStatsAdd = lipgloss.NewStyle(). - Foreground(lipgloss.Color("42")). - Bold(true) - - s.Log.CommitStatsDel = lipgloss.NewStyle(). - Foreground(lipgloss.Color("203")). - Bold(true) - - s.Log.Paginator = lipgloss.NewStyle(). - Margin(0). - Align(lipgloss.Center) - - s.Ref.Normal.Item = lipgloss.NewStyle() - - s.Ref.ItemSelector = lipgloss.NewStyle(). - Foreground(selectorColor). - SetString("> ") - - s.Ref.Active.Item = lipgloss.NewStyle(). - Foreground(highlightColorDim) - - s.Ref.Normal.Base = lipgloss.NewStyle() - - s.Ref.Active.Base = lipgloss.NewStyle() - - s.Ref.Normal.ItemTag = lipgloss.NewStyle(). - Foreground(lipgloss.Color("39")) - - s.Ref.Active.ItemTag = lipgloss.NewStyle(). - Bold(true). - Foreground(highlightColor) - - s.Ref.Active.Item = lipgloss.NewStyle(). - Bold(true). - Foreground(highlightColor) - - s.Ref.Normal.ItemDesc = lipgloss.NewStyle(). - Faint(true) - - s.Ref.Active.ItemDesc = lipgloss.NewStyle(). - Foreground(highlightColor). - Faint(true) - - s.Ref.Normal.ItemHash = lipgloss.NewStyle(). - Foreground(hashColor). - Bold(true) - - s.Ref.Active.ItemHash = lipgloss.NewStyle(). - Foreground(highlightColor). - Bold(true) - - s.Ref.Paginator = s.Log.Paginator.Copy() - - s.Ref.Selector = lipgloss.NewStyle() - - s.Tree.Selector = s.Tree.Normal.FileName.Copy(). - Width(1). - Foreground(selectorColor) - - s.Tree.Normal.FileName = lipgloss.NewStyle(). - MarginLeft(1) - - s.Tree.Active.FileName = s.Tree.Normal.FileName.Copy(). - Bold(true). - Foreground(highlightColor) - - s.Tree.Normal.FileDir = lipgloss.NewStyle(). - Foreground(lipgloss.Color("39")) - - s.Tree.Active.FileDir = lipgloss.NewStyle(). - Foreground(highlightColor) - - s.Tree.Normal.FileMode = s.Tree.Active.FileName.Copy(). - Width(10). - Foreground(lipgloss.Color("243")) - - s.Tree.Active.FileMode = s.Tree.Normal.FileMode.Copy(). - Foreground(highlightColorDim) - - s.Tree.Normal.FileSize = s.Tree.Normal.FileName.Copy(). - Foreground(lipgloss.Color("243")) - - s.Tree.Active.FileSize = s.Tree.Normal.FileName.Copy(). - Foreground(highlightColorDim) - - s.Tree.FileContent = lipgloss.NewStyle() - - s.Tree.Paginator = s.Log.Paginator.Copy() - - s.Tree.Blame.Hash = lipgloss.NewStyle(). - Foreground(hashColor). - Bold(true) - - s.Tree.Blame.Message = lipgloss.NewStyle() - - s.Spinner = lipgloss.NewStyle(). - MarginTop(1). - MarginLeft(2). - Foreground(lipgloss.Color("205")) - - s.SpinnerContainer = lipgloss.NewStyle() - - s.NoContent = lipgloss.NewStyle(). - MarginTop(1). - MarginLeft(2). - Foreground(lipgloss.Color("242")) - - s.StatusBar = lipgloss.NewStyle(). - Height(1) - - s.StatusBarKey = lipgloss.NewStyle(). - Bold(true). - Padding(0, 1). - Background(lipgloss.Color("206")). - Foreground(lipgloss.Color("228")) - - s.StatusBarValue = lipgloss.NewStyle(). - Padding(0, 1). - Background(lipgloss.Color("235")). - Foreground(lipgloss.Color("243")) - - s.StatusBarInfo = lipgloss.NewStyle(). - Padding(0, 1). - Background(lipgloss.Color("212")). - Foreground(lipgloss.Color("230")) - - s.StatusBarBranch = lipgloss.NewStyle(). - Padding(0, 1). - Background(lipgloss.Color("62")). - Foreground(lipgloss.Color("230")) - - s.StatusBarHelp = lipgloss.NewStyle(). - Padding(0, 1). - Background(lipgloss.Color("237")). - Foreground(lipgloss.Color("243")) - - s.Tabs = lipgloss.NewStyle(). - Height(1) - - s.TabInactive = lipgloss.NewStyle() - - s.TabActive = lipgloss.NewStyle(). - Underline(true). - Foreground(lipgloss.Color("36")) - - s.TabSeparator = lipgloss.NewStyle(). - SetString("│"). - Padding(0, 1). - Foreground(lipgloss.Color("238")) - - s.Code.LineDigit = lipgloss.NewStyle().Foreground(lipgloss.Color("239")) - - s.Code.LineBar = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) - - s.Stash.Normal.Message = lipgloss.NewStyle().MarginLeft(1) - - s.Stash.Active.Message = s.Stash.Normal.Message.Copy().Foreground(selectorColor) - - s.Stash.Title = lipgloss.NewStyle(). - Foreground(hashColor). - Bold(true) - - s.Stash.Selector = lipgloss.NewStyle(). - Width(1). - Foreground(selectorColor) - - return s -} diff --git a/server/utils/utils.go b/server/utils/utils.go deleted file mode 100644 index a98cb139c..000000000 --- a/server/utils/utils.go +++ /dev/null @@ -1,52 +0,0 @@ -package utils - -import ( - "fmt" - "path" - "strings" - "unicode" -) - -// SanitizeRepo returns a sanitized version of the given repository name. -func SanitizeRepo(repo string) string { - repo = strings.TrimPrefix(repo, "/") - // We're using path instead of filepath here because this is not OS dependent - // looking at you Windows - repo = path.Clean(repo) - repo = strings.TrimSuffix(repo, ".git") - return repo -} - -// ValidateUsername returns an error if any of the given usernames are invalid. -func ValidateUsername(username string) error { - if username == "" { - return fmt.Errorf("username cannot be empty") - } - - if !unicode.IsLetter(rune(username[0])) { - return fmt.Errorf("username must start with a letter") - } - - for _, r := range username { - if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' { - return fmt.Errorf("username can only contain letters, numbers, and hyphens") - } - } - - return nil -} - -// ValidateRepo returns an error if the given repository name is invalid. -func ValidateRepo(repo string) error { - if repo == "" { - return fmt.Errorf("repo cannot be empty") - } - - for _, r := range repo { - if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' && r != '.' && r != '/' { - return fmt.Errorf("repo can only contain letters, numbers, hyphens, underscores, periods, and slashes") - } - } - - return nil -} diff --git a/server/utils/utils_test.go b/server/utils/utils_test.go deleted file mode 100644 index 33c1c7cda..000000000 --- a/server/utils/utils_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package utils - -import "testing" - -func TestValidateRepo(t *testing.T) { - t.Run("valid", func(t *testing.T) { - for _, repo := range []string{ - "lower", - "Upper", - "with-dash", - "with/slash", - "withnumb3r5", - "with.dot", - "with_underline", - } { - t.Run(repo, func(t *testing.T) { - if err := ValidateRepo(repo); err != nil { - t.Errorf("expected no error, got %v", err) - } - }) - } - }) - t.Run("invalid", func(t *testing.T) { - for _, repo := range []string{ - "with$", - "with@", - "with!", - } { - t.Run(repo, func(t *testing.T) { - if err := ValidateRepo(repo); err == nil { - t.Error("expected an error, got nil") - } - }) - } - }) -} - -func TestSanitizeRepo(t *testing.T) { - cases := []struct { - in, out string - }{ - {"lower", "lower"}, - {"Upper", "Upper"}, - {"with/slash", "with/slash"}, - {"with.dot", "with.dot"}, - {"/with_forward_slash", "with_forward_slash"}, - {"withgitsuffix.git", "withgitsuffix"}, - } - for _, c := range cases { - t.Run(c.in, func(t *testing.T) { - if got := SanitizeRepo(c.in); got != c.out { - t.Errorf("expected %q, got %q", c.out, got) - } - }) - } -} diff --git a/server/version/version.go b/server/version/version.go deleted file mode 100644 index 22db4c3e5..000000000 --- a/server/version/version.go +++ /dev/null @@ -1,14 +0,0 @@ -// Package version is used to store the version of the server during runtime. -// The values are set during runtime in the main package. -package version - -var ( - // Version is the version of the server. - Version = "" - - // CommitSHA is the commit SHA of the server. - CommitSHA = "" - - // CommitDate is the commit date of the server. - CommitDate = "" -) diff --git a/server/web/auth.go b/server/web/auth.go deleted file mode 100644 index bbbe88b3f..000000000 --- a/server/web/auth.go +++ /dev/null @@ -1,171 +0,0 @@ -package web - -import ( - "context" - "errors" - "fmt" - "net/http" - "strings" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/golang-jwt/jwt/v5" -) - -// authenticate authenticates the user from the request. -func authenticate(r *http.Request) (proto.User, error) { - // Prefer the Authorization header - user, err := parseAuthHdr(r) - if err != nil || user == nil { - if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) { - return nil, err - } - return nil, proto.ErrUserNotFound - } - - return user, nil -} - -// ErrInvalidPassword is returned when the password is invalid. -var ErrInvalidPassword = errors.New("invalid password") - -func parseUsernamePassword(ctx context.Context, username, password string) (proto.User, error) { - logger := log.FromContext(ctx) - be := backend.FromContext(ctx) - - if username != "" && password != "" { - user, err := be.User(ctx, username) - if err == nil && user != nil && backend.VerifyPassword(password, user.Password()) { - return user, nil - } - - // Try to authenticate using access token as the password - user, err = be.UserByAccessToken(ctx, password) - if err == nil { - return user, nil - } - - logger.Error("invalid password or token", "username", username, "err", err) - return nil, ErrInvalidPassword - } else if username != "" { - // Try to authenticate using access token as the username - logger.Debug("trying to authenticate using access token as username", "username", username) - user, err := be.UserByAccessToken(ctx, username) - if err == nil { - return user, nil - } - - logger.Error("failed to get user", "err", err) - return nil, ErrInvalidToken - } - - return nil, proto.ErrUserNotFound -} - -// ErrInvalidHeader is returned when the authorization header is invalid. -var ErrInvalidHeader = errors.New("invalid authorization header") - -func parseAuthHdr(r *http.Request) (proto.User, error) { - // Check for auth header - header := r.Header.Get("Authorization") - if header == "" { - return nil, ErrInvalidHeader - } - - ctx := r.Context() - logger := log.FromContext(ctx).WithPrefix("http.auth") - be := backend.FromContext(ctx) - - logger.Debug("authorization auth header", "header", header) - - parts := strings.SplitN(header, " ", 2) - if len(parts) != 2 { - return nil, errors.New("invalid authorization header") - } - - switch strings.ToLower(parts[0]) { - case "token": - user, err := be.UserByAccessToken(ctx, parts[1]) - if err != nil { - logger.Error("failed to get user", "err", err) - return nil, err - } - - return user, nil - case "bearer": - claims, err := parseJWT(ctx, parts[1]) - if err != nil { - return nil, err - } - - // Find the user - parts := strings.SplitN(claims.Subject, "#", 2) - if len(parts) != 2 { - logger.Error("invalid jwt subject", "subject", claims.Subject) - return nil, errors.New("invalid jwt subject") - } - - user, err := be.User(ctx, parts[0]) - if err != nil { - logger.Error("failed to get user", "err", err) - return nil, err - } - - expectedSubject := fmt.Sprintf("%s#%d", user.Username(), user.ID()) - if expectedSubject != claims.Subject { - logger.Error("invalid jwt subject", "subject", claims.Subject, "expected", expectedSubject) - return nil, errors.New("invalid jwt subject") - } - - return user, nil - default: - username, password, ok := r.BasicAuth() - if !ok { - return nil, ErrInvalidHeader - } - - return parseUsernamePassword(ctx, username, password) - } -} - -// ErrInvalidToken is returned when a token is invalid. -var ErrInvalidToken = errors.New("invalid token") - -func parseJWT(ctx context.Context, bearer string) (*jwt.RegisteredClaims, error) { - cfg := config.FromContext(ctx) - logger := log.FromContext(ctx).WithPrefix("http.auth") - kp, err := cfg.SSH.KeyPair() - if err != nil { - return nil, err - } - - repo := proto.RepositoryFromContext(ctx) - if repo == nil { - return nil, errors.New("missing repository") - } - - token, err := jwt.ParseWithClaims(bearer, &jwt.RegisteredClaims{}, func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok { - return nil, errors.New("invalid signing method") - } - - return kp.CryptoPublicKey(), nil - }, - jwt.WithIssuer(cfg.HTTP.PublicURL), - jwt.WithIssuedAt(), - jwt.WithAudience(repo.Name()), - ) - if err != nil { - logger.Error("failed to parse jwt", "err", err) - return nil, ErrInvalidToken - } - - claims, ok := token.Claims.(*jwt.RegisteredClaims) - if !token.Valid || !ok { - return nil, ErrInvalidToken - } - - return claims, nil -} diff --git a/server/web/context.go b/server/web/context.go deleted file mode 100644 index 8e7eb8ea9..000000000 --- a/server/web/context.go +++ /dev/null @@ -1,34 +0,0 @@ -package web - -import ( - "context" - "net/http" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/store" -) - -// NewContextHandler returns a new context middleware. -// This middleware adds the config, backend, and logger to the request context. -func NewContextHandler(ctx context.Context) func(http.Handler) http.Handler { - cfg := config.FromContext(ctx) - be := backend.FromContext(ctx) - logger := log.FromContext(ctx).WithPrefix("http") - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - ctx = config.WithContext(ctx, cfg) - ctx = backend.WithContext(ctx, be) - ctx = log.WithContext(ctx, logger) - ctx = db.WithContext(ctx, dbx) - ctx = store.WithContext(ctx, datastore) - r = r.WithContext(ctx) - next.ServeHTTP(w, r) - }) - } -} diff --git a/server/web/git.go b/server/web/git.go deleted file mode 100644 index 4d4dde8b2..000000000 --- a/server/web/git.go +++ /dev/null @@ -1,634 +0,0 @@ -package web - -import ( - "bytes" - "compress/gzip" - "context" - "errors" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "github.com/charmbracelet/log" - gitb "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/git" - "github.com/charmbracelet/soft-serve/server/lfs" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/utils" - "github.com/gorilla/mux" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" -) - -// GitRoute is a route for git services. -type GitRoute struct { - method []string - handler http.HandlerFunc - path string -} - -var _ http.Handler = GitRoute{} - -// ServeHTTP implements http.Handler. -func (g GitRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) { - var hasMethod bool - for _, m := range g.method { - if m == r.Method { - hasMethod = true - break - } - } - - if !hasMethod { - renderMethodNotAllowed(w, r) - return - } - - g.handler(w, r) -} - -var ( - //nolint:revive - gitHttpReceiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "http", - Name: "git_receive_pack_total", - Help: "The total number of git push requests", - }, []string{"repo"}) - - //nolint:revive - gitHttpUploadCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "http", - Name: "git_upload_pack_total", - Help: "The total number of git fetch/pull requests", - }, []string{"repo", "file"}) -) - -func withParams(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - cfg := config.FromContext(ctx) - vars := mux.Vars(r) - repo := vars["repo"] - - // Construct "file" param from path - vars["file"] = strings.TrimPrefix(r.URL.Path, "/"+repo+"/") - - // Set service type - switch { - case strings.HasSuffix(r.URL.Path, git.UploadPackService.String()): - vars["service"] = git.UploadPackService.String() - case strings.HasSuffix(r.URL.Path, git.ReceivePackService.String()): - vars["service"] = git.ReceivePackService.String() - } - - repo = utils.SanitizeRepo(repo) - vars["repo"] = repo - vars["dir"] = filepath.Join(cfg.DataPath, "repos", repo+".git") - - // Add repo suffix (.git) - r.URL.Path = fmt.Sprintf("%s.git/%s", repo, vars["file"]) - r = mux.SetURLVars(r, vars) - h.ServeHTTP(w, r) - }) -} - -// GitController is a router for git services. -func GitController(_ context.Context, r *mux.Router) { - basePrefix := "/{repo:.*}" - for _, route := range gitRoutes { - // NOTE: withParam must always be the outermost wrapper, otherwise the - // request vars will not be set. - r.Handle(basePrefix+route.path, withParams(withAccess(route))) - } - - // Handle go-get - r.Handle(basePrefix, withParams(withAccess(GoGetHandler{}))).Methods(http.MethodGet) -} - -var gitRoutes = []GitRoute{ - // Git services - // These routes don't handle authentication/authorization. - // This is handled through wrapping the handlers for each route. - // See below (withAccess). - { - method: []string{http.MethodPost}, - handler: serviceRpc, - path: "/{service:(?:git-upload-pack|git-receive-pack)$}", - }, - { - method: []string{http.MethodGet}, - handler: getInfoRefs, - path: "/info/refs", - }, - { - method: []string{http.MethodGet}, - handler: getTextFile, - path: "/{_:(?:HEAD|objects/info/alternates|objects/info/http-alternates|objects/info/[^/]*)$}", - }, - { - method: []string{http.MethodGet}, - handler: getInfoPacks, - path: "/objects/info/packs", - }, - { - method: []string{http.MethodGet}, - handler: getLooseObject, - path: "/objects/{_:[0-9a-f]{2}/[0-9a-f]{38}$}", - }, - { - method: []string{http.MethodGet}, - handler: getPackFile, - path: "/objects/pack/{_:pack-[0-9a-f]{40}\\.pack$}", - }, - { - method: []string{http.MethodGet}, - handler: getIdxFile, - path: "/objects/pack/{_:pack-[0-9a-f]{40}\\.idx$}", - }, - // Git LFS - { - method: []string{http.MethodPost}, - handler: serviceLfsBatch, - path: "/info/lfs/objects/batch", - }, - { - // Git LFS basic object handler - method: []string{http.MethodGet, http.MethodPut}, - handler: serviceLfsBasic, - path: "/info/lfs/objects/basic/{oid:[0-9a-f]{64}$}", - }, - { - method: []string{http.MethodPost}, - handler: serviceLfsBasicVerify, - path: "/info/lfs/objects/basic/verify", - }, - // Git LFS locks - { - method: []string{http.MethodPost, http.MethodGet}, - handler: serviceLfsLocks, - path: "/info/lfs/locks", - }, - { - method: []string{http.MethodPost}, - handler: serviceLfsLocksVerify, - path: "/info/lfs/locks/verify", - }, - { - method: []string{http.MethodPost}, - handler: serviceLfsLocksDelete, - path: "/info/lfs/locks/{lock_id:[0-9]+}/unlock", - }, -} - -func askCredentials(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("WWW-Authenticate", `Basic realm="Git" charset="UTF-8", Token, Bearer`) - w.Header().Set("LFS-Authenticate", `Basic realm="Git LFS" charset="UTF-8", Token, Bearer`) -} - -// withAccess handles auth. -func withAccess(next http.Handler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - cfg := config.FromContext(ctx) - logger := log.FromContext(ctx) - be := backend.FromContext(ctx) - - // Store repository in context - // We're not checking for errors here because we want to allow - // repo creation on the fly. - repoName := mux.Vars(r)["repo"] - repo, _ := be.Repository(ctx, repoName) - ctx = proto.WithRepositoryContext(ctx, repo) - r = r.WithContext(ctx) - - user, err := authenticate(r) - if err != nil { - switch { - case errors.Is(err, ErrInvalidToken): - case errors.Is(err, proto.ErrUserNotFound): - default: - logger.Error("failed to authenticate", "err", err) - } - } - - if user == nil && !be.AllowKeyless(ctx) { - askCredentials(w, r) - renderUnauthorized(w, r) - return - } - - // Store user in context - ctx = proto.WithUserContext(ctx, user) - r = r.WithContext(ctx) - - if user != nil { - logger.Debug("authenticated", "username", user.Username()) - } - - service := git.Service(mux.Vars(r)["service"]) - if service == "" { - // Get service from request params - service = getServiceType(r) - } - - accessLevel := be.AccessLevelForUser(ctx, repoName, user) - ctx = access.WithContext(ctx, accessLevel) - r = r.WithContext(ctx) - - file := mux.Vars(r)["file"] - - // We only allow these services to proceed any other services should return 403 - // - git-upload-pack - // - git-receive-pack - // - git-lfs - switch { - case service == git.ReceivePackService: - if accessLevel < access.ReadWriteAccess { - askCredentials(w, r) - renderUnauthorized(w, r) - return - } - - // Create the repo if it doesn't exist. - if repo == nil { - repo, err = be.CreateRepository(ctx, repoName, user, proto.RepositoryOptions{}) - if err != nil { - logger.Error("failed to create repository", "repo", repoName, "err", err) - renderInternalServerError(w, r) - return - } - - ctx = proto.WithRepositoryContext(ctx, repo) - r = r.WithContext(ctx) - } - - fallthrough - case service == git.UploadPackService: - if repo == nil { - // If the repo doesn't exist, return 404 - renderNotFound(w, r) - return - } else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) { - // return 403 when bad credentials are provided - renderForbidden(w, r) - return - } else if accessLevel < access.ReadOnlyAccess { - askCredentials(w, r) - renderUnauthorized(w, r) - return - } - - case strings.HasPrefix(file, "info/lfs"): - if !cfg.LFS.Enabled { - logger.Debug("LFS is not enabled, skipping") - renderNotFound(w, r) - return - } - - switch { - case strings.HasPrefix(file, "info/lfs/locks"): - switch { - case strings.HasSuffix(file, "lfs/locks"), strings.HasSuffix(file, "/unlock") && r.Method == http.MethodPost: - // Create lock, list locks, and delete lock require write access - fallthrough - case strings.HasSuffix(file, "lfs/locks/verify"): - // Locks verify requires write access - // https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md#unauthorized-response-2 - if accessLevel < access.ReadWriteAccess { - renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{ - Message: "write access required", - }) - return - } - } - case strings.HasPrefix(file, "info/lfs/objects/basic"): - switch r.Method { - case http.MethodPut: - // Basic upload - if accessLevel < access.ReadWriteAccess { - renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{ - Message: "write access required", - }) - return - } - case http.MethodGet: - // Basic download - case http.MethodPost: - // Basic verify - } - } - - if accessLevel < access.ReadOnlyAccess { - if repo == nil { - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "repository not found", - }) - } else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) { - renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{ - Message: "bad credentials", - }) - } else { - askCredentials(w, r) - renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{ - Message: "credentials needed", - }) - } - return - } - } - - switch { - case r.URL.Query().Get("go-get") == "1" && accessLevel >= access.ReadOnlyAccess: - // Allow go-get requests to passthrough. - break - case errors.Is(err, ErrInvalidToken), errors.Is(err, ErrInvalidPassword): - // return 403 when bad credentials are provided - renderForbidden(w, r) - return - case repo == nil, accessLevel < access.ReadOnlyAccess: - // Don't hint that the repo exists if the user doesn't have access - renderNotFound(w, r) - return - } - - next.ServeHTTP(w, r) - } -} - -//nolint:revive -func serviceRpc(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - cfg := config.FromContext(ctx) - logger := log.FromContext(ctx) - service, dir, repoName := git.Service(mux.Vars(r)["service"]), mux.Vars(r)["dir"], mux.Vars(r)["repo"] - - if !isSmart(r, service) { - renderForbidden(w, r) - return - } - - if service == git.ReceivePackService { - gitHttpReceiveCounter.WithLabelValues(repoName) - } - - w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", service)) - w.Header().Set("Connection", "Keep-Alive") - w.Header().Set("Transfer-Encoding", "chunked") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.WriteHeader(http.StatusOK) - - version := r.Header.Get("Git-Protocol") - - var stdout bytes.Buffer - cmd := git.ServiceCommand{ - Stdout: &stdout, - Dir: dir, - Args: []string{"--stateless-rpc"}, - } - - user := proto.UserFromContext(ctx) - cmd.Env = cfg.Environ() - cmd.Env = append(cmd.Env, []string{ - "SOFT_SERVE_REPO_NAME=" + repoName, - "SOFT_SERVE_REPO_PATH=" + dir, - "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"), - }...) - if user != nil { - cmd.Env = append(cmd.Env, []string{ - "SOFT_SERVE_USERNAME=" + user.Username(), - }...) - } - if len(version) != 0 { - cmd.Env = append(cmd.Env, []string{ - fmt.Sprintf("GIT_PROTOCOL=%s", version), - }...) - } - - // Handle gzip encoding - reader := r.Body - defer reader.Close() // nolint: errcheck - switch r.Header.Get("Content-Encoding") { - case "gzip": - reader, err := gzip.NewReader(reader) - if err != nil { - logger.Errorf("failed to create gzip reader: %v", err) - renderInternalServerError(w, r) - return - } - defer reader.Close() // nolint: errcheck - } - - cmd.Stdin = reader - - if err := service.Handler(ctx, cmd); err != nil { - if errors.Is(err, git.ErrInvalidRepo) { - renderNotFound(w, r) - return - } - renderInternalServerError(w, r) - return - } - - // Handle buffered output - // Useful when using proxies - - // We know that `w` is an `http.ResponseWriter`. - flusher, ok := w.(http.Flusher) - if !ok { - logger.Errorf("expected http.ResponseWriter to be an http.Flusher, got %T", w) - return - } - - p := make([]byte, 1024) - for { - nRead, err := stdout.Read(p) - if err == io.EOF { - break - } - nWrite, err := w.Write(p[:nRead]) - if err != nil { - logger.Errorf("failed to write data: %v", err) - return - } - if nRead != nWrite { - logger.Errorf("failed to write data: %d read, %d written", nRead, nWrite) - return - } - flusher.Flush() - } - - if service == git.ReceivePackService { - if err := git.EnsureDefaultBranch(ctx, cmd); err != nil { - logger.Errorf("failed to ensure default branch: %s", err) - } - } -} - -func getInfoRefs(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - cfg := config.FromContext(ctx) - dir, repoName, file := mux.Vars(r)["dir"], mux.Vars(r)["repo"], mux.Vars(r)["file"] - service := getServiceType(r) - version := r.Header.Get("Git-Protocol") - - gitHttpUploadCounter.WithLabelValues(repoName, file).Inc() - - if service != "" && (service == git.UploadPackService || service == git.ReceivePackService) { - // Smart HTTP - var refs bytes.Buffer - cmd := git.ServiceCommand{ - Stdout: &refs, - Dir: dir, - Args: []string{"--stateless-rpc", "--advertise-refs"}, - } - - user := proto.UserFromContext(ctx) - cmd.Env = cfg.Environ() - cmd.Env = append(cmd.Env, []string{ - "SOFT_SERVE_REPO_NAME=" + repoName, - "SOFT_SERVE_REPO_PATH=" + dir, - "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"), - }...) - if user != nil { - cmd.Env = append(cmd.Env, []string{ - "SOFT_SERVE_USERNAME=" + user.Username(), - }...) - } - if len(version) != 0 { - cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", version)) - } - - if err := service.Handler(ctx, cmd); err != nil { - renderNotFound(w, r) - return - } - - hdrNocache(w) - w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service)) - w.WriteHeader(http.StatusOK) - if len(version) == 0 { - git.WritePktline(w, "# service="+service.String()) // nolint: errcheck - } - - w.Write(refs.Bytes()) // nolint: errcheck - } else { - // Dumb HTTP - updateServerInfo(ctx, dir) // nolint: errcheck - hdrNocache(w) - sendFile("text/plain; charset=utf-8", w, r) - } -} - -func getInfoPacks(w http.ResponseWriter, r *http.Request) { - hdrCacheForever(w) - sendFile("text/plain; charset=utf-8", w, r) -} - -func getLooseObject(w http.ResponseWriter, r *http.Request) { - hdrCacheForever(w) - sendFile("application/x-git-loose-object", w, r) -} - -func getPackFile(w http.ResponseWriter, r *http.Request) { - hdrCacheForever(w) - sendFile("application/x-git-packed-objects", w, r) -} - -func getIdxFile(w http.ResponseWriter, r *http.Request) { - hdrCacheForever(w) - sendFile("application/x-git-packed-objects-toc", w, r) -} - -func getTextFile(w http.ResponseWriter, r *http.Request) { - hdrNocache(w) - sendFile("text/plain", w, r) -} - -func sendFile(contentType string, w http.ResponseWriter, r *http.Request) { - dir, file := mux.Vars(r)["dir"], mux.Vars(r)["file"] - reqFile := filepath.Join(dir, file) - - f, err := os.Stat(reqFile) - if os.IsNotExist(err) { - renderNotFound(w, r) - return - } - - w.Header().Set("Content-Type", contentType) - w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size())) - w.Header().Set("Last-Modified", f.ModTime().Format(http.TimeFormat)) - http.ServeFile(w, r, reqFile) -} - -func getServiceType(r *http.Request) git.Service { - service := r.FormValue("service") - if !strings.HasPrefix(service, "git-") { - return "" - } - - return git.Service(service) -} - -func isSmart(r *http.Request, service git.Service) bool { - contentType := r.Header.Get("Content-Type") - return strings.HasPrefix(contentType, fmt.Sprintf("application/x-%s-request", service)) -} - -func updateServerInfo(ctx context.Context, dir string) error { - return gitb.UpdateServerInfo(ctx, dir) -} - -// HTTP error response handling functions - -func renderBadRequest(w http.ResponseWriter, r *http.Request) { - renderStatus(http.StatusBadRequest)(w, r) -} - -func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) { - if r.Proto == "HTTP/1.1" { - renderStatus(http.StatusMethodNotAllowed)(w, r) - } else { - renderBadRequest(w, r) - } -} - -func renderNotFound(w http.ResponseWriter, r *http.Request) { - renderStatus(http.StatusNotFound)(w, r) -} - -func renderUnauthorized(w http.ResponseWriter, r *http.Request) { - renderStatus(http.StatusUnauthorized)(w, r) -} - -func renderForbidden(w http.ResponseWriter, r *http.Request) { - renderStatus(http.StatusForbidden)(w, r) -} - -func renderInternalServerError(w http.ResponseWriter, r *http.Request) { - renderStatus(http.StatusInternalServerError)(w, r) -} - -// Header writing functions - -func hdrNocache(w http.ResponseWriter) { - w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") - w.Header().Set("Pragma", "no-cache") - w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") -} - -func hdrCacheForever(w http.ResponseWriter) { - now := time.Now().Unix() - expires := now + 31536000 - w.Header().Set("Date", fmt.Sprintf("%d", now)) - w.Header().Set("Expires", fmt.Sprintf("%d", expires)) - w.Header().Set("Cache-Control", "public, max-age=31536000") -} diff --git a/server/web/git_lfs.go b/server/web/git_lfs.go deleted file mode 100644 index 4d25f76e9..000000000 --- a/server/web/git_lfs.go +++ /dev/null @@ -1,975 +0,0 @@ -package web - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "net/http" - "net/url" - "path" - "path/filepath" - "strconv" - "strings" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "github.com/charmbracelet/soft-serve/server/lfs" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/storage" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/gorilla/mux" -) - -// serviceLfsBatch handles a Git LFS batch requests. -// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md -// TODO: support refname -// POST: /.git/info/lfs/objects/batch -func serviceLfsBatch(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - logger := log.FromContext(ctx).WithPrefix("http.lfs") - - if !isLfs(r) { - logger.Errorf("invalid content type: %s", r.Header.Get("Content-Type")) - renderNotAcceptable(w) - return - } - - var batchRequest lfs.BatchRequest - defer r.Body.Close() // nolint: errcheck - if err := json.NewDecoder(r.Body).Decode(&batchRequest); err != nil { - logger.Errorf("error decoding json: %s", err) - renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{ - Message: "validation error in request: " + err.Error(), - }) - return - } - - // We only accept basic transfers for now - // Default to basic if no transfer is specified - if len(batchRequest.Transfers) > 0 { - var isBasic bool - for _, t := range batchRequest.Transfers { - if t == lfs.TransferBasic { - isBasic = true - break - } - } - - if !isBasic { - renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{ - Message: "unsupported transfer", - }) - return - } - } - - if len(batchRequest.Objects) == 0 { - renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{ - Message: "no objects found", - }) - return - } - - name := mux.Vars(r)["repo"] - repo := proto.RepositoryFromContext(ctx) - if repo == nil { - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "repository not found", - }) - return - } - - cfg := config.FromContext(ctx) - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - // TODO: support S3 storage - repoID := strconv.FormatInt(repo.ID(), 10) - strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)) - - baseHref := fmt.Sprintf("%s/%s/info/lfs/objects/basic", cfg.HTTP.PublicURL, name+".git") - - var batchResponse lfs.BatchResponse - batchResponse.Transfer = lfs.TransferBasic - batchResponse.HashAlgo = lfs.HashAlgorithmSHA256 - - objects := make([]*lfs.ObjectResponse, 0, len(batchRequest.Objects)) - // XXX: We don't support objects TTL for now, probably implement that with - // S3 using object "expires_at" & "expires_in" - switch batchRequest.Operation { - case lfs.OperationDownload: - for _, o := range batchRequest.Objects { - exist, err := strg.Exists(path.Join("objects", o.RelativePath())) - if err != nil && !errors.Is(err, fs.ErrNotExist) { - logger.Error("error getting object stat", "oid", o.Oid, "repo", name, "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), o.Oid) - if err != nil && !errors.Is(err, db.ErrRecordNotFound) { - logger.Error("error getting object from database", "oid", o.Oid, "repo", name, "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - if !exist { - objects = append(objects, &lfs.ObjectResponse{ - Pointer: o, - Error: &lfs.ObjectError{ - Code: http.StatusNotFound, - Message: "object not found", - }, - }) - } else if obj.Size != o.Size { - objects = append(objects, &lfs.ObjectResponse{ - Pointer: o, - Error: &lfs.ObjectError{ - Code: http.StatusUnprocessableEntity, - Message: "size mismatch", - }, - }) - } else if o.IsValid() { - download := &lfs.Link{ - Href: fmt.Sprintf("%s/%s", baseHref, o.Oid), - } - if auth := r.Header.Get("Authorization"); auth != "" { - download.Header = map[string]string{ - "Authorization": auth, - } - } - - objects = append(objects, &lfs.ObjectResponse{ - Pointer: o, - Actions: map[string]*lfs.Link{ - lfs.ActionDownload: download, - }, - }) - - // If the object doesn't exist in the database, create it - if exist && obj.ID == 0 { - if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), o.Oid, o.Size); err != nil { - logger.Error("error creating object in datastore", "oid", o.Oid, "repo", name, "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - } - } else { - logger.Error("invalid object", "oid", o.Oid, "repo", name) - objects = append(objects, &lfs.ObjectResponse{ - Pointer: o, - Error: &lfs.ObjectError{ - Code: http.StatusUnprocessableEntity, - Message: "invalid object", - }, - }) - } - } - case lfs.OperationUpload: - // Check authorization - accessLevel := access.FromContext(ctx) - if accessLevel < access.ReadWriteAccess { - askCredentials(w, r) - renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{ - Message: "write access required", - }) - return - } - - // Object upload logic happens in the "basic" API route - for _, o := range batchRequest.Objects { - if !o.IsValid() { - objects = append(objects, &lfs.ObjectResponse{ - Pointer: o, - Error: &lfs.ObjectError{ - Code: http.StatusUnprocessableEntity, - Message: "invalid object", - }, - }) - } else { - upload := &lfs.Link{ - Href: fmt.Sprintf("%s/%s", baseHref, o.Oid), - Header: map[string]string{ - // NOTE: git-lfs v2.5.0 sets the Content-Type based on the uploaded file. - // This ensures that the client always uses the designated value for the header. - "Content-Type": "application/octet-stream", - }, - } - verify := &lfs.Link{ - Href: fmt.Sprintf("%s/verify", baseHref), - } - if auth := r.Header.Get("Authorization"); auth != "" { - upload.Header["Authorization"] = auth - verify.Header = map[string]string{ - "Authorization": auth, - } - } - - objects = append(objects, &lfs.ObjectResponse{ - Pointer: o, - Actions: map[string]*lfs.Link{ - lfs.ActionUpload: upload, - // Verify uploaded objects - // https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md#verification - lfs.ActionVerify: verify, - }, - }) - } - } - default: - renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{ - Message: "unsupported operation", - }) - return - } - - batchResponse.Objects = objects - renderJSON(w, http.StatusOK, batchResponse) -} - -// serviceLfsBasic implements Git LFS basic transfer API -// https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md -func serviceLfsBasic(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - serviceLfsBasicDownload(w, r) - case http.MethodPut: - serviceLfsBasicUpload(w, r) - } -} - -// GET: /.git/info/lfs/objects/basic/ -func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - oid := mux.Vars(r)["oid"] - repo := proto.RepositoryFromContext(ctx) - cfg := config.FromContext(ctx) - logger := log.FromContext(ctx).WithPrefix("http.lfs-basic") - datastore := store.FromContext(ctx) - dbx := db.FromContext(ctx) - repoID := strconv.FormatInt(repo.ID(), 10) - strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)) - - obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid) - if err != nil && !errors.Is(err, db.ErrRecordNotFound) { - logger.Error("error getting object from database", "oid", oid, "repo", repo.Name(), "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - pointer := lfs.Pointer{Oid: oid} - f, err := strg.Open(path.Join("objects", pointer.RelativePath())) - if err != nil { - logger.Error("error opening object", "oid", oid, "err", err) - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "object not found", - }) - return - } - - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Length", strconv.FormatInt(obj.Size, 10)) - defer f.Close() // nolint: errcheck - if _, err := io.Copy(w, f); err != nil { - logger.Error("error copying object to response", "oid", oid, "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } -} - -// PUT: /.git/info/lfs/objects/basic/ -func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) { - if !isBinary(r) { - renderJSON(w, http.StatusUnsupportedMediaType, lfs.ErrorResponse{ - Message: "invalid content type", - }) - return - } - - ctx := r.Context() - oid := mux.Vars(r)["oid"] - cfg := config.FromContext(ctx) - be := backend.FromContext(ctx) - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - logger := log.FromContext(ctx).WithPrefix("http.lfs-basic") - repo := proto.RepositoryFromContext(ctx) - repoID := strconv.FormatInt(repo.ID(), 10) - strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)) - name := mux.Vars(r)["repo"] - - defer r.Body.Close() // nolint: errcheck - repo, err := be.Repository(ctx, name) - if err != nil { - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "repository not found", - }) - return - } - - // NOTE: Git LFS client will retry uploading the same object if there was a - // partial error, so we need to skip existing objects. - if _, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid); err == nil { - // Object exists, skip request - io.Copy(io.Discard, r.Body) // nolint: errcheck - renderStatus(http.StatusOK)(w, nil) - return - } else if !errors.Is(err, db.ErrRecordNotFound) { - logger.Error("error getting object", "oid", oid, "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - pointer := lfs.Pointer{Oid: oid} - if _, err := strg.Put(path.Join("objects", pointer.RelativePath()), r.Body); err != nil { - logger.Error("error writing object", "oid", oid, "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - size, err := strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64) - if err != nil { - logger.Error("error parsing content length", "err", err) - renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ - Message: "invalid content length", - }) - return - } - - if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), oid, size); err != nil { - logger.Error("error creating object", "oid", oid, "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - renderStatus(http.StatusOK)(w, nil) -} - -// POST: /.git/info/lfs/objects/basic/verify -func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) { - if !isLfs(r) { - renderNotAcceptable(w) - return - } - - var pointer lfs.Pointer - ctx := r.Context() - logger := log.FromContext(ctx).WithPrefix("http.lfs-basic") - repo := proto.RepositoryFromContext(ctx) - if repo == nil { - logger.Error("error getting repository from context") - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "repository not found", - }) - return - } - - defer r.Body.Close() // nolint: errcheck - if err := json.NewDecoder(r.Body).Decode(&pointer); err != nil { - logger.Error("error decoding json", "err", err) - renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ - Message: "invalid request: " + err.Error(), - }) - return - } - - cfg := config.FromContext(ctx) - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - repoID := strconv.FormatInt(repo.ID(), 10) - strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)) - if stat, err := strg.Stat(path.Join("objects", pointer.RelativePath())); err == nil { - // Verify object is in the database. - obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid) - if err != nil { - if errors.Is(err, db.ErrRecordNotFound) { - logger.Error("object not found", "oid", pointer.Oid) - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "object not found", - }) - return - } - logger.Error("error getting object", "oid", pointer.Oid, "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - if obj.Size != pointer.Size { - renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ - Message: "object size mismatch", - }) - return - } - - if pointer.IsValid() && stat.Size() == pointer.Size { - renderStatus(http.StatusOK)(w, nil) - return - } - } else if errors.Is(err, fs.ErrNotExist) { - logger.Error("file not found", "oid", pointer.Oid) - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "object not found", - }) - return - } else { - logger.Error("error getting object", "oid", pointer.Oid, "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } -} - -func serviceLfsLocks(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - serviceLfsLocksGet(w, r) - case http.MethodPost: - serviceLfsLocksCreate(w, r) - default: - renderMethodNotAllowed(w, r) - } -} - -// POST: /.git/info/lfs/objects/locks -func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) { - if !isLfs(r) { - renderNotAcceptable(w) - return - } - - ctx := r.Context() - logger := log.FromContext(ctx).WithPrefix("http.lfs-locks") - - var req lfs.LockCreateRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - logger.Error("error decoding json", "err", err) - renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ - Message: "invalid request: " + err.Error(), - }) - return - } - - repo := proto.RepositoryFromContext(ctx) - if repo == nil { - logger.Error("error getting repository from context") - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "repository not found", - }) - return - } - - user := proto.UserFromContext(ctx) - if user == nil { - logger.Error("error getting user from context") - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "user not found", - }) - return - } - - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - if err := datastore.CreateLFSLockForUser(ctx, dbx, repo.ID(), user.ID(), req.Path, req.Ref.Name); err != nil { - err = db.WrapError(err) - if errors.Is(err, db.ErrDuplicateKey) { - errResp := lfs.LockResponse{ - ErrorResponse: lfs.ErrorResponse{ - Message: "lock already exists", - }, - } - lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path) - if err == nil { - errResp.Lock = lfs.Lock{ - ID: strconv.FormatInt(lock.ID, 10), - Path: lock.Path, - LockedAt: lock.CreatedAt, - } - lockOwner := lfs.Owner{ - Name: user.Username(), - } - if lock.UserID != user.ID() { - owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID) - if err != nil { - logger.Error("error getting lock owner", "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - lockOwner.Name = owner.Username - } - errResp.Lock.Owner = lockOwner - } - renderJSON(w, http.StatusConflict, errResp) - return - } - logger.Error("error creating lock", "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path) - if err != nil { - logger.Error("error getting lock", "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - renderJSON(w, http.StatusCreated, lfs.LockResponse{ - Lock: lfs.Lock{ - ID: strconv.FormatInt(lock.ID, 10), - Path: lock.Path, - LockedAt: lock.CreatedAt, - Owner: lfs.Owner{ - Name: user.Username(), - }, - }, - }) -} - -// GET: /.git/info/lfs/objects/locks -func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) { - accept := r.Header.Get("Accept") - if !strings.HasPrefix(accept, lfs.MediaType) { - renderNotAcceptable(w) - return - } - - parseLocksQuery := func(values url.Values) (path string, id int64, cursor int, limit int, refspec string) { - path = values.Get("path") - idStr := values.Get("id") - if idStr != "" { - id, _ = strconv.ParseInt(idStr, 10, 64) - } - cursorStr := values.Get("cursor") - if cursorStr != "" { - cursor, _ = strconv.Atoi(cursorStr) - } - limitStr := values.Get("limit") - if limitStr != "" { - limit, _ = strconv.Atoi(limitStr) - } - refspec = values.Get("refspec") - return - } - - ctx := r.Context() - // TODO: respect refspec - path, id, cursor, limit, _ := parseLocksQuery(r.URL.Query()) - if limit > 100 { - limit = 100 - } else if limit <= 0 { - limit = lfs.DefaultLocksLimit - } - - // cursor is the page number - if cursor <= 0 { - cursor = 1 - } - - logger := log.FromContext(ctx).WithPrefix("http.lfs-locks") - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - repo := proto.RepositoryFromContext(ctx) - if repo == nil { - logger.Error("error getting repository from context") - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "repository not found", - }) - return - } - - if id > 0 { - lock, err := datastore.GetLFSLockByID(ctx, dbx, id) - if err != nil { - if errors.Is(err, db.ErrRecordNotFound) { - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "lock not found", - }) - return - } - logger.Error("error getting lock", "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID) - if err != nil { - logger.Error("error getting lock owner", "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - renderJSON(w, http.StatusOK, lfs.LockListResponse{ - Locks: []lfs.Lock{ - { - ID: strconv.FormatInt(lock.ID, 10), - Path: lock.Path, - LockedAt: lock.CreatedAt, - Owner: lfs.Owner{ - Name: owner.Username, - }, - }, - }, - }) - return - } else if path != "" { - lock, err := datastore.GetLFSLockForPath(ctx, dbx, repo.ID(), path) - if err != nil { - if errors.Is(err, db.ErrRecordNotFound) { - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "lock not found", - }) - return - } - logger.Error("error getting lock", "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID) - if err != nil { - logger.Error("error getting lock owner", "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - renderJSON(w, http.StatusOK, lfs.LockListResponse{ - Locks: []lfs.Lock{ - { - ID: strconv.FormatInt(lock.ID, 10), - Path: lock.Path, - LockedAt: lock.CreatedAt, - Owner: lfs.Owner{ - Name: owner.Username, - }, - }, - }, - }) - return - } - - locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit) - if err != nil { - logger.Error("error getting locks", "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - lockList := make([]lfs.Lock, len(locks)) - users := map[int64]models.User{} - for i, lock := range locks { - owner, ok := users[lock.UserID] - if !ok { - owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID) - if err != nil { - logger.Error("error getting lock owner", "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - users[lock.UserID] = owner - } - - lockList[i] = lfs.Lock{ - ID: strconv.FormatInt(lock.ID, 10), - Path: lock.Path, - LockedAt: lock.CreatedAt, - Owner: lfs.Owner{ - Name: owner.Username, - }, - } - } - - resp := lfs.LockListResponse{ - Locks: lockList, - } - if len(locks) == limit { - resp.NextCursor = strconv.Itoa(cursor + 1) - } - - renderJSON(w, http.StatusOK, resp) -} - -// POST: /.git/info/lfs/objects/locks/verify -func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) { - if !isLfs(r) { - renderNotAcceptable(w) - return - } - - ctx := r.Context() - logger := log.FromContext(ctx).WithPrefix("http.lfs-locks") - repo := proto.RepositoryFromContext(ctx) - if repo == nil { - logger.Error("error getting repository from context") - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "repository not found", - }) - return - } - - var req lfs.LockVerifyRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - logger.Error("error decoding request", "err", err) - renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ - Message: "invalid request: " + err.Error(), - }) - return - } - - // TODO: refspec - cursor, _ := strconv.Atoi(req.Cursor) - if cursor <= 0 { - cursor = 1 - } - - limit := req.Limit - if limit > 100 { - limit = 100 - } else if limit <= 0 { - limit = lfs.DefaultLocksLimit - } - - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - user := proto.UserFromContext(ctx) - ours := make([]lfs.Lock, 0) - theirs := make([]lfs.Lock, 0) - - var resp lfs.LockVerifyResponse - locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit) - if err != nil { - logger.Error("error getting locks", "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - users := map[int64]models.User{} - for _, lock := range locks { - owner, ok := users[lock.UserID] - if !ok { - owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID) - if err != nil { - logger.Error("error getting lock owner", "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - users[lock.UserID] = owner - } - - l := lfs.Lock{ - ID: strconv.FormatInt(lock.ID, 10), - Path: lock.Path, - LockedAt: lock.CreatedAt, - Owner: lfs.Owner{ - Name: owner.Username, - }, - } - - if user != nil && user.ID() == lock.UserID { - ours = append(ours, l) - } else { - theirs = append(theirs, l) - } - } - - resp.Ours = ours - resp.Theirs = theirs - - if len(locks) == limit { - resp.NextCursor = strconv.Itoa(cursor + 1) - } - - renderJSON(w, http.StatusOK, resp) -} - -// POST: /.git/info/lfs/objects/locks/:lockID/unlock -func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) { - if !isLfs(r) { - renderNotAcceptable(w) - return - } - - ctx := r.Context() - logger := log.FromContext(ctx).WithPrefix("http.lfs-locks") - lockIDStr := mux.Vars(r)["lock_id"] - if lockIDStr == "" { - logger.Error("error getting lock id") - renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ - Message: "invalid request", - }) - return - } - - lockID, err := strconv.ParseInt(lockIDStr, 10, 64) - if err != nil { - logger.Error("error parsing lock id", "err", err) - renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ - Message: "invalid request", - }) - return - } - - var req lfs.LockDeleteRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - logger.Error("error decoding request", "err", err) - renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ - Message: "invalid request: " + err.Error(), - }) - return - } - - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - repo := proto.RepositoryFromContext(ctx) - if repo == nil { - logger.Error("error getting repository from context") - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "repository not found", - }) - return - } - - // The lock being deleted - lock, err := datastore.GetLFSLockByID(ctx, dbx, lockID) - if err != nil { - logger.Error("error getting lock", "err", err) - renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ - Message: "lock not found", - }) - return - } - - owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID) - if err != nil { - logger.Error("error getting lock owner", "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - // Delete another user's lock - l := lfs.Lock{ - ID: strconv.FormatInt(lock.ID, 10), - Path: lock.Path, - LockedAt: lock.CreatedAt, - Owner: lfs.Owner{ - Name: owner.Username, - }, - } - if req.Force { - if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil { - logger.Error("error deleting lock", "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - renderJSON(w, http.StatusOK, l) - return - } - - // Delete our own lock - user := proto.UserFromContext(ctx) - if user == nil { - logger.Error("error getting user from context") - renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{ - Message: "unauthorized", - }) - return - } - - if owner.ID != user.ID() { - logger.Error("error deleting another user's lock") - renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{ - Message: "lock belongs to another user", - }) - return - } - - if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil { - logger.Error("error deleting lock", "err", err) - renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ - Message: "internal server error", - }) - return - } - - renderJSON(w, http.StatusOK, lfs.LockResponse{Lock: l}) -} - -// renderJSON renders a JSON response with the given status code and value. It -// also sets the Content-Type header to the JSON LFS media type (application/vnd.git-lfs+json). -func renderJSON(w http.ResponseWriter, statusCode int, v interface{}) { - hdrLfs(w) - w.WriteHeader(statusCode) - if err := json.NewEncoder(w).Encode(v); err != nil { - log.Error("error encoding json", "err", err) - } -} - -func renderNotAcceptable(w http.ResponseWriter) { - renderStatus(http.StatusNotAcceptable)(w, nil) -} - -func isLfs(r *http.Request) bool { - contentType := r.Header.Get("Content-Type") - accept := r.Header.Get("Accept") - return strings.HasPrefix(contentType, lfs.MediaType) && strings.HasPrefix(accept, lfs.MediaType) -} - -func isBinary(r *http.Request) bool { - contentType := r.Header.Get("Content-Type") - return strings.HasPrefix(contentType, "application/octet-stream") -} - -func hdrLfs(w http.ResponseWriter) { - w.Header().Set("Content-Type", lfs.MediaType) - w.Header().Set("Accept", lfs.MediaType) -} diff --git a/server/web/goget.go b/server/web/goget.go deleted file mode 100644 index ac0e54c69..000000000 --- a/server/web/goget.go +++ /dev/null @@ -1,99 +0,0 @@ -package web - -import ( - "net/http" - "net/url" - "path" - "text/template" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/utils" - "github.com/gorilla/mux" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" -) - -var goGetCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "soft_serve", - Subsystem: "http", - Name: "go_get_total", - Help: "The total number of go get requests", -}, []string{"repo"}) - -var repoIndexHTMLTpl = template.Must(template.New("index").Parse(` - - - - - - - -Redirecting to docs at godoc.org/{{ .ImportRoot }}/{{ .Repo }}... - - -`)) - -// GoGetHandler handles go get requests. -type GoGetHandler struct{} - -var _ http.Handler = (*GoGetHandler)(nil) - -func (g GoGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - cfg := config.FromContext(ctx) - be := backend.FromContext(ctx) - logger := log.FromContext(ctx) - repo := mux.Vars(r)["repo"] - - // Handle go get requests. - // - // Always return a 200 status code, even if the repo path doesn't exist. - // It will try to find the repo by walking up the path until it finds one. - // If it can't find one, it will return a 404. - // - // https://golang.org/cmd/go/#hdr-Remote_import_paths - // https://go.dev/ref/mod#vcs-branch - if r.URL.Query().Get("go-get") == "1" { - repo := repo - importRoot, err := url.Parse(cfg.HTTP.PublicURL) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // find the repo - for { - if _, err := be.Repository(ctx, repo); err == nil { - break - } - - if repo == "" || repo == "." || repo == "/" { - renderNotFound(w, r) - return - } - - repo = path.Dir(repo) - } - - if err := repoIndexHTMLTpl.Execute(w, struct { - Repo string - Config *config.Config - ImportRoot string - }{ - Repo: utils.SanitizeRepo(repo), - Config: cfg, - ImportRoot: importRoot.Host, - }); err != nil { - logger.Error("failed to render go get template", "err", err) - renderInternalServerError(w, r) - return - } - - goGetCounter.WithLabelValues(repo).Inc() - return - } - - renderNotFound(w, r) -} diff --git a/server/web/http.go b/server/web/http.go deleted file mode 100644 index e4588c45d..000000000 --- a/server/web/http.go +++ /dev/null @@ -1,56 +0,0 @@ -package web - -import ( - "context" - "net/http" - "time" - - "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/config" -) - -// HTTPServer is an http server. -type HTTPServer struct { - ctx context.Context - cfg *config.Config - server *http.Server -} - -// NewHTTPServer creates a new HTTP server. -func NewHTTPServer(ctx context.Context) (*HTTPServer, error) { - cfg := config.FromContext(ctx) - logger := log.FromContext(ctx).WithPrefix("http") - s := &HTTPServer{ - ctx: ctx, - cfg: cfg, - server: &http.Server{ - Addr: cfg.HTTP.ListenAddr, - Handler: NewRouter(ctx), - ReadHeaderTimeout: time.Second * 10, - ReadTimeout: time.Second * 10, - WriteTimeout: time.Second * 10, - MaxHeaderBytes: http.DefaultMaxHeaderBytes, - ErrorLog: logger.StandardLog(log.StandardLogOptions{ForceLevel: log.ErrorLevel}), - }, - } - - return s, nil -} - -// Close closes the HTTP server. -func (s *HTTPServer) Close() error { - return s.server.Close() -} - -// ListenAndServe starts the HTTP server. -func (s *HTTPServer) ListenAndServe() error { - if s.cfg.HTTP.TLSKeyPath != "" && s.cfg.HTTP.TLSCertPath != "" { - return s.server.ListenAndServeTLS(s.cfg.HTTP.TLSCertPath, s.cfg.HTTP.TLSKeyPath) - } - return s.server.ListenAndServe() -} - -// Shutdown gracefully shuts down the HTTP server. -func (s *HTTPServer) Shutdown(ctx context.Context) error { - return s.server.Shutdown(ctx) -} diff --git a/server/web/logging.go b/server/web/logging.go deleted file mode 100644 index 40f187e08..000000000 --- a/server/web/logging.go +++ /dev/null @@ -1,83 +0,0 @@ -package web - -import ( - "bufio" - "fmt" - "net" - "net/http" - "time" - - "github.com/charmbracelet/log" - "github.com/dustin/go-humanize" -) - -// logWriter is a wrapper around http.ResponseWriter that allows us to capture -// the HTTP status code and bytes written to the response. -type logWriter struct { - http.ResponseWriter - code, bytes int -} - -var _ http.ResponseWriter = (*logWriter)(nil) - -var _ http.Flusher = (*logWriter)(nil) - -var _ http.Hijacker = (*logWriter)(nil) - -var _ http.CloseNotifier = (*logWriter)(nil) // nolint: staticcheck - -// Write implements http.ResponseWriter. -func (r *logWriter) Write(p []byte) (int, error) { - written, err := r.ResponseWriter.Write(p) - r.bytes += written - return written, err -} - -// Note this is generally only called when sending an HTTP error, so it's -// important to set the `code` value to 200 as a default. -func (r *logWriter) WriteHeader(code int) { - r.code = code - r.ResponseWriter.WriteHeader(code) -} - -// Flush implements http.Flusher. -func (r *logWriter) Flush() { - if f, ok := r.ResponseWriter.(http.Flusher); ok { - f.Flush() - } -} - -// CloseNotify implements http.CloseNotifier. -func (r *logWriter) CloseNotify() <-chan bool { - if cn, ok := r.ResponseWriter.(http.CloseNotifier); ok { // nolint: staticcheck - return cn.CloseNotify() - } - return nil -} - -// Hijack implements http.Hijacker. -func (r *logWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { - if h, ok := r.ResponseWriter.(http.Hijacker); ok { - return h.Hijack() - } - return nil, nil, fmt.Errorf("http.Hijacker not implemented") -} - -// NewLoggingMiddleware returns a new logging middleware. -func NewLoggingMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - logger := log.FromContext(r.Context()) - start := time.Now() - writer := &logWriter{code: http.StatusOK, ResponseWriter: w} - logger.Debug("request", - "method", r.Method, - "uri", r.RequestURI, - "addr", r.RemoteAddr) - next.ServeHTTP(writer, r) - elapsed := time.Since(start) - logger.Debug("response", - "status", fmt.Sprintf("%d %s", writer.code, http.StatusText(writer.code)), - "bytes", humanize.Bytes(uint64(writer.bytes)), - "time", elapsed) - }) -} diff --git a/server/web/server.go b/server/web/server.go deleted file mode 100644 index 73921e68b..000000000 --- a/server/web/server.go +++ /dev/null @@ -1,28 +0,0 @@ -package web - -import ( - "context" - "net/http" - - "github.com/gorilla/handlers" - "github.com/gorilla/mux" -) - -// NewRouter returns a new HTTP router. -func NewRouter(ctx context.Context) http.Handler { - router := mux.NewRouter() - - // Git routes - GitController(ctx, router) - - router.PathPrefix("/").HandlerFunc(renderNotFound) - - // Context handler - // Adds context to the request - h := NewContextHandler(ctx)(router) - h = handlers.CompressHandler(h) - h = handlers.RecoveryHandler()(h) - h = NewLoggingMiddleware(h) - - return h -} diff --git a/server/web/util.go b/server/web/util.go deleted file mode 100644 index 412d0e00e..000000000 --- a/server/web/util.go +++ /dev/null @@ -1,14 +0,0 @@ -package web - -import ( - "fmt" - "io" - "net/http" -) - -func renderStatus(code int) http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(code) - io.WriteString(w, fmt.Sprintf("%d %s", code, http.StatusText(code))) // nolint: errcheck - } -} diff --git a/server/webhook/branch_tag.go b/server/webhook/branch_tag.go deleted file mode 100644 index 88f42bdde..000000000 --- a/server/webhook/branch_tag.go +++ /dev/null @@ -1,86 +0,0 @@ -package webhook - -import ( - "context" - "fmt" - - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/store" -) - -// BranchTagEvent is a branch or tag event. -type BranchTagEvent struct { - Common - - // Ref is the branch or tag name. - Ref string `json:"ref" url:"ref"` - // Before is the previous commit SHA. - Before string `json:"before" url:"before"` - // After is the current commit SHA. - After string `json:"after" url:"after"` - // Created is whether the branch or tag was created. - Created bool `json:"created" url:"created"` - // Deleted is whether the branch or tag was deleted. - Deleted bool `json:"deleted" url:"deleted"` -} - -// NewBranchTagEvent sends a branch or tag event. -func NewBranchTagEvent(ctx context.Context, user proto.User, repo proto.Repository, ref, before, after string) (BranchTagEvent, error) { - var event Event - if git.IsZeroHash(before) { - event = EventBranchTagCreate - } else if git.IsZeroHash(after) { - event = EventBranchTagDelete - } else { - return BranchTagEvent{}, fmt.Errorf("invalid branch or tag event: before=%q after=%q", before, after) - } - - payload := BranchTagEvent{ - Ref: ref, - Before: before, - After: after, - Created: git.IsZeroHash(before), - Deleted: git.IsZeroHash(after), - Common: Common{ - EventType: event, - Repository: Repository{ - ID: repo.ID(), - Name: repo.Name(), - Description: repo.Description(), - ProjectName: repo.ProjectName(), - Private: repo.IsPrivate(), - CreatedAt: repo.CreatedAt(), - UpdatedAt: repo.UpdatedAt(), - }, - Sender: User{ - ID: user.ID(), - Username: user.Username(), - }, - }, - } - - cfg := config.FromContext(ctx) - payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name()) - payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name()) - payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name()) - - // Find repo owner. - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID()) - if err != nil { - return BranchTagEvent{}, db.WrapError(err) - } - - payload.Repository.Owner.ID = owner.ID - payload.Repository.Owner.Username = owner.Username - payload.Repository.DefaultBranch, err = proto.RepositoryDefaultBranch(repo) - if err != nil { - return BranchTagEvent{}, err - } - - return payload, nil -} diff --git a/server/webhook/collaborator.go b/server/webhook/collaborator.go deleted file mode 100644 index 86f3e86a1..000000000 --- a/server/webhook/collaborator.go +++ /dev/null @@ -1,83 +0,0 @@ -package webhook - -import ( - "context" - - "github.com/charmbracelet/soft-serve/server/access" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/store" -) - -// CollaboratorEvent is a collaborator event. -type CollaboratorEvent struct { - Common - - // Action is the collaborator event action. - Action CollaboratorEventAction `json:"action" url:"action"` - // AccessLevel is the collaborator access level. - AccessLevel access.AccessLevel `json:"access_level" url:"access_level"` - // Collaborator is the collaborator. - Collaborator User `json:"collaborator" url:"collaborator"` -} - -// CollaboratorEventAction is a collaborator event action. -type CollaboratorEventAction string - -const ( - // CollaboratorEventAdded is a collaborator added event. - CollaboratorEventAdded CollaboratorEventAction = "added" - // CollaboratorEventRemoved is a collaborator removed event. - CollaboratorEventRemoved CollaboratorEventAction = "removed" -) - -// NewCollaboratorEvent sends a collaborator event. -func NewCollaboratorEvent(ctx context.Context, user proto.User, repo proto.Repository, collabUsername string, action CollaboratorEventAction) (CollaboratorEvent, error) { - event := EventCollaborator - - payload := CollaboratorEvent{ - Action: action, - Common: Common{ - EventType: event, - Repository: Repository{ - ID: repo.ID(), - Name: repo.Name(), - Description: repo.Description(), - ProjectName: repo.ProjectName(), - Private: repo.IsPrivate(), - CreatedAt: repo.CreatedAt(), - UpdatedAt: repo.UpdatedAt(), - }, - Sender: User{ - ID: user.ID(), - Username: user.Username(), - }, - }, - } - - // Find repo owner. - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID()) - if err != nil { - return CollaboratorEvent{}, db.WrapError(err) - } - - payload.Repository.Owner.ID = owner.ID - payload.Repository.Owner.Username = owner.Username - payload.Repository.DefaultBranch, err = proto.RepositoryDefaultBranch(repo) - if err != nil { - return CollaboratorEvent{}, err - } - - collab, err := datastore.GetCollabByUsernameAndRepo(ctx, dbx, collabUsername, repo.Name()) - if err != nil { - return CollaboratorEvent{}, err - } - - payload.AccessLevel = collab.AccessLevel - payload.Collaborator.ID = collab.UserID - payload.Collaborator.Username = collabUsername - - return payload, nil -} diff --git a/server/webhook/common.go b/server/webhook/common.go deleted file mode 100644 index b805c24a8..000000000 --- a/server/webhook/common.go +++ /dev/null @@ -1,95 +0,0 @@ -package webhook - -import "time" - -// EventPayload is a webhook event payload. -type EventPayload interface { - // Event returns the event type. - Event() Event - // RepositoryID returns the repository ID. - RepositoryID() int64 -} - -// Common is a common payload. -type Common struct { - // EventType is the event type. - EventType Event `json:"event" url:"event"` - // Repository is the repository payload. - Repository Repository `json:"repository" url:"repository"` - // Sender is the sender payload. - Sender User `json:"sender" url:"sender"` -} - -// Event returns the event type. -// Implements EventPayload. -func (c Common) Event() Event { - return c.EventType -} - -// RepositoryID returns the repository ID. -// Implements EventPayload. -func (c Common) RepositoryID() int64 { - return c.Repository.ID -} - -// User represents a user in an event. -type User struct { - // ID is the owner ID. - ID int64 `json:"id" url:"id"` - // Username is the owner username. - Username string `json:"username" url:"username"` -} - -// Repository represents an event repository. -type Repository struct { - // ID is the repository ID. - ID int64 `json:"id" url:"id"` - // Name is the repository name. - Name string `json:"name" url:"name"` - // ProjectName is the repository project name. - ProjectName string `json:"project_name" url:"project_name"` - // Description is the repository description. - Description string `json:"description" url:"description"` - // DefaultBranch is the repository default branch. - DefaultBranch string `json:"default_branch" url:"default_branch"` - // Private is whether the repository is private. - Private bool `json:"private" url:"private"` - // Owner is the repository owner. - Owner User `json:"owner" url:"owner"` - // HTTPURL is the repository HTTP URL. - HTTPURL string `json:"http_url" url:"http_url"` - // SSHURL is the repository SSH URL. - SSHURL string `json:"ssh_url" url:"ssh_url"` - // GitURL is the repository Git URL. - GitURL string `json:"git_url" url:"git_url"` - // CreatedAt is the repository creation time. - CreatedAt time.Time `json:"created_at" url:"created_at"` - // UpdatedAt is the repository last update time. - UpdatedAt time.Time `json:"updated_at" url:"updated_at"` -} - -// Author is a commit author. -type Author struct { - // Name is the author name. - Name string `json:"name" url:"name"` - // Email is the author email. - Email string `json:"email" url:"email"` - // Date is the author date. - Date time.Time `json:"date" url:"date"` -} - -// Commit represents a Git commit. -type Commit struct { - // ID is the commit ID. - ID string `json:"id" url:"id"` - // Message is the commit message. - Message string `json:"message" url:"message"` - // Title is the commit title. - Title string `json:"title" url:"title"` - // Author is the commit author. - Author Author `json:"author" url:"author"` - // Committer is the commit committer. - Committer Author `json:"committer" url:"committer"` - // Timestamp is the commit timestamp. - Timestamp time.Time `json:"timestamp" url:"timestamp"` -} diff --git a/server/webhook/content_type.go b/server/webhook/content_type.go deleted file mode 100644 index 31731d287..000000000 --- a/server/webhook/content_type.go +++ /dev/null @@ -1,70 +0,0 @@ -package webhook - -import ( - "encoding" - "errors" - "strings" -) - -// ContentType is the type of content that will be sent in a webhook request. -type ContentType int8 - -const ( - // ContentTypeJSON is the JSON content type. - ContentTypeJSON ContentType = iota - // ContentTypeForm is the form content type. - ContentTypeForm -) - -var contentTypeStrings = map[ContentType]string{ - ContentTypeJSON: "application/json", - ContentTypeForm: "application/x-www-form-urlencoded", -} - -// String returns the string representation of the content type. -func (c ContentType) String() string { - return contentTypeStrings[c] -} - -var stringContentType = map[string]ContentType{ - "application/json": ContentTypeJSON, - "application/x-www-form-urlencoded": ContentTypeForm, -} - -// ErrInvalidContentType is returned when the content type is invalid. -var ErrInvalidContentType = errors.New("invalid content type") - -// ParseContentType parses a content type string and returns the content type. -func ParseContentType(s string) (ContentType, error) { - for k, v := range stringContentType { - if strings.HasPrefix(s, k) { - return v, nil - } - } - - return -1, ErrInvalidContentType -} - -var _ encoding.TextMarshaler = ContentType(0) -var _ encoding.TextUnmarshaler = (*ContentType)(nil) - -// UnmarshalText implements encoding.TextUnmarshaler. -func (c *ContentType) UnmarshalText(text []byte) error { - ct, err := ParseContentType(string(text)) - if err != nil { - return err - } - - *c = ct - return nil -} - -// MarshalText implements encoding.TextMarshaler. -func (c ContentType) MarshalText() (text []byte, err error) { - ct := c.String() - if ct == "" { - return nil, ErrInvalidContentType - } - - return []byte(ct), nil -} diff --git a/server/webhook/event.go b/server/webhook/event.go deleted file mode 100644 index 09edb9cab..000000000 --- a/server/webhook/event.go +++ /dev/null @@ -1,101 +0,0 @@ -package webhook - -import ( - "encoding" - "errors" -) - -// Event is a webhook event. -type Event int - -const ( - // EventBranchTagCreate is a branch or tag create event. - EventBranchTagCreate Event = 1 - - // EventBranchTagDelete is a branch or tag delete event. - EventBranchTagDelete Event = 2 - - // EventCollaborator is a collaborator change event. - EventCollaborator Event = 3 - - // EventPush is a push event. - EventPush Event = 4 - - // EventRepository is a repository create, delete, rename event. - EventRepository Event = 5 - - // EventRepositoryVisibilityChange is a repository visibility change event. - EventRepositoryVisibilityChange Event = 6 -) - -// Events return all events. -func Events() []Event { - return []Event{ - EventBranchTagCreate, - EventBranchTagDelete, - EventCollaborator, - EventPush, - EventRepository, - EventRepositoryVisibilityChange, - } -} - -var eventStrings = map[Event]string{ - EventBranchTagCreate: "branch_tag_create", - EventBranchTagDelete: "branch_tag_delete", - EventCollaborator: "collaborator", - EventPush: "push", - EventRepository: "repository", - EventRepositoryVisibilityChange: "repository_visibility_change", -} - -// String returns the string representation of the event. -func (e Event) String() string { - return eventStrings[e] -} - -var stringEvent = map[string]Event{ - "branch_tag_create": EventBranchTagCreate, - "branch_tag_delete": EventBranchTagDelete, - "collaborator": EventCollaborator, - "push": EventPush, - "repository": EventRepository, - "repository_visibility_change": EventRepositoryVisibilityChange, -} - -// ErrInvalidEvent is returned when the event is invalid. -var ErrInvalidEvent = errors.New("invalid event") - -// ParseEvent parses an event string and returns the event. -func ParseEvent(s string) (Event, error) { - e, ok := stringEvent[s] - if !ok { - return -1, ErrInvalidEvent - } - - return e, nil -} - -var _ encoding.TextMarshaler = Event(0) -var _ encoding.TextUnmarshaler = (*Event)(nil) - -// UnmarshalText implements encoding.TextUnmarshaler. -func (e *Event) UnmarshalText(text []byte) error { - ev, err := ParseEvent(string(text)) - if err != nil { - return err - } - - *e = ev - return nil -} - -// MarshalText implements encoding.TextMarshaler. -func (e Event) MarshalText() (text []byte, err error) { - ev := e.String() - if ev == "" { - return nil, ErrInvalidEvent - } - - return []byte(ev), nil -} diff --git a/server/webhook/push.go b/server/webhook/push.go deleted file mode 100644 index 4642aeb81..000000000 --- a/server/webhook/push.go +++ /dev/null @@ -1,117 +0,0 @@ -package webhook - -import ( - "context" - "fmt" - - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/store" - gitm "github.com/gogs/git-module" -) - -// PushEvent is a push event. -type PushEvent struct { - Common - - // Ref is the branch or tag name. - Ref string `json:"ref" url:"ref"` - // Before is the previous commit SHA. - Before string `json:"before" url:"before"` - // After is the current commit SHA. - After string `json:"after" url:"after"` - // Commits is the list of commits. - Commits []Commit `json:"commits" url:"commits"` -} - -// NewPushEvent sends a push event. -func NewPushEvent(ctx context.Context, user proto.User, repo proto.Repository, ref, before, after string) (PushEvent, error) { - event := EventPush - - payload := PushEvent{ - Ref: ref, - Before: before, - After: after, - Common: Common{ - EventType: event, - Repository: Repository{ - ID: repo.ID(), - Name: repo.Name(), - Description: repo.Description(), - ProjectName: repo.ProjectName(), - Private: repo.IsPrivate(), - CreatedAt: repo.CreatedAt(), - UpdatedAt: repo.UpdatedAt(), - }, - Sender: User{ - ID: user.ID(), - Username: user.Username(), - }, - }, - } - - cfg := config.FromContext(ctx) - payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name()) - payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name()) - payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name()) - - // Find repo owner. - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID()) - if err != nil { - return PushEvent{}, db.WrapError(err) - } - - payload.Repository.Owner.ID = owner.ID - payload.Repository.Owner.Username = owner.Username - - // Find commits. - r, err := repo.Open() - if err != nil { - return PushEvent{}, err - } - - payload.Repository.DefaultBranch, err = proto.RepositoryDefaultBranch(repo) - if err != nil { - return PushEvent{}, err - } - - rev := after - if !git.IsZeroHash(before) { - rev = fmt.Sprintf("%s..%s", before, after) - } - - commits, err := r.Log(rev, gitm.LogOptions{ - // XXX: limit to 20 commits for now - // TODO: implement a commits api - MaxCount: 20, - }) - if err != nil { - return PushEvent{}, err - } - - payload.Commits = make([]Commit, len(commits)) - for i, c := range commits { - payload.Commits[i] = Commit{ - ID: c.ID.String(), - Message: c.Message, - Title: c.Summary(), - Author: Author{ - Name: c.Author.Name, - Email: c.Author.Email, - Date: c.Author.When, - }, - Committer: Author{ - Name: c.Committer.Name, - Email: c.Committer.Email, - Date: c.Committer.When, - }, - Timestamp: c.Committer.When, - } - } - - return payload, nil -} diff --git a/server/webhook/repository.go b/server/webhook/repository.go deleted file mode 100644 index 19cb72304..000000000 --- a/server/webhook/repository.go +++ /dev/null @@ -1,82 +0,0 @@ -package webhook - -import ( - "context" - - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/store" -) - -// RepositoryEvent is a repository payload. -type RepositoryEvent struct { - Common - - // Action is the repository event action. - Action RepositoryEventAction `json:"action" url:"action"` -} - -// RepositoryEventAction is a repository event action. -type RepositoryEventAction string - -const ( - // RepositoryEventActionDelete is a repository deleted event. - RepositoryEventActionDelete RepositoryEventAction = "delete" - // RepositoryEventActionRename is a repository renamed event. - RepositoryEventActionRename RepositoryEventAction = "rename" - // RepositoryEventActionVisibilityChange is a repository visibility changed event. - RepositoryEventActionVisibilityChange RepositoryEventAction = "visibility_change" - // RepositoryEventActionDefaultBranchChange is a repository default branch changed event. - RepositoryEventActionDefaultBranchChange RepositoryEventAction = "default_branch_change" -) - -// NewRepositoryEvent sends a repository event. -func NewRepositoryEvent(ctx context.Context, user proto.User, repo proto.Repository, action RepositoryEventAction) (RepositoryEvent, error) { - var event Event - switch action { - case RepositoryEventActionVisibilityChange: - event = EventRepositoryVisibilityChange - default: - event = EventRepository - } - - payload := RepositoryEvent{ - Action: action, - Common: Common{ - EventType: event, - Repository: Repository{ - ID: repo.ID(), - Name: repo.Name(), - Description: repo.Description(), - ProjectName: repo.ProjectName(), - Private: repo.IsPrivate(), - CreatedAt: repo.CreatedAt(), - UpdatedAt: repo.UpdatedAt(), - }, - Sender: User{ - ID: user.ID(), - Username: user.Username(), - }, - }, - } - - cfg := config.FromContext(ctx) - payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name()) - payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name()) - payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name()) - - // Find repo owner. - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID()) - if err != nil { - return RepositoryEvent{}, db.WrapError(err) - } - - payload.Repository.Owner.ID = owner.ID - payload.Repository.Owner.Username = owner.Username - payload.Repository.DefaultBranch, _ = proto.RepositoryDefaultBranch(repo) - - return payload, nil -} diff --git a/server/webhook/webhook.go b/server/webhook/webhook.go deleted file mode 100644 index 0429056c2..000000000 --- a/server/webhook/webhook.go +++ /dev/null @@ -1,144 +0,0 @@ -package webhook - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/models" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/charmbracelet/soft-serve/server/utils" - "github.com/charmbracelet/soft-serve/server/version" - "github.com/google/go-querystring/query" - "github.com/google/uuid" -) - -// Hook is a repository webhook. -type Hook struct { - models.Webhook - ContentType ContentType - Events []Event -} - -// Delivery is a webhook delivery. -type Delivery struct { - models.WebhookDelivery - Event Event -} - -// do sends a webhook. -// Caller must close the returned body. -func do(ctx context.Context, url string, method string, headers http.Header, body io.Reader) (*http.Response, error) { - req, err := http.NewRequestWithContext(ctx, method, url, body) - if err != nil { - return nil, err - } - - req.Header = headers - res, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - - return res, nil -} - -// SendWebhook sends a webhook event. -func SendWebhook(ctx context.Context, w models.Webhook, event Event, payload interface{}) error { - var buf bytes.Buffer - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - - contentType := ContentType(w.ContentType) - switch contentType { - case ContentTypeJSON: - if err := json.NewEncoder(&buf).Encode(payload); err != nil { - return err - } - case ContentTypeForm: - v, err := query.Values(payload) - if err != nil { - return err - } - buf.WriteString(v.Encode()) // nolint: errcheck - default: - return ErrInvalidContentType - } - - headers := http.Header{} - headers.Add("Content-Type", contentType.String()) - headers.Add("User-Agent", "SoftServe/"+version.Version) - headers.Add("X-SoftServe-Event", event.String()) - - id, err := uuid.NewUUID() - if err != nil { - return err - } - - headers.Add("X-SoftServe-Delivery", id.String()) - - reqBody := buf.String() - if w.Secret != "" { - sig := hmac.New(sha256.New, []byte(w.Secret)) - sig.Write([]byte(reqBody)) // nolint: errcheck - headers.Add("X-SoftServe-Signature", "sha256="+hex.EncodeToString(sig.Sum(nil))) - } - - res, reqErr := do(ctx, w.URL, http.MethodPost, headers, &buf) - var reqHeaders string - for k, v := range headers { - reqHeaders += k + ": " + v[0] + "\n" - } - - resStatus := 0 - resHeaders := "" - resBody := "" - - if res != nil { - resStatus = res.StatusCode - for k, v := range res.Header { - resHeaders += k + ": " + v[0] + "\n" - } - - if res.Body != nil { - defer res.Body.Close() // nolint: errcheck - b, err := io.ReadAll(res.Body) - if err != nil { - return err - } - - resBody = string(b) - } - } - - return db.WrapError(datastore.CreateWebhookDelivery(ctx, dbx, id, w.ID, int(event), w.URL, http.MethodPost, reqErr, reqHeaders, reqBody, resStatus, resHeaders, resBody)) -} - -// SendEvent sends a webhook event. -func SendEvent(ctx context.Context, payload EventPayload) error { - dbx := db.FromContext(ctx) - datastore := store.FromContext(ctx) - webhooks, err := datastore.GetWebhooksByRepoIDWhereEvent(ctx, dbx, payload.RepositoryID(), []int{int(payload.Event())}) - if err != nil { - return db.WrapError(err) - } - - for _, w := range webhooks { - if err := SendWebhook(ctx, w, payload.Event(), payload); err != nil { - return err - } - } - - return nil -} - -func repoURL(publicURL string, repo string) string { - return fmt.Sprintf("%s/%s.git", publicURL, utils.SanitizeRepo(repo)) -} diff --git a/testscript/script_test.go b/testscript/script_test.go index 1372c8258..94c6408bc 100644 --- a/testscript/script_test.go +++ b/testscript/script_test.go @@ -19,15 +19,15 @@ import ( "github.com/charmbracelet/keygen" "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/migrate" - logr "github.com/charmbracelet/soft-serve/server/log" - "github.com/charmbracelet/soft-serve/server/store" - "github.com/charmbracelet/soft-serve/server/store/database" - "github.com/charmbracelet/soft-serve/server/test" + "github.com/charmbracelet/soft-serve/cmd/soft/serve" + "github.com/charmbracelet/soft-serve/pkg/backend" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/db" + "github.com/charmbracelet/soft-serve/pkg/db/migrate" + logr "github.com/charmbracelet/soft-serve/pkg/log" + "github.com/charmbracelet/soft-serve/pkg/store" + "github.com/charmbracelet/soft-serve/pkg/store/database" + "github.com/charmbracelet/soft-serve/pkg/test" "github.com/rogpeppe/go-internal/testscript" "github.com/spf13/cobra" "golang.org/x/crypto/ssh" @@ -153,7 +153,7 @@ func TestScript(t *testing.T) { ctx = backend.WithContext(ctx, be) lock.Lock() - srv, err := server.NewServer(ctx) + srv, err := serve.NewServer(ctx) if err != nil { lock.Unlock() return err