From ef3424e730b62f6ab2531f5c43989a93fe00b4a2 Mon Sep 17 00:00:00 2001 From: Axenos-dev Date: Sun, 28 Jun 2026 19:15:34 -0700 Subject: [PATCH 1/5] webhook table and queries --- internal/db/gen/models.go | 9 +++ internal/db/gen/webhooks.sql.go | 83 ++++++++++++++++++++++++ internal/db/migrations/0004_webhooks.sql | 22 +++++++ internal/db/queries/webhooks.sql | 14 ++++ 4 files changed, 128 insertions(+) create mode 100644 internal/db/gen/webhooks.sql.go create mode 100644 internal/db/migrations/0004_webhooks.sql create mode 100644 internal/db/queries/webhooks.sql diff --git a/internal/db/gen/models.go b/internal/db/gen/models.go index 430561a..26795b3 100644 --- a/internal/db/gen/models.go +++ b/internal/db/gen/models.go @@ -65,3 +65,12 @@ type User struct { CreatedAtUnixMs int64 UpdatedAtUnixMs sql.NullInt64 } + +type Webhook struct { + ID int64 + RepositoryID int64 + Secret string + Url string + CreatedAtUnixMs int64 + UpdatedAtUnixMs sql.NullInt64 +} diff --git a/internal/db/gen/webhooks.sql.go b/internal/db/gen/webhooks.sql.go new file mode 100644 index 0000000..03b3eb1 --- /dev/null +++ b/internal/db/gen/webhooks.sql.go @@ -0,0 +1,83 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: webhooks.sql + +package gen + +import ( + "context" +) + +const createWebhook = `-- name: CreateWebhook :one +insert into webhooks ( + repository_id, secret, url +) values ( + ?, ?, ? +) returning id, repository_id, secret, url, created_at_unix_ms, updated_at_unix_ms +` + +type CreateWebhookParams struct { + RepositoryID int64 + Secret string + Url string +} + +func (q *Queries) CreateWebhook(ctx context.Context, arg CreateWebhookParams) (Webhook, error) { + row := q.db.QueryRowContext(ctx, createWebhook, arg.RepositoryID, arg.Secret, arg.Url) + var i Webhook + err := row.Scan( + &i.ID, + &i.RepositoryID, + &i.Secret, + &i.Url, + &i.CreatedAtUnixMs, + &i.UpdatedAtUnixMs, + ) + return i, err +} + +const deleteWebhook = `-- name: DeleteWebhook :exec +delete from webhooks +where id=? +` + +func (q *Queries) DeleteWebhook(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteWebhook, id) + return err +} + +const listWebhooksForRepository = `-- name: ListWebhooksForRepository :many +select id, repository_id, secret, url, created_at_unix_ms, updated_at_unix_ms from webhooks +where repository_id=? +` + +func (q *Queries) ListWebhooksForRepository(ctx context.Context, repositoryID int64) ([]Webhook, error) { + rows, err := q.db.QueryContext(ctx, listWebhooksForRepository, repositoryID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Webhook + for rows.Next() { + var i Webhook + if err := rows.Scan( + &i.ID, + &i.RepositoryID, + &i.Secret, + &i.Url, + &i.CreatedAtUnixMs, + &i.UpdatedAtUnixMs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/migrations/0004_webhooks.sql b/internal/db/migrations/0004_webhooks.sql new file mode 100644 index 0000000..db4c52e --- /dev/null +++ b/internal/db/migrations/0004_webhooks.sql @@ -0,0 +1,22 @@ +-- +goose Up +create table if not exists webhooks ( + id integer primary key, + repository_id integer not null, + + secret text not null, + url text not null, + + created_at_unix_ms integer not null default ( + CAST(unixepoch('subsec') * 1000 as integer) + ), + updated_at_unix_ms integer, + + foreign key (repository_id) references repositories(id) on delete cascade +); + +create index if not exists idx_webhooks_repository_id on webhooks(repository_id); + +-- +goose Down +drop index if exists idx_webhooks_repository_id; + +drop table if exists webhooks; diff --git a/internal/db/queries/webhooks.sql b/internal/db/queries/webhooks.sql new file mode 100644 index 0000000..d038d5b --- /dev/null +++ b/internal/db/queries/webhooks.sql @@ -0,0 +1,14 @@ +-- name: CreateWebhook :one +insert into webhooks ( + repository_id, secret, url +) values ( + ?, ?, ? +) returning *; + +-- name: ListWebhooksForRepository :many +select * from webhooks +where repository_id=?; + +-- name: DeleteWebhook :exec +delete from webhooks +where id=?; \ No newline at end of file From 587efff4dc7ea967807502eba56f68785b8dca34 Mon Sep 17 00:00:00 2001 From: Axenos-dev Date: Sun, 28 Jun 2026 21:15:13 -0700 Subject: [PATCH 2/5] webhook service layer --- internal/db/gen/webhooks.sql.go | 11 +- internal/db/queries/webhooks.sql | 2 +- internal/domain/webhooks.go | 17 ++ internal/services/webhooks/errors.go | 7 + internal/services/webhooks/registry.go | 37 ++++ internal/services/webhooks/service.go | 223 +++++++++++++++++++++++++ internal/services/webhooks/types.go | 6 + 7 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 internal/domain/webhooks.go create mode 100644 internal/services/webhooks/errors.go create mode 100644 internal/services/webhooks/registry.go create mode 100644 internal/services/webhooks/service.go create mode 100644 internal/services/webhooks/types.go diff --git a/internal/db/gen/webhooks.sql.go b/internal/db/gen/webhooks.sql.go index 03b3eb1..5cd49d2 100644 --- a/internal/db/gen/webhooks.sql.go +++ b/internal/db/gen/webhooks.sql.go @@ -39,11 +39,16 @@ func (q *Queries) CreateWebhook(ctx context.Context, arg CreateWebhookParams) (W const deleteWebhook = `-- name: DeleteWebhook :exec delete from webhooks -where id=? +where id=? and repository_id=? ` -func (q *Queries) DeleteWebhook(ctx context.Context, id int64) error { - _, err := q.db.ExecContext(ctx, deleteWebhook, id) +type DeleteWebhookParams struct { + ID int64 + RepositoryID int64 +} + +func (q *Queries) DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) error { + _, err := q.db.ExecContext(ctx, deleteWebhook, arg.ID, arg.RepositoryID) return err } diff --git a/internal/db/queries/webhooks.sql b/internal/db/queries/webhooks.sql index d038d5b..649ee80 100644 --- a/internal/db/queries/webhooks.sql +++ b/internal/db/queries/webhooks.sql @@ -11,4 +11,4 @@ where repository_id=?; -- name: DeleteWebhook :exec delete from webhooks -where id=?; \ No newline at end of file +where id=? and repository_id=?; \ No newline at end of file diff --git a/internal/domain/webhooks.go b/internal/domain/webhooks.go new file mode 100644 index 0000000..c855406 --- /dev/null +++ b/internal/domain/webhooks.go @@ -0,0 +1,17 @@ +package domain + +import "time" + +type Webhook struct { + ID int64 + RepositoryID int64 + URL string + Secret string + CreatedAt time.Time + UpdatedAt time.Time +} + +type RepositoryEvent struct { + RepositoryID int64 + Event string +} diff --git a/internal/services/webhooks/errors.go b/internal/services/webhooks/errors.go new file mode 100644 index 0000000..9fe619a --- /dev/null +++ b/internal/services/webhooks/errors.go @@ -0,0 +1,7 @@ +package webhooks + +import "errors" + +var ( + ErrEventsChannelFull = errors.New("events channel buffer is full") +) diff --git a/internal/services/webhooks/registry.go b/internal/services/webhooks/registry.go new file mode 100644 index 0000000..eaef846 --- /dev/null +++ b/internal/services/webhooks/registry.go @@ -0,0 +1,37 @@ +package webhooks + +import ( + "context" + + "github.com/Axenos-dev/HeadlessGit/internal/db" + "github.com/Axenos-dev/HeadlessGit/internal/db/gen" +) + +type WebhooksRegistry struct { + db *db.DB +} + +func NewRegistry(db *db.DB) *WebhooksRegistry { + return &WebhooksRegistry{ + db: db, + } +} + +func (r *WebhooksRegistry) CreateWebhook(ctx context.Context, repoID int64, secret, url string) (gen.Webhook, error) { + return r.db.CreateWebhook(ctx, gen.CreateWebhookParams{ + RepositoryID: repoID, + Secret: secret, + Url: url, + }) +} + +func (r *WebhooksRegistry) ListWebhooksForRepository(ctx context.Context, repoID int64) ([]gen.Webhook, error) { + return r.db.ListWebhooksForRepository(ctx, repoID) +} + +func (r *WebhooksRegistry) DeleteWebhook(ctx context.Context, webhookID, repositoryID int64) error { + return r.db.DeleteWebhook(ctx, gen.DeleteWebhookParams{ + ID: webhookID, + RepositoryID: repositoryID, + }) +} diff --git a/internal/services/webhooks/service.go b/internal/services/webhooks/service.go new file mode 100644 index 0000000..ef12f32 --- /dev/null +++ b/internal/services/webhooks/service.go @@ -0,0 +1,223 @@ +package webhooks + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/Axenos-dev/HeadlessGit/internal/db/gen" + "github.com/Axenos-dev/HeadlessGit/internal/domain" + "go.uber.org/zap" +) + +type Registry interface { + CreateWebhook(ctx context.Context, repoID int64, secret, url string) (gen.Webhook, error) + DeleteWebhook(ctx context.Context, webhookID, repositoryID int64) error + ListWebhooksForRepository(ctx context.Context, repoID int64) ([]gen.Webhook, error) +} + +type Service struct { + registry Registry + logger *zap.Logger + + httpClient *http.Client + eventsCh chan domain.RepositoryEvent +} + +func NewService(logger *zap.Logger, registry Registry) *Service { + return &Service{ + logger: logger, + registry: registry, + eventsCh: make(chan domain.RepositoryEvent, 1024), + httpClient: http.DefaultClient, + } +} + +func (s *Service) RegisterWebhook(ctx context.Context, repoID int64, url string) (domain.Webhook, error) { + secret, err := generateSecret() + if err != nil { + return domain.Webhook{}, err + } + + webhook, err := s.registry.CreateWebhook(ctx, repoID, secret, url) + if err != nil { + return domain.Webhook{}, err + } + + return toDomain(webhook), nil +} + +func (s *Service) DeleteWebhook(ctx context.Context, webhookID, repositoryID int64) error { + return s.registry.DeleteWebhook(ctx, webhookID, repositoryID) +} + +func (s *Service) DispatchEvent(ctx context.Context, event domain.RepositoryEvent) error { + select { + case s.eventsCh <- event: + return nil + default: + return ErrEventsChannelFull + } +} + +func (s *Service) Start(ctx context.Context, nWorkers int) { + for range nWorkers { + go s.handleEvents(ctx) + } +} + +func (s *Service) handleEvents(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case event := <-s.eventsCh: + if err := s.handleEvent(ctx, event); err != nil { + s.logger.Error("failed to handle event", zap.Any("event", event), zap.Error(err)) + } + } + } +} + +func (s *Service) handleEvent(ctx context.Context, event domain.RepositoryEvent) error { + webhooks, err := s.registry.ListWebhooksForRepository(ctx, event.RepositoryID) + if err != nil { + return err + } + + for _, wh := range webhooks { + func() { + webhook := toDomain(wh) + + // send webhooks with retry (exponential backoff) + err := withExponentialBackoff(ctx, 3, time.Second*2, func() error { + // 15 second timeout on webhook request + timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + return s.sendWebhook(timeoutCtx, webhook, event) + }) + if err != nil { + s.logger.Warn( + "failed to send webhook", + zap.Any("webhookId", webhook.ID), + zap.Any("webhookUrl", webhook.URL), + zap.Any("event", event), + zap.Error(err), + ) + } + }() + } + + return nil +} + +func (s *Service) sendWebhook(ctx context.Context, webhook domain.Webhook, event domain.RepositoryEvent) error { + body, err := json.Marshal(WebhookPayload{ + Event: event.Event, + RepositoryID: event.RepositoryID, + }) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhook.URL, bytes.NewReader(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "headlessgit") + req.Header.Set("X-HeadlessGit-Delivery", deliveryID()) + // HMAC-SHA256 of the raw body, keyed by the webhook secret + // receivers recompute it to verify the delivery is authentic + req.Header.Set("X-HeadlessGit-Signature", "sha256="+sign(webhook.Secret, body)) + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + + if resp.StatusCode >= 400 { + s.logger.Warn( + "webhook endpoint returned error", + zap.Int64("webhookId", webhook.ID), + zap.Int("statusCode", resp.StatusCode), + zap.String("respBody", string(respBody)), + ) + return fmt.Errorf("webhook endpoint returned status %d", resp.StatusCode) + } + + return nil +} + +// sign returns the hex-encoded HMAC-SHA256 of body keyed by secret +func sign(secret string, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +// deliveryID returns a random correlation id +func deliveryID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "" + } + return hex.EncodeToString(b) +} + +func withExponentialBackoff(ctx context.Context, attempts int, base time.Duration, fn func() error) error { + var err error + for attempt := range attempts { + if attempt > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(base << (attempt - 1)): + } + } + + if err = fn(); err == nil { + return nil + } + } + return err +} + +// random 32 bytes +func generateSecret() (raw string, err error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + raw = base64.RawURLEncoding.EncodeToString(b) + return raw, nil +} + +func toDomain(webhook gen.Webhook) domain.Webhook { + w := domain.Webhook{ + ID: webhook.ID, + RepositoryID: webhook.RepositoryID, + URL: webhook.Url, + Secret: webhook.Secret, + CreatedAt: time.UnixMilli(webhook.CreatedAtUnixMs).UTC(), + } + if webhook.UpdatedAtUnixMs.Valid { + w.UpdatedAt = time.UnixMilli(webhook.UpdatedAtUnixMs.Int64).UTC() + } + + return w +} diff --git a/internal/services/webhooks/types.go b/internal/services/webhooks/types.go new file mode 100644 index 0000000..100af72 --- /dev/null +++ b/internal/services/webhooks/types.go @@ -0,0 +1,6 @@ +package webhooks + +type WebhookPayload struct { + Event string `json:"event"` + RepositoryID int64 `json:"repository_id"` +} From 9a68e0cf8cdbfc8c6d19885ef81d1a6394fe3e01 Mon Sep 17 00:00:00 2001 From: Axenos-dev Date: Mon, 29 Jun 2026 11:37:22 -0700 Subject: [PATCH 3/5] dispatch webhook events on upload-pack --- cmd/app/main.go | 9 +- internal/domain/webhooks.go | 5 + internal/gitbackend/backend.go | 15 +++ internal/gitbackend/local.go | 113 +++++++++++++++++- internal/gitbackend/local_test.go | 62 ++++++++++ internal/gitbackend/types.go | 12 ++ internal/server/control/server.go | 26 ++-- internal/server/git/git.go | 6 +- internal/server/git/githttp/server.go | 31 +++-- internal/server/git/githttp/smart/handlers.go | 62 ++++++++-- internal/server/git/gitssh/server.go | 49 ++++++-- internal/server/server.go | 29 +++-- internal/services/lfs/service.go | 27 ++--- internal/services/webhooks/service.go | 4 + internal/services/webhooks/types.go | 4 + internal/storage/disk.go | 9 +- internal/storage/s3.go | 7 +- internal/storage/storage.go | 24 ++++ 18 files changed, 413 insertions(+), 81 deletions(-) create mode 100644 internal/gitbackend/backend.go create mode 100644 internal/gitbackend/types.go create mode 100644 internal/storage/storage.go diff --git a/cmd/app/main.go b/cmd/app/main.go index 7dca711..3ba8f65 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -17,6 +17,7 @@ import ( "github.com/Axenos-dev/HeadlessGit/internal/services/permissions" "github.com/Axenos-dev/HeadlessGit/internal/services/repositories" "github.com/Axenos-dev/HeadlessGit/internal/services/users" + "github.com/Axenos-dev/HeadlessGit/internal/services/webhooks" "github.com/Axenos-dev/HeadlessGit/internal/storage" "go.uber.org/zap" ) @@ -92,6 +93,11 @@ func main() { ) } + webhooksService := webhooks.NewService( + root.With(zap.String("service", "webhooks")), + webhooks.NewRegistry(db), + ) + ctx, stop := signal.NotifyContext( context.Background(), syscall.SIGINT, @@ -104,6 +110,7 @@ func main() { Users: usersService, Authentication: authService, Authorization: permsService, + Webhooks: webhooksService, GitBackend: gitBackend, LFS: lfsService, DB: db, @@ -113,7 +120,7 @@ func main() { } } -func newLFSStorage(cfg config.LFSConfig) (lfs.ObjectStorage, error) { +func newLFSStorage(cfg config.LFSConfig) (storage.Storage, error) { switch cfg.StorageType { case "disk": return storage.NewDisk(cfg.Root) diff --git a/internal/domain/webhooks.go b/internal/domain/webhooks.go index c855406..4f006e1 100644 --- a/internal/domain/webhooks.go +++ b/internal/domain/webhooks.go @@ -13,5 +13,10 @@ type Webhook struct { type RepositoryEvent struct { RepositoryID int64 + PusherID int64 Event string + + Ref string + OldSHA string + NewSHA string } diff --git a/internal/gitbackend/backend.go b/internal/gitbackend/backend.go new file mode 100644 index 0000000..e7b6e7e --- /dev/null +++ b/internal/gitbackend/backend.go @@ -0,0 +1,15 @@ +package gitbackend + +import ( + "context" + "io" +) + +// git backend is a polymorphic thing, +// it can be either local (we store bare repos on a disk), +// or the repos itself we store on storage nodes (coming soon) +type Backend interface { + AdvertiseRefs(ctx context.Context, storagePath string, svc Service, stdout io.Writer) error + UploadPack(ctx context.Context, storagePath string, stateless bool, stdin io.Reader, stdout, stderr io.Writer) error + ReceivePack(ctx context.Context, storagePath string, stateless bool, stdin io.Reader, stdout, stderr io.Writer) ([]RefChange, error) +} diff --git a/internal/gitbackend/local.go b/internal/gitbackend/local.go index bf0fb74..d46dbb8 100644 --- a/internal/gitbackend/local.go +++ b/internal/gitbackend/local.go @@ -1,6 +1,7 @@ package gitbackend import ( + "bufio" "bytes" "context" "fmt" @@ -9,9 +10,13 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "time" ) +// Local implementation of git backend +var _ Backend = (*Local)(nil) + type Service int const ( @@ -33,6 +38,9 @@ type Local struct { root string gitPath string timeout time.Duration + + mu sync.Mutex + locks map[string]*sync.Mutex } func NewLocal(root string) (*Local, error) { @@ -50,6 +58,7 @@ func NewLocal(root string) (*Local, error) { root: absRoot, gitPath: gitPath, timeout: 30 * time.Second, + locks: make(map[string]*sync.Mutex), }, nil } @@ -102,7 +111,39 @@ func (l *Local) AdvertiseRefs(ctx context.Context, storagePath string, svc Servi return nil } -func (l *Local) Pack(ctx context.Context, storagePath string, svc Service, stateless bool, stdin io.Reader, stdout, stderr io.Writer) error { +func (l *Local) UploadPack(ctx context.Context, storagePath string, stateless bool, stdin io.Reader, stdout, stderr io.Writer) error { + return l.pack(ctx, storagePath, UploadPack, stateless, stdin, stdout, stderr) +} + +func (l *Local) ReceivePack(ctx context.Context, storagePath string, stateless bool, stdin io.Reader, stdout, stderr io.Writer) ([]RefChange, error) { + // important to lock concurrent pushes + // as we compare refs before/after for one operation + unlock := l.lockRepo(storagePath) + defer unlock() + + // list refs BEFORE the push + before, beforeErr := l.listRefs(ctx, storagePath) + // and IGNORE error, as we dont need to block main receive-pack operation + + if err := l.pack(ctx, storagePath, ReceivePack, stateless, stdin, stdout, stderr); err != nil { + return nil, err + } + + // and check the before refs error after successful push + if beforeErr != nil { + return nil, nil + } + + // and then list refs AFTER the successful push + after, err := l.listRefs(ctx, storagePath) + if err != nil { + return nil, nil + } + + return DiffRefs(before, after), nil +} + +func (l *Local) pack(ctx context.Context, storagePath string, svc Service, stateless bool, stdin io.Reader, stdout, stderr io.Writer) error { dir, err := l.resolve(storagePath) if err != nil { return err @@ -126,6 +167,76 @@ func (l *Local) Pack(ctx context.Context, storagePath string, svc Service, state return cmd.Run() } +// listRefs returns the repo refs as a refname -> object id map +func (l *Local) listRefs(ctx context.Context, storagePath string) (map[string]string, error) { + dir, err := l.resolve(storagePath) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(ctx, l.timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, l.gitPath, "-C", dir, "for-each-ref", "--format=%(objectname) %(refname)") + var out, stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("for-each-ref: %w: %s", err, strings.TrimSpace(stderr.String())) + } + + refs := make(map[string]string) + sc := bufio.NewScanner(&out) + for sc.Scan() { + // cut the string by " ", to separate sha from ref + sha, ref, ok := strings.Cut(sc.Text(), " ") + if !ok { + continue + } + refs[ref] = sha + } + return refs, sc.Err() +} + +func (l *Local) lockRepo(storagePath string) func() { + l.mu.Lock() + m, ok := l.locks[storagePath] + if !ok { + m = &sync.Mutex{} + l.locks[storagePath] = m + } + l.mu.Unlock() + + m.Lock() + return m.Unlock +} + +// compares before and after refs, and returns structured list of RefChange, +// public, so its testable +func DiffRefs(before, after map[string]string) []RefChange { + var changes []RefChange + + // compare before refs with after + for ref, oldSHA := range before { + switch newSHA, ok := after[ref]; { + case !ok: // if after is missing -> it was deleted, and new sha = 0 + changes = append(changes, RefChange{Ref: ref, OldSHA: oldSHA, NewSHA: zeroSHA}) + case newSHA != oldSHA: // if after is different -> it was just updated + changes = append(changes, RefChange{Ref: ref, OldSHA: oldSHA, NewSHA: newSHA}) + } + } + + // compare after refs with before + for ref, newSHA := range after { + // if before is missing -> ref was created, and old sha = 0 + if _, ok := before[ref]; !ok { + changes = append(changes, RefChange{Ref: ref, OldSHA: zeroSHA, NewSHA: newSHA}) + } + } + + return changes +} + // resolve maps a stored relative path to an absolute dir under the root // refuses anything that escapes it func (l *Local) resolve(storagePath string) (string, error) { diff --git a/internal/gitbackend/local_test.go b/internal/gitbackend/local_test.go index 5cf60fb..2461b76 100644 --- a/internal/gitbackend/local_test.go +++ b/internal/gitbackend/local_test.go @@ -2,6 +2,7 @@ package gitbackend import ( "path/filepath" + "sort" "strings" "testing" ) @@ -38,3 +39,64 @@ func TestResolveContainment(t *testing.T) { }) } } + +func TestDiffRefs(t *testing.T) { + cases := []struct { + name string + before, after map[string]string + want []RefChange + }{ + { + name: "no change", + before: map[string]string{"refs/heads/main": "aaa"}, + after: map[string]string{"refs/heads/main": "aaa"}, + want: nil, + }, + { + name: "update", + before: map[string]string{"refs/heads/main": "aaa"}, + after: map[string]string{"refs/heads/main": "bbb"}, + want: []RefChange{{Ref: "refs/heads/main", OldSHA: "aaa", NewSHA: "bbb"}}, + }, + { + name: "create", + before: map[string]string{}, + after: map[string]string{"refs/heads/dev": "ccc"}, + want: []RefChange{{Ref: "refs/heads/dev", OldSHA: zeroSHA, NewSHA: "ccc"}}, + }, + { + name: "delete", + before: map[string]string{"refs/tags/v1": "ddd"}, + after: map[string]string{}, + want: []RefChange{{Ref: "refs/tags/v1", OldSHA: "ddd", NewSHA: zeroSHA}}, + }, + { + name: "mixed", + before: map[string]string{"refs/heads/main": "aaa", "refs/tags/v1": "ddd"}, + after: map[string]string{"refs/heads/main": "bbb", "refs/heads/dev": "ccc"}, + want: []RefChange{ + {Ref: "refs/heads/dev", OldSHA: zeroSHA, NewSHA: "ccc"}, + {Ref: "refs/heads/main", OldSHA: "aaa", NewSHA: "bbb"}, + {Ref: "refs/tags/v1", OldSHA: "ddd", NewSHA: zeroSHA}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := DiffRefs(tc.before, tc.after) + + // map iteration order is unstable, so sort before comparing + sort.Slice(got, func(i, j int) bool { return got[i].Ref < got[j].Ref }) + + if len(got) != len(tc.want) { + t.Fatalf("got %d changes, want %d: %+v", len(got), len(tc.want), got) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("change %d = %+v, want %+v", i, got[i], tc.want[i]) + } + } + }) + } +} diff --git a/internal/gitbackend/types.go b/internal/gitbackend/types.go new file mode 100644 index 0000000..8479546 --- /dev/null +++ b/internal/gitbackend/types.go @@ -0,0 +1,12 @@ +package gitbackend + +// the all-zero object id git uses to denote a missing ref +// its zero in before, if it was created after +// or its zero after, if it was deleted before +const zeroSHA = "0000000000000000000000000000000000000000" + +type RefChange struct { + Ref string + OldSHA string + NewSHA string +} diff --git a/internal/server/control/server.go b/internal/server/control/server.go index 61ff2ca..7e012df 100644 --- a/internal/server/control/server.go +++ b/internal/server/control/server.go @@ -14,6 +14,7 @@ import ( permsservice "github.com/Axenos-dev/HeadlessGit/internal/services/permissions" reposervice "github.com/Axenos-dev/HeadlessGit/internal/services/repositories" usersservice "github.com/Axenos-dev/HeadlessGit/internal/services/users" + webhooksservice "github.com/Axenos-dev/HeadlessGit/internal/services/webhooks" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "go.uber.org/zap" @@ -29,27 +30,30 @@ type Services struct { Authentication *authservice.Service Authorization *permsservice.Service Users *usersservice.Service + Webhooks *webhooksservice.Service Health HealthChecker } type Server struct { logger *zap.Logger - repos *reposervice.Service - users *usersservice.Service - auth *authservice.Service - perms *permsservice.Service - health HealthChecker + repos *reposervice.Service + users *usersservice.Service + auth *authservice.Service + perms *permsservice.Service + webhooks *webhooksservice.Service + health HealthChecker } func NewServer(logger *zap.Logger, svc Services) *Server { return &Server{ - logger: logger, - repos: svc.Repositories, - users: svc.Users, - auth: svc.Authentication, - perms: svc.Authorization, - health: svc.Health, + logger: logger, + repos: svc.Repositories, + users: svc.Users, + auth: svc.Authentication, + perms: svc.Authorization, + webhooks: svc.Webhooks, + health: svc.Health, } } diff --git a/internal/server/git/git.go b/internal/server/git/git.go index 1dd9f8e..3ba3ace 100644 --- a/internal/server/git/git.go +++ b/internal/server/git/git.go @@ -10,6 +10,7 @@ import ( lfsservice "github.com/Axenos-dev/HeadlessGit/internal/services/lfs" permsservice "github.com/Axenos-dev/HeadlessGit/internal/services/permissions" reposervice "github.com/Axenos-dev/HeadlessGit/internal/services/repositories" + webhooksservice "github.com/Axenos-dev/HeadlessGit/internal/services/webhooks" "go.uber.org/zap" ) @@ -17,8 +18,9 @@ type Services struct { Repositories *reposervice.Service Authentication *authservice.Service Authorization *permsservice.Service - Backend *gitbackend.Local + Backend gitbackend.Backend LFS *lfsservice.Service + Webhooks *webhooksservice.Service } type Server struct { @@ -44,6 +46,7 @@ func NewServer(logger *zap.Logger, hostKeyPath string, svc Services) *Server { Authorization: svc.Authorization, Backend: svc.Backend, LFS: svc.LFS, + Dispatcher: svc.Webhooks, }), ssh: gitssh.NewServer(logger.With(zap.String("transport", "ssh")), hostKeyPath, gitssh.Services{ Backend: svc.Backend, @@ -52,6 +55,7 @@ func NewServer(logger *zap.Logger, hostKeyPath string, svc Services) *Server { Minter: svc.Authentication, Authorization: svc.Authorization, LFS: lfsEndpoints, + Dispatcher: svc.Webhooks, }), } } diff --git a/internal/server/git/githttp/server.go b/internal/server/git/githttp/server.go index bbac28b..149c5bc 100644 --- a/internal/server/git/githttp/server.go +++ b/internal/server/git/githttp/server.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "github.com/Axenos-dev/HeadlessGit/internal/gitbackend" "github.com/Axenos-dev/HeadlessGit/internal/server/audit" "github.com/Axenos-dev/HeadlessGit/internal/server/git/githttp/lfs" "github.com/Axenos-dev/HeadlessGit/internal/server/git/githttp/middleware" @@ -14,6 +15,7 @@ import ( lfsservice "github.com/Axenos-dev/HeadlessGit/internal/services/lfs" permsservice "github.com/Axenos-dev/HeadlessGit/internal/services/permissions" reposervice "github.com/Axenos-dev/HeadlessGit/internal/services/repositories" + webhooksservice "github.com/Axenos-dev/HeadlessGit/internal/services/webhooks" "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" "go.uber.org/zap" @@ -23,29 +25,32 @@ type Services struct { Repositories *reposervice.Service Authentication *authservice.Service Authorization *permsservice.Service - Backend smart.GitBackend + Backend gitbackend.Backend LFS *lfsservice.Service + Dispatcher *webhooksservice.Service } type Server struct { logger *zap.Logger - backend smart.GitBackend + backend gitbackend.Backend - repos *reposervice.Service - auth *authservice.Service - perms *permsservice.Service - lfs *lfsservice.Service // nil if disabled + dispatcher *webhooksservice.Service + repos *reposervice.Service + auth *authservice.Service + perms *permsservice.Service + lfs *lfsservice.Service // nil if disabled } func NewServer(logger *zap.Logger, svc Services) *Server { return &Server{ - logger: logger, - auth: svc.Authentication, - backend: svc.Backend, - repos: svc.Repositories, - perms: svc.Authorization, - lfs: svc.LFS, + logger: logger, + auth: svc.Authentication, + backend: svc.Backend, + dispatcher: svc.Dispatcher, + repos: svc.Repositories, + perms: svc.Authorization, + lfs: svc.LFS, } } @@ -83,7 +88,7 @@ func (s *Server) Handler() http.Handler { } func (s *Server) registerRoutes(r chi.Router) { - smart.NewHandlers(s.logger, s.backend, s.repos, s.perms).RegisterRoutes(r) + smart.NewHandlers(s.logger, s.backend, s.repos, s.perms, s.dispatcher).RegisterRoutes(r) // register LFS handlers if lfs service is provided if s.lfs != nil { diff --git a/internal/server/git/githttp/smart/handlers.go b/internal/server/git/githttp/smart/handlers.go index 1fd1fe5..f6ada11 100644 --- a/internal/server/git/githttp/smart/handlers.go +++ b/internal/server/git/githttp/smart/handlers.go @@ -26,23 +26,30 @@ type Authorizer interface { type GitBackend interface { AdvertiseRefs(ctx context.Context, storagePath string, svc gitbackend.Service, stdout io.Writer) error - Pack(ctx context.Context, storagePath string, svc gitbackend.Service, stateless bool, stdin io.Reader, stdout, stderr io.Writer) error + UploadPack(ctx context.Context, storagePath string, stateless bool, stdin io.Reader, stdout, stderr io.Writer) error + ReceivePack(ctx context.Context, storagePath string, stateless bool, stdin io.Reader, stdout, stderr io.Writer) ([]gitbackend.RefChange, error) +} + +type Dispatcher interface { + DispatchEvent(ctx context.Context, event domain.RepositoryEvent) error } type Handlers struct { logger *zap.Logger - backend GitBackend - resolver RepositoryResolver - authz Authorizer + backend GitBackend + resolver RepositoryResolver + authz Authorizer + dispatcher Dispatcher } -func NewHandlers(logger *zap.Logger, backend GitBackend, resolver RepositoryResolver, authz Authorizer) *Handlers { +func NewHandlers(logger *zap.Logger, backend GitBackend, resolver RepositoryResolver, authz Authorizer, dispatcher Dispatcher) *Handlers { return &Handlers{ - logger: logger, - backend: backend, - resolver: resolver, - authz: authz, + logger: logger, + backend: backend, + resolver: resolver, + authz: authz, + dispatcher: dispatcher, } } @@ -145,7 +152,17 @@ func (h *Handlers) pack(svc gitbackend.Service) http.HandlerFunc { w.Header().Set("Cache-Control", "no-cache") var stderr strings.Builder - if err := h.backend.Pack(r.Context(), repo.StoragePath, svc, true, body, w, &stderr); err != nil { + switch svc { + case gitbackend.ReceivePack: + var changes []gitbackend.RefChange + changes, err = h.backend.ReceivePack(r.Context(), repo.StoragePath, true, body, w, &stderr) + if err == nil { + h.dispatchPush(r.Context(), repo.ID, changes) + } + case gitbackend.UploadPack: + err = h.backend.UploadPack(r.Context(), repo.StoragePath, true, body, w, &stderr) + } + if err != nil { h.logger.Warn("git pack failed", zap.String("service", svc.Name()), zap.String("stderr", strings.TrimSpace(stderr.String())), @@ -155,6 +172,31 @@ func (h *Handlers) pack(svc gitbackend.Service) http.HandlerFunc { } } +func (h *Handlers) dispatchPush(ctx context.Context, repoID int64, changes []gitbackend.RefChange) { + if h.dispatcher == nil { + return + } + + var pusherID int64 + if account := middleware.AccountFromContext(ctx); account != nil { + pusherID = account.UserID + } + + for _, c := range changes { + err := h.dispatcher.DispatchEvent(ctx, domain.RepositoryEvent{ + RepositoryID: repoID, + Event: "push", + Ref: c.Ref, + OldSHA: c.OldSHA, + NewSHA: c.NewSHA, + PusherID: pusherID, + }) + if err != nil { + h.logger.Warn("failed to enqueue webhook event", zap.String("ref", c.Ref), zap.Error(err)) + } + } +} + // decompressing request body if client sent gzip-encoded // (git does this for larger requests) func requestBody(r *http.Request) (io.ReadCloser, error) { diff --git a/internal/server/git/gitssh/server.go b/internal/server/git/gitssh/server.go index ef45ce6..3af02e8 100644 --- a/internal/server/git/gitssh/server.go +++ b/internal/server/git/gitssh/server.go @@ -30,7 +30,12 @@ import ( const lfsTokenTTL = 15 * time.Minute type GitBackend interface { - Pack(ctx context.Context, storagePath string, svc gitbackend.Service, stateless bool, stdin io.Reader, stdout, stderr io.Writer) error + UploadPack(ctx context.Context, storagePath string, stateless bool, stdin io.Reader, stdout, stderr io.Writer) error + ReceivePack(ctx context.Context, storagePath string, stateless bool, stdin io.Reader, stdout, stderr io.Writer) ([]gitbackend.RefChange, error) +} + +type Dispatcher interface { + DispatchEvent(ctx context.Context, event domain.RepositoryEvent) error } type RepositoryResolver interface { @@ -60,18 +65,20 @@ type Services struct { Authorization Authorizer Minter TokenMinter LFS LFSEndpoints + Dispatcher Dispatcher } type Server struct { logger *zap.Logger hostKeyPath string - backend GitBackend - resolver RepositoryResolver - auth Authenticator - authz Authorizer - minter TokenMinter - lfs LFSEndpoints // nil if LFS is disabled + backend GitBackend + resolver RepositoryResolver + auth Authenticator + authz Authorizer + minter TokenMinter + lfs LFSEndpoints // nil if LFS is disabled + dispatcher Dispatcher } func NewServer(logger *zap.Logger, hostKeyPath string, svc Services) *Server { @@ -84,6 +91,7 @@ func NewServer(logger *zap.Logger, hostKeyPath string, svc Services) *Server { authz: svc.Authorization, minter: svc.Minter, lfs: svc.LFS, + dispatcher: svc.Dispatcher, } } @@ -269,9 +277,13 @@ func (s *Server) runGit(ctx context.Context, account domain.Account, ch ssh.Chan switch subcommand { case "git-upload-pack": - err = s.backend.Pack(ctx, resolved.StoragePath, gitbackend.UploadPack, false, ch, ch, ch.Stderr()) + err = s.backend.UploadPack(ctx, resolved.StoragePath, false, ch, ch, ch.Stderr()) case "git-receive-pack": - err = s.backend.Pack(ctx, resolved.StoragePath, gitbackend.ReceivePack, false, ch, ch, ch.Stderr()) + var changes []gitbackend.RefChange + changes, err = s.backend.ReceivePack(ctx, resolved.StoragePath, false, ch, ch, ch.Stderr()) + if err == nil { + s.dispatchPush(ctx, resolved.ID, account.UserID, changes) + } } if err != nil { s.logger.Warn("git command failed", zap.String("command", command), zap.Error(err)) @@ -283,6 +295,25 @@ func (s *Server) runGit(ctx context.Context, account domain.Account, ch ssh.Chan sendExit(ch, 0) } +func (s *Server) dispatchPush(ctx context.Context, repoID, pusherID int64, changes []gitbackend.RefChange) { + if s.dispatcher == nil { + return + } + for _, c := range changes { + err := s.dispatcher.DispatchEvent(ctx, domain.RepositoryEvent{ + RepositoryID: repoID, + Event: "push", + Ref: c.Ref, + OldSHA: c.OldSHA, + NewSHA: c.NewSHA, + PusherID: pusherID, + }) + if err != nil { + s.logger.Warn("failed to enqueue webhook event", zap.String("ref", c.Ref), zap.Error(err)) + } + } +} + func (s *Server) runLFSAuthenticate(ctx context.Context, account domain.Account, ch ssh.Channel, command string) { start := time.Now() e := &audit.Event{ diff --git a/internal/server/server.go b/internal/server/server.go index 6ea8c94..d1d0279 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -15,15 +15,18 @@ import ( permsservice "github.com/Axenos-dev/HeadlessGit/internal/services/permissions" reposervice "github.com/Axenos-dev/HeadlessGit/internal/services/repositories" usersservice "github.com/Axenos-dev/HeadlessGit/internal/services/users" + webhooksservice "github.com/Axenos-dev/HeadlessGit/internal/services/webhooks" "go.uber.org/zap" ) type Services struct { + GitBackend gitbackend.Backend + Repositories *reposervice.Service Users *usersservice.Service Authentication *authservice.Service Authorization *permsservice.Service - GitBackend *gitbackend.Local + Webhooks *webhooksservice.Service LFS *lfsservice.Service DB *db.DB } @@ -31,11 +34,16 @@ type Services struct { // clean up expired tokens every hour const tokenGCInterval = time.Hour +// number of workers which going to handle webhooks +const webhookWorkers = 3 + type server struct { - cfg config.ServerConfig + cfg config.ServerConfig + logger *zap.Logger + + auth *authservice.Service + webhooks *webhooksservice.Service - logger *zap.Logger - auth *authservice.Service control *control.Server git *git.Server } @@ -46,14 +54,16 @@ func NewServer( svc Services, ) *server { return &server{ - cfg: cfg, - logger: logger, - auth: svc.Authentication, + cfg: cfg, + logger: logger, + auth: svc.Authentication, + webhooks: svc.Webhooks, control: control.NewServer(logger.With(zap.String("component", "control")), control.Services{ Repositories: svc.Repositories, Authentication: svc.Authentication, Authorization: svc.Authorization, Users: svc.Users, + Webhooks: svc.Webhooks, Health: svc.DB, }), git: git.NewServer(logger.With(zap.String("component", "git")), cfg.HostKeyPath, git.Services{ @@ -62,6 +72,7 @@ func NewServer( Authorization: svc.Authorization, Backend: svc.GitBackend, LFS: svc.LFS, + Webhooks: svc.Webhooks, }), } } @@ -72,6 +83,10 @@ func (s *server) Run(ctx context.Context) error { // clean up expired tokens go s.auth.RunExpiredTokenGC(ctx, tokenGCInterval) + // handle webhooks + // it runs N goroutines for us, so dont Start in goroutine + s.webhooks.Start(ctx, webhookWorkers) + go func() { errCh <- s.control.Run(ctx, fmt.Sprintf(":%d", s.cfg.ControlPort)) }() diff --git a/internal/services/lfs/service.go b/internal/services/lfs/service.go index 6e8ac95..acce6b7 100644 --- a/internal/services/lfs/service.go +++ b/internal/services/lfs/service.go @@ -13,6 +13,7 @@ import ( "github.com/Axenos-dev/HeadlessGit/internal/db/gen" "github.com/Axenos-dev/HeadlessGit/internal/domain" + "github.com/Axenos-dev/HeadlessGit/internal/storage" "go.uber.org/zap" ) @@ -25,28 +26,20 @@ type Registry interface { DeleteLFSObject(ctx context.Context, repositoryID int64, objectID string) error SetLFSObjectVerified(ctx context.Context, repositoryID int64, objectID string, verified bool) (gen.LfsObject, error) } - -type ObjectStorage interface { - Stat(ctx context.Context, key string) (exists bool, size int64, err error) - Get(ctx context.Context, key string) (io.ReadCloser, error) - Put(ctx context.Context, key string, size int64, r io.Reader) error - Delete(ctx context.Context, key string) error -} - -type Presigner interface { - PresignPut(ctx context.Context, key string, size int64, ttl time.Duration) (url string, header map[string]string, err error) - PresignGet(ctx context.Context, key string, ttl time.Duration) (url string, err error) -} - type Service struct { logger *zap.Logger registry Registry - storage ObjectStorage + + storage storage.Storage + // both disk and S3 implements basic object storage, + // but S3, transparently able to to implement the presigner, + // so to check if it does, we just do: + // pre, ok := storage.(storage.Presigner) publicURL string } -func NewService(logger *zap.Logger, registry Registry, storage ObjectStorage, publicURL string) *Service { +func NewService(logger *zap.Logger, registry Registry, storage storage.Storage, publicURL string) *Service { return &Service{ logger: logger, registry: registry, @@ -153,7 +146,7 @@ func (s *Service) uploadActions(ctx context.Context, repo domain.Repository, lfs verify := domain.LFSAction{Href: lfsBase + "/verify"} // if we have presigner - if pre, ok := s.storage.(Presigner); ok { + if pre, ok := s.storage.(storage.Presigner); ok { url, header, err := pre.PresignPut(ctx, objectKey(repo.ID, p.OID), p.Size, presignTTL) if err != nil { return nil, err @@ -173,7 +166,7 @@ func (s *Service) uploadActions(ctx context.Context, repo domain.Repository, lfs } func (s *Service) downloadAction(ctx context.Context, repo domain.Repository, lfsBase, oid string) (domain.LFSAction, error) { - if pre, ok := s.storage.(Presigner); ok { + if pre, ok := s.storage.(storage.Presigner); ok { url, err := pre.PresignGet(ctx, objectKey(repo.ID, oid), presignTTL) if err != nil { return domain.LFSAction{}, err diff --git a/internal/services/webhooks/service.go b/internal/services/webhooks/service.go index ef12f32..3343334 100644 --- a/internal/services/webhooks/service.go +++ b/internal/services/webhooks/service.go @@ -125,6 +125,10 @@ func (s *Service) sendWebhook(ctx context.Context, webhook domain.Webhook, event body, err := json.Marshal(WebhookPayload{ Event: event.Event, RepositoryID: event.RepositoryID, + Ref: event.Ref, + OldSHA: event.OldSHA, + NewSHA: event.NewSHA, + PusherID: event.PusherID, }) if err != nil { return err diff --git a/internal/services/webhooks/types.go b/internal/services/webhooks/types.go index 100af72..7a4eef1 100644 --- a/internal/services/webhooks/types.go +++ b/internal/services/webhooks/types.go @@ -3,4 +3,8 @@ package webhooks type WebhookPayload struct { Event string `json:"event"` RepositoryID int64 `json:"repository_id"` + Ref string `json:"ref"` + OldSHA string `json:"old_sha"` + NewSHA string `json:"new_sha"` + PusherID int64 `json:"pusher_id"` } diff --git a/internal/storage/disk.go b/internal/storage/disk.go index ebb1b05..93c2138 100644 --- a/internal/storage/disk.go +++ b/internal/storage/disk.go @@ -9,16 +9,11 @@ import ( "os" "path/filepath" "strings" - - "github.com/Axenos-dev/HeadlessGit/internal/services/lfs" ) -var ( - _ lfs.ObjectStorage = (*Disk)(nil) -) +var _ Storage = (*Disk)(nil) -// filesystem-backed object store -// implements the services/lfs ObjectStorage interface +// filesystem-backed object store, implements Storage type Disk struct { root string } diff --git a/internal/storage/s3.go b/internal/storage/s3.go index 715a673..1011442 100644 --- a/internal/storage/s3.go +++ b/internal/storage/s3.go @@ -10,17 +10,16 @@ import ( "time" "github.com/Axenos-dev/HeadlessGit/internal/config" - "github.com/Axenos-dev/HeadlessGit/internal/services/lfs" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) var ( - _ lfs.ObjectStorage = (*S3)(nil) - _ lfs.Presigner = (*S3)(nil) + _ Storage = (*S3)(nil) + _ Presigner = (*S3)(nil) ) -// implements both the services/lfs ObjectStorage and Presigner interfaces +// implements the object Storage contract plus the optional Presigner capability type S3 struct { client *minio.Client bucket string diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..44fa223 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,24 @@ +package storage + +import ( + "context" + "io" + "time" +) + +// storage is a polymorphic thing +// it can either disk (store objects locally) +// or S3-compatible bucket +type Storage interface { + Stat(ctx context.Context, key string) (exists bool, size int64, err error) + Get(ctx context.Context, key string) (io.ReadCloser, error) + Put(ctx context.Context, key string, size int64, r io.Reader) error + Delete(ctx context.Context, key string) error +} + +// presigner is more an optional capability +// something like local disk, does not implement this +type Presigner interface { + PresignPut(ctx context.Context, key string, size int64, ttl time.Duration) (url string, header map[string]string, err error) + PresignGet(ctx context.Context, key string, ttl time.Duration) (url string, err error) +} From e89a2fdecfcbdfec2c150e363e2aca5c7f2e9457 Mon Sep 17 00:00:00 2001 From: Axenos-dev Date: Mon, 29 Jun 2026 11:56:54 -0700 Subject: [PATCH 4/5] webhooks control api --- AGENTS.md | 28 ++++--- README.md | 83 +++++++++++++++----- internal/server/control/server.go | 2 + internal/server/control/webhooks/create.go | 34 ++++++++ internal/server/control/webhooks/delete.go | 30 +++++++ internal/server/control/webhooks/handlers.go | 34 ++++++++ internal/server/control/webhooks/types.go | 40 ++++++++++ internal/services/webhooks/service.go | 1 + 8 files changed, 220 insertions(+), 32 deletions(-) create mode 100644 internal/server/control/webhooks/create.go create mode 100644 internal/server/control/webhooks/delete.go create mode 100644 internal/server/control/webhooks/handlers.go create mode 100644 internal/server/control/webhooks/types.go diff --git a/AGENTS.md b/AGENTS.md index 44e2170..8d5db46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,14 +10,13 @@ It provides: - Git over SSH and HTTP (clone / fetch / push). - Git LFS (object transfer over HTTP; disk or S3 storage). -- A control API to manage repositories, users, SSH keys, tokens, and permissions. +- A control API to manage repositories, users, SSH keys, tokens, permissions, and webhooks. - A `read` / `write` / `admin` permission model enforced before every Git operation. +- Push webhooks, signed and delivered off the push path. Out of scope unless explicitly requested: issues, pull requests, wikis, CI/actions, stars, social profiles, a repo-browsing UI, package registries. -Push webhooks are specified but **not yet implemented** — see [Planned: webhooks](#planned-webhooks). - ## Commands ```sh @@ -142,11 +141,18 @@ widely-used packages. Acceptable areas: SSH server library, HTTP router/middlewa (`chi`), SQLite driver/query tooling, structured logging, config loading, object storage clients. Do not roll your own SSH or Git protocol implementation. -## Planned: webhooks - -Not yet implemented. When built: - -- Emit a push webhook only **after** a successful push, off the push path (an - in-process background queue with bounded retries — no external job system). -- Payload includes at least: `event`, `repo_id`, `ref`, `old_sha`, `new_sha`, `pusher_id`. -- Sign each delivery with a per-webhook secret (constant-time comparison). +## Webhooks + +- Emitted only **after** a successful push, off the push path: receive-pack runs, + `gitbackend` diffs the repo's refs before/after, and the transport hands the + changes to the webhooks service. Delivery is an in-process bounded queue with + worker goroutines and retries — no external job system. +- One delivery per changed ref. Payload: `event`, `repository_id`, `ref`, + `old_sha`, `new_sha`, `pusher_id` (creates/deletes use the all-zero SHA). +- Each delivery is signed with the per-webhook secret: `X-HeadlessGit-Signature: + sha256=` over the raw body. The secret is generated server-side and + returned once at registration; it is stored recoverably (needed to sign), unlike + hashed tokens. +- Registered per repo via the control API; webhook detection lives in `gitbackend` + (refs), the service in `internal/services/webhooks`, dispatch at the receive-pack + call sites. diff --git a/README.md b/README.md index 097c6d6..f64e77f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Basically, this is a Git layer of infrastructure you'd put _underneath_ a projec - **Git LFS** for large files, with object storage on local disk or any S3-compatible bucket (AWS S3, Cloudflare R2, MinIO). - A small **control API**, RESTful api to manage repositories, users, SSH keys, tokens, and permissions. - Simple **permission model** (`read` / `write` / `admin`) enforced before every Git operation. +- **Push webhooks** — signed deliveries on every successful push. - Bare-repository storage on a filesystem, with SQLite for metadata. ## Example @@ -106,35 +107,75 @@ Every request requires `Authorization: Bearer `. Responses are enve **Accounts & credentials** -| Method | Path | Body | Description | -| -------- | --------------------------------- | -------------------- | ------------------------------------------------------------ | -| `POST` | `/users` | `{username, kind}` | Create a user/service account (`kind`: `user` \| `service`). | -| `GET` | `/users/{id}` | — | Get an account. | -| `GET` | `/users/{id}/repositories` | — | List repositories owned by the account. | -| `POST` | `/users/{id}/ssh-keys` | `{title, publicKey}` | Register an SSH public key. | -| `GET` | `/users/{id}/ssh-keys` | — | List the account's SSH keys. | -| `DELETE` | `/users/{id}/ssh-keys/{keyId}` | — | Revoke an SSH key. | -| `POST` | `/users/{id}/tokens` | `{title}` | Mint a token; the raw value is returned **once**. | -| `GET` | `/users/{id}/tokens` | — | List the account's tokens (never the secret). | -| `DELETE` | `/users/{id}/tokens/{tokenId}` | — | Revoke a single token. | -| `DELETE` | `/users/{id}/tokens` | — | Revoke **all** of the account's tokens. | +| Method | Path | Body | Description | +| -------- | ------------------------------ | -------------------- | ------------------------------------------------------------ | +| `POST` | `/users` | `{username, kind}` | Create a user/service account (`kind`: `user` \| `service`). | +| `GET` | `/users/{id}` | — | Get an account. | +| `GET` | `/users/{id}/repositories` | — | List repositories owned by the account. | +| `POST` | `/users/{id}/ssh-keys` | `{title, publicKey}` | Register an SSH public key. | +| `GET` | `/users/{id}/ssh-keys` | — | List the account's SSH keys. | +| `DELETE` | `/users/{id}/ssh-keys/{keyId}` | — | Revoke an SSH key. | +| `POST` | `/users/{id}/tokens` | `{title}` | Mint a token; the raw value is returned **once**. | +| `GET` | `/users/{id}/tokens` | — | List the account's tokens (never the secret). | +| `DELETE` | `/users/{id}/tokens/{tokenId}` | — | Revoke a single token. | +| `DELETE` | `/users/{id}/tokens` | — | Revoke **all** of the account's tokens. | **Repositories & permissions** -| Method | Path | Body | Description | -| -------- | ------------------------------------------ | ----------------------------- | ---------------------------------------------------------------- | -| `POST` | `/repositories` | `{ownerId, name, visibility}` | Create a repository (`visibility`: `public` \| `private`). | -| `GET` | `/repositories/{id}` | — | Get repository metadata. | -| `PUT` | `/repositories/{id}/visibility` | `{visibility}` | Change visibility (`public` \| `private`). | -| `DELETE` | `/repositories/{id}` | — | Delete a repository (row + bare repo). | -| `GET` | `/repositories/{id}/permissions` | — | List collaborators. | -| `PUT` | `/repositories/{id}/permissions` | `{userId, role}` | Grant/update a collaborator role (`read` \| `write` \| `admin`). | -| `DELETE` | `/repositories/{id}/permissions/{userId}` | — | Revoke a collaborator. | +| Method | Path | Body | Description | +| -------- | ----------------------------------------- | ----------------------------- | ----------------------------------------------------------------- | +| `POST` | `/repositories` | `{ownerId, name, visibility}` | Create a repository (`visibility`: `public` \| `private`). | +| `GET` | `/repositories/{id}` | — | Get repository metadata. | +| `PUT` | `/repositories/{id}/visibility` | `{visibility}` | Change visibility (`public` \| `private`). | +| `DELETE` | `/repositories/{id}` | — | Delete a repository (row + bare repo). | +| `GET` | `/repositories/{id}/permissions` | — | List collaborators. | +| `PUT` | `/repositories/{id}/permissions` | `{userId, role}` | Grant/update a collaborator role (`read` \| `write` \| `admin`). | +| `DELETE` | `/repositories/{id}/permissions/{userId}` | — | Revoke a collaborator. | +| `POST` | `/repositories/{id}/webhooks` | `{url}` | Register a push webhook; the signing secret is returned **once**. | +| `DELETE` | `/repositories/{id}/webhooks/{hookId}` | — | Delete a webhook. | ### Health The control port also serves an unauthenticated `GET /healthz` readiness probe. It returns `200 {"status":"ok"}` when the database is reachable and `503 {"status":"unavailable"}` otherwise, and backs the container `HEALTHCHECK`. +## Webhooks + +Register a webhook on a repository and `headlessgit` will `POST` to it after every successful push. + +One delivery is sent **per changed ref** (a branch/tag create, update, or delete — not per file or commit). The JSON body: + +```json +{ + "event": "push", + "repository_id": 6, + "ref": "refs/heads/main", + "old_sha": "0000000000000000000000000000000000000000", + "new_sha": "344018f5c8bce597cfb1b13058edc688f3a13230", + "pusher_id": 7 +} +``` + +Creates and deletes use the all-zero SHA for the missing side (`old_sha` on a create, `new_sha` on a delete). + +Each request carries these headers: + +| Header | Value | +| ------------------------- | ----------------------------------------------------------------------------- | +| `X-HeadlessGit-Event` | `push` | +| `X-HeadlessGit-Delivery` | Unique id for this delivery attempt. | +| `X-HeadlessGit-Signature` | `sha256=` — HMAC-SHA256 of the **raw body** keyed by the webhook secret. | + +Verify a delivery by recomputing the HMAC over the exact request body with the secret returned at registration, e.g.: + +```go +mac := hmac.New(sha256.New, []byte(secret)) +mac.Write(body) +expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) +ok := hmac.Equal([]byte(expected), []byte(r.Header.Get("X-HeadlessGit-Signature"))) +``` + +The secret is generated server-side and shown **once** in the registration response. + ## Development ```sh diff --git a/internal/server/control/server.go b/internal/server/control/server.go index 7e012df..4266bd2 100644 --- a/internal/server/control/server.go +++ b/internal/server/control/server.go @@ -10,6 +10,7 @@ import ( permhandlers "github.com/Axenos-dev/HeadlessGit/internal/server/control/permissions" repohandlers "github.com/Axenos-dev/HeadlessGit/internal/server/control/repositories" userhandlers "github.com/Axenos-dev/HeadlessGit/internal/server/control/users" + webhookhandlers "github.com/Axenos-dev/HeadlessGit/internal/server/control/webhooks" authservice "github.com/Axenos-dev/HeadlessGit/internal/services/auth" permsservice "github.com/Axenos-dev/HeadlessGit/internal/services/permissions" reposervice "github.com/Axenos-dev/HeadlessGit/internal/services/repositories" @@ -100,4 +101,5 @@ func (s *Server) registerRoutes(r chi.Router) { repohandlers.NewHandlers(s.logger, s.repos).RegisterRoutes(r) userhandlers.NewHandlers(s.logger, s.users, s.auth).RegisterRoutes(r) permhandlers.NewHandlers(s.logger, s.perms).RegisterRoutes(r) + webhookhandlers.NewHandlers(s.logger, s.webhooks).RegisterRoutes(r) } diff --git a/internal/server/control/webhooks/create.go b/internal/server/control/webhooks/create.go new file mode 100644 index 0000000..f761fd5 --- /dev/null +++ b/internal/server/control/webhooks/create.go @@ -0,0 +1,34 @@ +package webhooks + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/Axenos-dev/HeadlessGit/internal/server/response" + "github.com/go-chi/chi/v5" + "go.uber.org/zap" +) + +func (h *handlers) createWebhook(w http.ResponseWriter, r *http.Request) error { + repoID, err := strconv.ParseInt(chi.URLParam(r, "repositoryID"), 10, 64) + if err != nil { + return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "invalid repository id") + } + + var req CreateWebhookRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "invalid request body") + } + if err := req.Validate(); err != nil { + return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, err.Error()) + } + + webhook, err := h.webhooks.RegisterWebhook(r.Context(), repoID, req.URL) + if err != nil { + h.logger.Error("failed to register webhook", zap.Error(err)) + return response.NewError(http.StatusInternalServerError, response.CodeInternalError, "failed to register webhook") + } + + return response.Data(w, http.StatusCreated, newWebhookResponse(webhook)) +} diff --git a/internal/server/control/webhooks/delete.go b/internal/server/control/webhooks/delete.go new file mode 100644 index 0000000..0e7c6a2 --- /dev/null +++ b/internal/server/control/webhooks/delete.go @@ -0,0 +1,30 @@ +package webhooks + +import ( + "net/http" + "strconv" + + "github.com/Axenos-dev/HeadlessGit/internal/server/response" + "github.com/go-chi/chi/v5" + "go.uber.org/zap" +) + +func (h *handlers) deleteWebhook(w http.ResponseWriter, r *http.Request) error { + repoID, err := strconv.ParseInt(chi.URLParam(r, "repositoryID"), 10, 64) + if err != nil { + return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "invalid repository id") + } + webhookID, err := strconv.ParseInt(chi.URLParam(r, "webhookID"), 10, 64) + if err != nil { + return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "invalid webhook id") + } + + // scoped to the repo; deleting a non-existent hook is a no-op + if err := h.webhooks.DeleteWebhook(r.Context(), webhookID, repoID); err != nil { + h.logger.Error("failed to delete webhook", zap.Error(err)) + return response.NewError(http.StatusInternalServerError, response.CodeInternalError, "failed to delete webhook") + } + + w.WriteHeader(http.StatusNoContent) + return nil +} diff --git a/internal/server/control/webhooks/handlers.go b/internal/server/control/webhooks/handlers.go new file mode 100644 index 0000000..a8de2fe --- /dev/null +++ b/internal/server/control/webhooks/handlers.go @@ -0,0 +1,34 @@ +package webhooks + +import ( + "context" + + "github.com/Axenos-dev/HeadlessGit/internal/domain" + "github.com/Axenos-dev/HeadlessGit/internal/server/response" + "github.com/go-chi/chi/v5" + "go.uber.org/zap" +) + +type WebhookManager interface { + RegisterWebhook(ctx context.Context, repoID int64, url string) (domain.Webhook, error) + DeleteWebhook(ctx context.Context, webhookID, repositoryID int64) error +} + +type handlers struct { + logger *zap.Logger + webhooks WebhookManager +} + +func NewHandlers(logger *zap.Logger, webhooks WebhookManager) *handlers { + return &handlers{ + logger: logger, + webhooks: webhooks, + } +} + +func (h *handlers) RegisterRoutes(parent chi.Router) { + parent.Route("/repositories/{repositoryID}/webhooks", func(r chi.Router) { + r.Post("/", response.Handler(h.logger, h.createWebhook)) + r.Delete("/{webhookID}", response.Handler(h.logger, h.deleteWebhook)) + }) +} diff --git a/internal/server/control/webhooks/types.go b/internal/server/control/webhooks/types.go new file mode 100644 index 0000000..2eee8c6 --- /dev/null +++ b/internal/server/control/webhooks/types.go @@ -0,0 +1,40 @@ +package webhooks + +import ( + "errors" + "net/url" + "time" + + "github.com/Axenos-dev/HeadlessGit/internal/domain" +) + +type CreateWebhookRequest struct { + URL string `json:"url"` +} + +func (r CreateWebhookRequest) Validate() error { + if r.URL == "" { + return errors.New("url is required") + } + u, err := url.Parse(r.URL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return errors.New("url must be a valid http(s) URL") + } + return nil +} + +type WebhookResponse struct { + ID int64 `json:"id"` + URL string `json:"url"` + Secret string `json:"secret"` + CreatedAt time.Time `json:"createdAt"` +} + +func newWebhookResponse(w domain.Webhook) WebhookResponse { + return WebhookResponse{ + ID: w.ID, + URL: w.URL, + Secret: w.Secret, + CreatedAt: w.CreatedAt, + } +} diff --git a/internal/services/webhooks/service.go b/internal/services/webhooks/service.go index 3343334..b5c973b 100644 --- a/internal/services/webhooks/service.go +++ b/internal/services/webhooks/service.go @@ -141,6 +141,7 @@ func (s *Service) sendWebhook(ctx context.Context, webhook domain.Webhook, event req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "headlessgit") + req.Header.Set("X-HeadlessGit-Event", event.Event) req.Header.Set("X-HeadlessGit-Delivery", deliveryID()) // HMAC-SHA256 of the raw body, keyed by the webhook secret // receivers recompute it to verify the delivery is authentic From 068eb7f165c7d2a0a912431311d6af7579f25fa3 Mon Sep 17 00:00:00 2001 From: Axenos-dev Date: Mon, 29 Jun 2026 13:16:17 -0700 Subject: [PATCH 5/5] extend webhook event payload --- AGENTS.md | 6 +- README.md | 17 ++++-- internal/domain/webhooks.go | 13 +++- internal/server/git/githttp/smart/handlers.go | 27 +++++---- internal/server/git/gitssh/server.go | 23 +++++--- internal/services/webhooks/service.go | 9 +-- internal/services/webhooks/types.go | 59 +++++++++++++++++-- 7 files changed, 110 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8d5db46..14662fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,8 +147,10 @@ storage clients. Do not roll your own SSH or Git protocol implementation. `gitbackend` diffs the repo's refs before/after, and the transport hands the changes to the webhooks service. Delivery is an in-process bounded queue with worker goroutines and retries — no external job system. -- One delivery per changed ref. Payload: `event`, `repository_id`, `ref`, - `old_sha`, `new_sha`, `pusher_id` (creates/deletes use the all-zero SHA). +- One delivery per changed ref. Payload is self-describing: `event`, `ref`, + `before`/`after`, `created`/`deleted`, a `repository` object (`id`, `name`, + `full_name`), a `pusher` object (`id`, `username`), and `timestamp`. Creates/ + deletes use the all-zero SHA for the missing side. - Each delivery is signed with the per-webhook secret: `X-HeadlessGit-Signature: sha256=` over the raw body. The secret is generated server-side and returned once at registration; it is stored recoverably (needed to sign), unlike diff --git a/README.md b/README.md index f64e77f..78d982c 100644 --- a/README.md +++ b/README.md @@ -147,15 +147,22 @@ One delivery is sent **per changed ref** (a branch/tag create, update, or delete ```json { "event": "push", - "repository_id": 6, "ref": "refs/heads/main", - "old_sha": "0000000000000000000000000000000000000000", - "new_sha": "344018f5c8bce597cfb1b13058edc688f3a13230", - "pusher_id": 7 + "before": "0000000000000000000000000000000000000000", + "after": "344018f5c8bce597cfb1b13058edc688f3a13230", + "created": true, + "deleted": false, + "repository": { + "id": 6, + "name": "HeadlessGit", + "full_name": "Axenos-dev/HeadlessGit" + }, + "pusher": { "id": 7, "username": "Axenos-dev" }, + "timestamp": "2026-06-29T19:06:48Z" } ``` -Creates and deletes use the all-zero SHA for the missing side (`old_sha` on a create, `new_sha` on a delete). +`before`/`after` are the ref's SHAs around the push; a create has `before` all-zero (`created: true`), a delete has `after` all-zero (`deleted: true`). `repository.full_name` is `namespace/name`. Each request carries these headers: diff --git a/internal/domain/webhooks.go b/internal/domain/webhooks.go index 4f006e1..85038cc 100644 --- a/internal/domain/webhooks.go +++ b/internal/domain/webhooks.go @@ -12,11 +12,18 @@ type Webhook struct { } type RepositoryEvent struct { - RepositoryID int64 - PusherID int64 - Event string + Event string + + RepositoryID int64 + RepositoryName string + RepositoryFullName string // namespace/name + + PusherID int64 + PusherUsername string Ref string OldSHA string NewSHA string + + Timestamp time.Time } diff --git a/internal/server/git/githttp/smart/handlers.go b/internal/server/git/githttp/smart/handlers.go index f6ada11..d8d1069 100644 --- a/internal/server/git/githttp/smart/handlers.go +++ b/internal/server/git/githttp/smart/handlers.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "strings" + "time" "github.com/Axenos-dev/HeadlessGit/internal/domain" "github.com/Axenos-dev/HeadlessGit/internal/gitbackend" @@ -157,7 +158,8 @@ func (h *Handlers) pack(svc gitbackend.Service) http.HandlerFunc { var changes []gitbackend.RefChange changes, err = h.backend.ReceivePack(r.Context(), repo.StoragePath, true, body, w, &stderr) if err == nil { - h.dispatchPush(r.Context(), repo.ID, changes) + namespace := chi.URLParam(r, "namespace") + h.dispatchPush(r.Context(), repo, namespace, middleware.AccountFromContext(r.Context()), changes) } case gitbackend.UploadPack: err = h.backend.UploadPack(r.Context(), repo.StoragePath, true, body, w, &stderr) @@ -172,24 +174,25 @@ func (h *Handlers) pack(svc gitbackend.Service) http.HandlerFunc { } } -func (h *Handlers) dispatchPush(ctx context.Context, repoID int64, changes []gitbackend.RefChange) { +func (h *Handlers) dispatchPush(ctx context.Context, repo domain.Repository, namespace string, account *domain.Account, changes []gitbackend.RefChange) { if h.dispatcher == nil { return } - var pusherID int64 - if account := middleware.AccountFromContext(ctx); account != nil { - pusherID = account.UserID - } + fullName := namespace + "/" + repo.RepositoryName for _, c := range changes { err := h.dispatcher.DispatchEvent(ctx, domain.RepositoryEvent{ - RepositoryID: repoID, - Event: "push", - Ref: c.Ref, - OldSHA: c.OldSHA, - NewSHA: c.NewSHA, - PusherID: pusherID, + Event: "push", + RepositoryID: repo.ID, + RepositoryName: repo.RepositoryName, + RepositoryFullName: fullName, + PusherID: account.UserID, + PusherUsername: account.Username, + Ref: c.Ref, + OldSHA: c.OldSHA, + NewSHA: c.NewSHA, + Timestamp: time.Now().UTC(), }) if err != nil { h.logger.Warn("failed to enqueue webhook event", zap.String("ref", c.Ref), zap.Error(err)) diff --git a/internal/server/git/gitssh/server.go b/internal/server/git/gitssh/server.go index 3af02e8..4d1728d 100644 --- a/internal/server/git/gitssh/server.go +++ b/internal/server/git/gitssh/server.go @@ -282,7 +282,7 @@ func (s *Server) runGit(ctx context.Context, account domain.Account, ch ssh.Chan var changes []gitbackend.RefChange changes, err = s.backend.ReceivePack(ctx, resolved.StoragePath, false, ch, ch, ch.Stderr()) if err == nil { - s.dispatchPush(ctx, resolved.ID, account.UserID, changes) + s.dispatchPush(ctx, resolved, namespace, account, changes) } } if err != nil { @@ -295,18 +295,25 @@ func (s *Server) runGit(ctx context.Context, account domain.Account, ch ssh.Chan sendExit(ch, 0) } -func (s *Server) dispatchPush(ctx context.Context, repoID, pusherID int64, changes []gitbackend.RefChange) { +func (s *Server) dispatchPush(ctx context.Context, repo domain.Repository, namespace string, account domain.Account, changes []gitbackend.RefChange) { if s.dispatcher == nil { return } + + fullName := namespace + "/" + repo.RepositoryName + for _, c := range changes { err := s.dispatcher.DispatchEvent(ctx, domain.RepositoryEvent{ - RepositoryID: repoID, - Event: "push", - Ref: c.Ref, - OldSHA: c.OldSHA, - NewSHA: c.NewSHA, - PusherID: pusherID, + Event: "push", + RepositoryID: repo.ID, + RepositoryName: repo.RepositoryName, + RepositoryFullName: fullName, + PusherID: account.UserID, + PusherUsername: account.Username, + Ref: c.Ref, + OldSHA: c.OldSHA, + NewSHA: c.NewSHA, + Timestamp: time.Now().UTC(), }) if err != nil { s.logger.Warn("failed to enqueue webhook event", zap.String("ref", c.Ref), zap.Error(err)) diff --git a/internal/services/webhooks/service.go b/internal/services/webhooks/service.go index b5c973b..59d7dd5 100644 --- a/internal/services/webhooks/service.go +++ b/internal/services/webhooks/service.go @@ -122,14 +122,7 @@ func (s *Service) handleEvent(ctx context.Context, event domain.RepositoryEvent) } func (s *Service) sendWebhook(ctx context.Context, webhook domain.Webhook, event domain.RepositoryEvent) error { - body, err := json.Marshal(WebhookPayload{ - Event: event.Event, - RepositoryID: event.RepositoryID, - Ref: event.Ref, - OldSHA: event.OldSHA, - NewSHA: event.NewSHA, - PusherID: event.PusherID, - }) + body, err := json.Marshal(newWebhookPayload(event)) if err != nil { return err } diff --git a/internal/services/webhooks/types.go b/internal/services/webhooks/types.go index 7a4eef1..42c44ed 100644 --- a/internal/services/webhooks/types.go +++ b/internal/services/webhooks/types.go @@ -1,10 +1,57 @@ package webhooks +import ( + "time" + + "github.com/Axenos-dev/HeadlessGit/internal/domain" +) + +// the all-zero object id git uses for a missing ref side (create's before, +// delete's after) +const zeroSHA = "0000000000000000000000000000000000000000" + type WebhookPayload struct { - Event string `json:"event"` - RepositoryID int64 `json:"repository_id"` - Ref string `json:"ref"` - OldSHA string `json:"old_sha"` - NewSHA string `json:"new_sha"` - PusherID int64 `json:"pusher_id"` + Event string `json:"event"` + Ref string `json:"ref"` + Before string `json:"before"` + After string `json:"after"` + Created bool `json:"created"` + Deleted bool `json:"deleted"` + + Repository WebhookRepository `json:"repository"` + Pusher WebhookPusher `json:"pusher"` + + Timestamp time.Time `json:"timestamp"` +} + +type WebhookRepository struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` +} + +type WebhookPusher struct { + ID int64 `json:"id"` + Username string `json:"username"` +} + +func newWebhookPayload(e domain.RepositoryEvent) WebhookPayload { + return WebhookPayload{ + Event: e.Event, + Ref: e.Ref, + Before: e.OldSHA, + After: e.NewSHA, + Created: e.OldSHA == zeroSHA, + Deleted: e.NewSHA == zeroSHA, + Repository: WebhookRepository{ + ID: e.RepositoryID, + Name: e.RepositoryName, + FullName: e.RepositoryFullName, + }, + Pusher: WebhookPusher{ + ID: e.PusherID, + Username: e.PusherUsername, + }, + Timestamp: e.Timestamp, + } }