From 08463237cade5869673a6cded4700833cb4286f2 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 25 Oct 2023 11:37:11 -0700 Subject: [PATCH] Repository webhooks (#375) * feat: export server version * fix: move db driver imports to db package * feat: implement server webhooks - branch/tag events - collaborators events - push events - repository events - [x] Implement database logic - [x] Add database migrations - [x] Implement webhooks logic - [x] Integrate webhooks with backend - [x] Implement repository webhooks SSH command interface - [x] Implement webhook deliveries listing Fixes: https://github.com/charmbracelet/soft-serve/issues/148 Fixes: https://github.com/charmbracelet/soft-serve/pull/56 Fixes: https://github.com/charmbracelet/soft-serve/issues/49 * wip * fix: remove unnecessary webhook events * fix(db): postgres migration script * fix(db): use returning instead of LastInsertId * fix(webhook): limit the number of push commits to 20 * fix(webhook): rename html_url to http_url * fix(http): return 404 when repository on go-get not found --- cmd/soft/browse.go | 5 + cmd/soft/root.go | 9 + git/commit.go | 8 + go.mod | 3 +- go.sum | 3 + server/backend/collab.go | 43 +- server/backend/hooks.go | 56 ++- server/backend/repo.go | 83 +++- server/backend/webhooks.go | 279 ++++++++++++ server/config/config.go | 5 + server/config/file.go | 4 + server/db/errors.go | 8 +- 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/migrations.go | 1 + server/db/models/webhook.go | 44 ++ server/proto/errors.go | 2 + server/proto/repo.go | 17 + server/ssh/cmd/branch.go | 27 +- server/ssh/cmd/cmd.go | 20 +- server/ssh/cmd/repo.go | 1 + server/ssh/cmd/tag.go | 39 +- server/ssh/cmd/webhooks.go | 406 ++++++++++++++++++ server/store/database/database.go | 1 + server/store/database/webhooks.go | 165 +++++++ server/store/store.go | 1 + server/store/webhooks.go | 48 +++ server/version/version.go | 14 + server/web/goget.go | 1 + 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/testdata/http.txtar | 4 + testscript/testdata/repo-webhooks.txtar | 27 ++ 42 files changed, 2191 insertions(+), 26 deletions(-) create mode 100644 server/backend/webhooks.go create mode 100644 server/db/migrate/0002_webhooks.go create mode 100644 server/db/migrate/0002_webhooks_postgres.down.sql create mode 100644 server/db/migrate/0002_webhooks_postgres.up.sql create mode 100644 server/db/migrate/0002_webhooks_sqlite.down.sql create mode 100644 server/db/migrate/0002_webhooks_sqlite.up.sql create mode 100644 server/db/models/webhook.go create mode 100644 server/ssh/cmd/webhooks.go create mode 100644 server/store/database/webhooks.go create mode 100644 server/store/webhooks.go create mode 100644 server/version/version.go create mode 100644 server/webhook/branch_tag.go create mode 100644 server/webhook/collaborator.go create mode 100644 server/webhook/common.go create mode 100644 server/webhook/content_type.go create mode 100644 server/webhook/event.go create mode 100644 server/webhook/push.go create mode 100644 server/webhook/repository.go create mode 100644 server/webhook/webhook.go create mode 100644 testscript/testdata/repo-webhooks.txtar diff --git a/cmd/soft/browse.go b/cmd/soft/browse.go index 324e7cb46..8cb760198 100644 --- a/cmd/soft/browse.go +++ b/cmd/soft/browse.go @@ -299,3 +299,8 @@ func (r repository) UpdatedAt() time.Time { func (r repository) UserID() int64 { return 0 } + +// CreatedAt implements proto.Repository. +func (r repository) CreatedAt() time.Time { + return time.Time{} +} diff --git a/cmd/soft/root.go b/cmd/soft/root.go index e031a67a3..c47a81f7e 100644 --- a/cmd/soft/root.go +++ b/cmd/soft/root.go @@ -15,6 +15,7 @@ import ( 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/version" "github.com/spf13/cobra" "go.uber.org/automaxprocs/maxprocs" ) @@ -28,6 +29,10 @@ var ( // against. It's set via ldflags when building. CommitSHA = "" + // CommitDate contains the date of the commit that this application was + // built against. It's set via ldflags when building. + CommitDate = "" + rootCmd = &cobra.Command{ Use: "soft", Short: "A self-hostable Git server for the command line", @@ -61,6 +66,10 @@ func init() { } } rootCmd.Version = Version + + version.Version = Version + version.CommitSHA = CommitSHA + version.CommitDate = CommitDate } func main() { diff --git a/git/commit.go b/git/commit.go index 1e955ff31..20f2050d4 100644 --- a/git/commit.go +++ b/git/commit.go @@ -1,12 +1,20 @@ package git import ( + "regexp" + "github.com/gogs/git-module" ) // ZeroID is the zero hash. const ZeroID = git.EmptyID +// IsZeroHash returns whether the hash is a zero hash. +func IsZeroHash(h string) bool { + pattern := regexp.MustCompile(`^0{40,}$`) + return pattern.MatchString(h) +} + // Commit is a wrapper around git.Commit with helper methods. type Commit = git.Commit diff --git a/go.mod b/go.mod index 25682ab2c..a5422cf75 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,8 @@ require ( github.com/gobwas/glob v0.2.3 github.com/gogs/git-module v1.8.3 github.com/golang-jwt/jwt/v5 v5.0.0 + github.com/google/go-querystring v1.1.0 + github.com/google/uuid v1.3.0 github.com/gorilla/handlers v1.5.1 github.com/gorilla/mux v1.8.0 github.com/hashicorp/golang-lru/v2 v2.0.7 @@ -65,7 +67,6 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/golang/protobuf v1.5.3 // indirect - github.com/google/uuid v1.3.0 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect diff --git a/go.sum b/go.sum index 22b77cacd..dd7cca011 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,11 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/server/backend/collab.go b/server/backend/collab.go index ffe4b5f6e..6b03c5d0e 100644 --- a/server/backend/collab.go +++ b/server/backend/collab.go @@ -2,12 +2,15 @@ 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. @@ -20,11 +23,25 @@ func (d *Backend) AddCollaborator(ctx context.Context, repo string, username str } repo = utils.SanitizeRepo(repo) - return db.WrapError( + 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. @@ -75,9 +92,27 @@ func (d *Backend) IsCollaborator(ctx context.Context, repo string, username stri // It implements backend.Backend. func (d *Backend) RemoveCollaborator(ctx context.Context, repo string, username string) error { repo = utils.SanitizeRepo(repo) - return db.WrapError( + 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/hooks.go b/server/backend/hooks.go index fe10432ed..3e5297eb9 100644 --- a/server/backend/hooks.go +++ b/server/backend/hooks.go @@ -3,10 +3,14 @@ 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) @@ -28,8 +32,58 @@ func (d *Backend) PreReceive(_ context.Context, _ io.Writer, _ io.Writer, repo s // Update is called by the git update hook. // // It implements Hooks. -func (d *Backend) Update(_ context.Context, _ io.Writer, _ io.Writer, repo string, arg hooks.HookArg) { +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. diff --git a/server/backend/repo.go b/server/backend/repo.go index 2d8633cc3..2a8a8d1be 100644 --- a/server/backend/repo.go +++ b/server/backend/repo.go @@ -21,6 +21,7 @@ import ( "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 { @@ -216,7 +217,20 @@ func (d *Backend) DeleteRepository(ctx context.Context, name string) error { repo := name + ".git" rp := filepath.Join(d.reposPath(), repo) - err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { + 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) @@ -257,17 +271,20 @@ func (d *Backend) DeleteRepository(ctx context.Context, name string) error { } return os.RemoveAll(rp) - }) - if errors.Is(err, db.ErrRecordNotFound) { - return proto.ErrRepoNotFound + }); err != nil { + if errors.Is(err, db.ErrRecordNotFound) { + return proto.ErrRepoNotFound + } + + return db.WrapError(err) } - return err + return webhook.SendEvent(ctx, wh) } // DeleteUserRepositories deletes all user repositories. func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) error { - return d.db.TransactionContext(ctx, func(tx *db.Tx) 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 @@ -285,7 +302,11 @@ func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) e } return nil - }) + }); err != nil { + return db.WrapError(err) + } + + return nil } // RenameRepository renames a repository. @@ -301,6 +322,11 @@ func (d *Backend) RenameRepository(ctx context.Context, oldName string, 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) @@ -331,7 +357,18 @@ func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName return db.WrapError(err) } - return nil + 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. @@ -537,7 +574,7 @@ func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) err // Delete cache d.cache.Delete(name) - return db.WrapError( + if err := db.WrapError( d.db.TransactionContext(ctx, func(tx *db.Tx) error { fp := filepath.Join(rp, "git-daemon-export-ok") if !private { @@ -556,7 +593,28 @@ func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) 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. @@ -651,6 +709,11 @@ 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. diff --git a/server/backend/webhooks.go b/server/backend/webhooks.go new file mode 100644 index 000000000..6d676d356 --- /dev/null +++ b/server/backend/webhooks.go @@ -0,0 +1,279 @@ +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 index aba8f508e..fa5e2de8e 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -40,6 +40,9 @@ 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"` @@ -157,6 +160,7 @@ func (c *Config) Environ() []string { 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), @@ -304,6 +308,7 @@ func DefaultConfig() *Config { }, Git: GitConfig{ ListenAddr: ":9418", + PublicURL: "git://localhost", MaxTimeout: 0, IdleTimeout: 3, MaxConnections: 32, diff --git a/server/config/file.go b/server/config/file.go index 6560dd15c..27a3dcdf1 100644 --- a/server/config/file.go +++ b/server/config/file.go @@ -50,6 +50,10 @@ 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 }} diff --git a/server/db/errors.go b/server/db/errors.go index 9c19b0282..752835f43 100644 --- a/server/db/errors.go +++ b/server/db/errors.go @@ -6,7 +6,7 @@ import ( "github.com/lib/pq" sqlite "modernc.org/sqlite" - sqlite3 "modernc.org/sqlite/lib" + sqlitelib "modernc.org/sqlite/lib" ) var ( @@ -28,9 +28,9 @@ func WrapError(err error) error { // Handle sqlite constraint error. if liteErr, ok := err.(*sqlite.Error); ok { code := liteErr.Code() - if code == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY || - code == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY || - code == sqlite3.SQLITE_CONSTRAINT_UNIQUE { + if code == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY || + code == sqlitelib.SQLITE_CONSTRAINT_FOREIGNKEY || + code == sqlitelib.SQLITE_CONSTRAINT_UNIQUE { return ErrDuplicateKey } } diff --git a/server/db/migrate/0002_webhooks.go b/server/db/migrate/0002_webhooks.go new file mode 100644 index 000000000..7ad37be61 --- /dev/null +++ b/server/db/migrate/0002_webhooks.go @@ -0,0 +1,23 @@ +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 new file mode 100644 index 000000000..e69de29bb diff --git a/server/db/migrate/0002_webhooks_postgres.up.sql b/server/db/migrate/0002_webhooks_postgres.up.sql new file mode 100644 index 000000000..dee09f703 --- /dev/null +++ b/server/db/migrate/0002_webhooks_postgres.up.sql @@ -0,0 +1,46 @@ +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 new file mode 100644 index 000000000..e69de29bb diff --git a/server/db/migrate/0002_webhooks_sqlite.up.sql b/server/db/migrate/0002_webhooks_sqlite.up.sql new file mode 100644 index 000000000..5f2139c30 --- /dev/null +++ b/server/db/migrate/0002_webhooks_sqlite.up.sql @@ -0,0 +1,46 @@ +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/migrations.go b/server/db/migrate/migrations.go index 88a9e4346..890454ab1 100644 --- a/server/db/migrate/migrations.go +++ b/server/db/migrate/migrations.go @@ -16,6 +16,7 @@ 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 { diff --git a/server/db/models/webhook.go b/server/db/models/webhook.go new file mode 100644 index 000000000..85667ceb8 --- /dev/null +++ b/server/db/models/webhook.go @@ -0,0 +1,44 @@ +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/proto/errors.go b/server/proto/errors.go index cb453a8f0..cc48b6f78 100644 --- a/server/proto/errors.go +++ b/server/proto/errors.go @@ -19,4 +19,6 @@ var ( 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 index ee6ccae29..a4b80db98 100644 --- a/server/proto/repo.go +++ b/server/proto/repo.go @@ -25,6 +25,8 @@ type Repository interface { // 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 @@ -42,3 +44,18 @@ type RepositoryOptions struct { 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/ssh/cmd/branch.go b/server/ssh/cmd/branch.go index 7ed4603db..051bc9c16 100644 --- a/server/ssh/cmd/branch.go +++ b/server/ssh/cmd/branch.go @@ -6,6 +6,8 @@ import ( "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" ) @@ -123,6 +125,15 @@ func branchDefaultCommand() *cobra.Command { }); 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 @@ -175,7 +186,21 @@ func branchDeleteCommand() *cobra.Command { return fmt.Errorf("cannot delete the default branch") } - return r.DeleteBranch(branch, gitm.DeleteBranchOptions{Force: true}) + 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) }, } diff --git a/server/ssh/cmd/cmd.go b/server/ssh/cmd/cmd.go index 08ac8f9b4..d543c3ad1 100644 --- a/server/ssh/cmd/cmd.go +++ b/server/ssh/cmd/cmd.go @@ -137,9 +137,16 @@ func IsPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool { return false } -func checkIfAdmin(cmd *cobra.Command, _ []string) error { +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 @@ -150,11 +157,16 @@ func checkIfAdmin(cmd *cobra.Command, _ []string) error { return proto.ErrUnauthorized } - if !user.IsAdmin() { - return proto.ErrUnauthorized + if user.IsAdmin() { + return nil } - 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 { diff --git a/server/ssh/cmd/repo.go b/server/ssh/cmd/repo.go index 0b5ff3e5d..0579a12ac 100644 --- a/server/ssh/cmd/repo.go +++ b/server/ssh/cmd/repo.go @@ -34,6 +34,7 @@ func RepoCommand() *cobra.Command { renameCommand(), tagCommand(), treeCommand(), + webhookCommand(), ) cmd.AddCommand( diff --git a/server/ssh/cmd/tag.go b/server/ssh/cmd/tag.go index 6b7208762..c15ce09f6 100644 --- a/server/ssh/cmd/tag.go +++ b/server/ssh/cmd/tag.go @@ -3,7 +3,11 @@ 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" ) @@ -72,10 +76,43 @@ func tagDeleteCommand() *cobra.Command { r, err := rr.Open() if err != nil { + log.Errorf("failed to open repo: %s", err) return err } - return r.DeleteTag(args[1]) + 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) }, } diff --git a/server/ssh/cmd/webhooks.go b/server/ssh/cmd/webhooks.go new file mode 100644 index 000000000..53ee877f8 --- /dev/null +++ b/server/ssh/cmd/webhooks.go @@ -0,0 +1,406 @@ +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/store/database/database.go b/server/store/database/database.go index f05ee4fa0..ab6155e8e 100644 --- a/server/store/database/database.go +++ b/server/store/database/database.go @@ -21,6 +21,7 @@ type datastore struct { *collabStore *lfsStore *accessTokenStore + *webhookStore } // New returns a new store.Store database. diff --git a/server/store/database/webhooks.go b/server/store/database/webhooks.go new file mode 100644 index 000000000..abaa25851 --- /dev/null +++ b/server/store/database/webhooks.go @@ -0,0 +1,165 @@ +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/store.go b/server/store/store.go index 7862cb598..41490cbf8 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -8,4 +8,5 @@ type Store interface { SettingStore LFSStore AccessTokenStore + WebhookStore } diff --git a/server/store/webhooks.go b/server/store/webhooks.go new file mode 100644 index 000000000..139753484 --- /dev/null +++ b/server/store/webhooks.go @@ -0,0 +1,48 @@ +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/version/version.go b/server/version/version.go new file mode 100644 index 000000000..22db4c3e5 --- /dev/null +++ b/server/version/version.go @@ -0,0 +1,14 @@ +// 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/goget.go b/server/web/goget.go index 3e56f9db8..ac0e54c69 100644 --- a/server/web/goget.go +++ b/server/web/goget.go @@ -70,6 +70,7 @@ func (g GoGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } if repo == "" || repo == "." || repo == "/" { + renderNotFound(w, r) return } diff --git a/server/webhook/branch_tag.go b/server/webhook/branch_tag.go new file mode 100644 index 000000000..88f42bdde --- /dev/null +++ b/server/webhook/branch_tag.go @@ -0,0 +1,86 @@ +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 new file mode 100644 index 000000000..86f3e86a1 --- /dev/null +++ b/server/webhook/collaborator.go @@ -0,0 +1,83 @@ +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 new file mode 100644 index 000000000..b805c24a8 --- /dev/null +++ b/server/webhook/common.go @@ -0,0 +1,95 @@ +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 new file mode 100644 index 000000000..31731d287 --- /dev/null +++ b/server/webhook/content_type.go @@ -0,0 +1,70 @@ +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 new file mode 100644 index 000000000..09edb9cab --- /dev/null +++ b/server/webhook/event.go @@ -0,0 +1,101 @@ +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 new file mode 100644 index 000000000..4642aeb81 --- /dev/null +++ b/server/webhook/push.go @@ -0,0 +1,117 @@ +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 new file mode 100644 index 000000000..19cb72304 --- /dev/null +++ b/server/webhook/repository.go @@ -0,0 +1,82 @@ +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 new file mode 100644 index 000000000..0429056c2 --- /dev/null +++ b/server/webhook/webhook.go @@ -0,0 +1,144 @@ +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/testdata/http.txtar b/testscript/testdata/http.txtar index 68bec65d5..f2af369af 100644 --- a/testscript/testdata/http.txtar +++ b/testscript/testdata/http.txtar @@ -84,6 +84,10 @@ cmpenv stdout goget.txt curl -XPOST http://localhost:$HTTP_PORT/repo2/subpackage?go-get=1 stdout '404.*' +# go-get not found (invalid repo) +curl -XPOST http://localhost:$HTTP_PORT/repo299/subpackage?go-get=1 +stdout '404.*' + # set private soft repo private repo2 true diff --git a/testscript/testdata/repo-webhooks.txtar b/testscript/testdata/repo-webhooks.txtar new file mode 100644 index 000000000..bee002e5c --- /dev/null +++ b/testscript/testdata/repo-webhooks.txtar @@ -0,0 +1,27 @@ +# vi: set ft=conf + +# create a repo +soft repo create repo-123 +stderr 'Created repository repo-123.*' +stdout ssh://localhost:$SSH_PORT/repo-123.git + +# create webhook +soft repo webhook create repo-123 https://webhook.site/794fa12b-08d4-4362-a0a9-a6f995f22e17 -e branch_tag_create -e branch_tag_delete -e collaborator -e push -e repository -e repository_visibility_change + +# list webhooks +soft repo webhook list repo-123 +stdout '1.*https://webhook.site/794fa12b-08d4-4362-a0a9-a6f995f22e17.*' + +# clone repo +git clone ssh://localhost:$SSH_PORT/repo-123 repo-123 + +# create files +mkfile ./repo-123/README.md 'foobar' +git -C repo-123 add -A +git -C repo-123 commit -m 'first' +git -C repo-123 push origin HEAD + +# list webhook deliveries +# TODO: enable this test when githooks tests are fixed +# soft repo webhook deliver list repo-123 1 +# stdout '.*https://webhook.site/.*'