mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
core.inbound_hooks (migration 00013): POST /api/v1/hooks/<token> accepts external events. event mode republishes as inbound.received through the signed dispatcher + realtime; job mode triggers a scheduled-job run with the received body as payload. Optional X-Primora-Signature HMAC verify. Dashboard card on the Integrations page. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
683 lines
21 KiB
Go
683 lines
21 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
|
"github.com/tdvorak/primora/apps/backend/internal/secrets"
|
|
)
|
|
|
|
const (
|
|
WebhookEventIssueCreated = "issue.created"
|
|
WebhookEventDeployMarker = "deploy.marker"
|
|
WebhookEventTest = "webhook.test"
|
|
WebhookEventAlertFired = "alert.fired"
|
|
WebhookEventAlertResolved = "alert.resolved"
|
|
WebhookEventDocumentCreated = "document.created"
|
|
WebhookEventDocumentUpdated = "document.updated"
|
|
WebhookEventDocumentDeleted = "document.deleted"
|
|
WebhookEventObjectCreated = "object.created"
|
|
WebhookEventObjectUpdated = "object.updated"
|
|
WebhookEventObjectDeleted = "object.deleted"
|
|
)
|
|
|
|
var webhookEventTypes = map[string]bool{
|
|
WebhookEventIssueCreated: true,
|
|
WebhookEventDeployMarker: true,
|
|
WebhookEventTest: true,
|
|
WebhookEventAlertFired: true,
|
|
WebhookEventAlertResolved: true,
|
|
WebhookEventInboundReceived: true,
|
|
WebhookEventJobRun: true,
|
|
WebhookEventDocumentCreated: true,
|
|
WebhookEventDocumentUpdated: true,
|
|
WebhookEventDocumentDeleted: true,
|
|
WebhookEventObjectCreated: true,
|
|
WebhookEventObjectUpdated: true,
|
|
WebhookEventObjectDeleted: true,
|
|
}
|
|
|
|
const webhookMaxAttempts = 3
|
|
|
|
type CreateWebhookInput struct {
|
|
URL string `json:"url" validate:"required,url"`
|
|
Secret string `json:"secret"`
|
|
Events []string `json:"events"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
|
|
type UpdateWebhookInput struct {
|
|
URL *string `json:"url" validate:"omitempty,url"`
|
|
Secret *string `json:"secret"`
|
|
Events []string `json:"events"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
|
|
type WebhookSummary struct {
|
|
ID uuid.UUID `json:"id"`
|
|
ProjectID uuid.UUID `json:"project_id"`
|
|
URL string `json:"url"`
|
|
Events []string `json:"events"`
|
|
Enabled bool `json:"enabled"`
|
|
HasSecret bool `json:"has_secret"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
// Secret is populated only when Primora generated the secret — it is the
|
|
// one chance to see it. Not part of the stored shape.
|
|
Secret string `json:"secret,omitempty"`
|
|
}
|
|
|
|
type WebhookDeliverySummary struct {
|
|
ID uuid.UUID `json:"id"`
|
|
WebhookID uuid.UUID `json:"webhook_id"`
|
|
EventType string `json:"event_type"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
Status string `json:"status"`
|
|
Attempts int32 `json:"attempts"`
|
|
LastStatusCode *int32 `json:"last_status_code"`
|
|
LastError string `json:"last_error"`
|
|
DeliveredAt *time.Time `json:"delivered_at"`
|
|
NextRetryAt *time.Time `json:"next_retry_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type CreateDeployMarkerInput struct {
|
|
Version string `json:"version"`
|
|
Ref string `json:"ref"`
|
|
Environment string `json:"environment"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
type DeployMarker struct {
|
|
Version string `json:"version,omitempty"`
|
|
Ref string `json:"ref,omitempty"`
|
|
Environment string `json:"environment,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
RecordedAt time.Time `json:"recorded_at"`
|
|
}
|
|
|
|
func toWebhookSummary(row db.CoreWebhook) WebhookSummary {
|
|
events := row.Events
|
|
if events == nil {
|
|
events = []string{}
|
|
}
|
|
return WebhookSummary{
|
|
ID: row.ID,
|
|
ProjectID: row.ProjectID,
|
|
URL: row.Url,
|
|
Events: events,
|
|
Enabled: row.Enabled,
|
|
HasSecret: len(row.Secret) > 0,
|
|
CreatedAt: row.CreatedAt.Time,
|
|
}
|
|
}
|
|
|
|
func toDeliverySummary(row db.CoreWebhookDelivery) WebhookDeliverySummary {
|
|
out := WebhookDeliverySummary{
|
|
ID: row.ID,
|
|
WebhookID: row.WebhookID,
|
|
EventType: row.EventType,
|
|
Payload: row.Payload,
|
|
Status: row.Status,
|
|
Attempts: row.Attempts,
|
|
LastError: row.LastError,
|
|
CreatedAt: row.CreatedAt.Time,
|
|
}
|
|
if row.LastStatusCode != nil {
|
|
code := *row.LastStatusCode
|
|
out.LastStatusCode = &code
|
|
}
|
|
if row.DeliveredAt.Valid {
|
|
t := row.DeliveredAt.Time
|
|
out.DeliveredAt = &t
|
|
}
|
|
if row.NextRetryAt.Valid {
|
|
t := row.NextRetryAt.Time
|
|
out.NextRetryAt = &t
|
|
}
|
|
return out
|
|
}
|
|
|
|
// normalizeWebhookURL requires https for public targets. Plain http is allowed
|
|
// for private/self-hosted sinks: loopback, RFC1918/link-local IPs, .local /
|
|
// .internal / .lan names, host.docker.internal, and bare hostnames (Docker
|
|
// service names on the internal network).
|
|
func normalizeWebhookURL(raw string) (string, error) {
|
|
trimmed := strings.TrimSpace(raw)
|
|
u, err := url.Parse(trimmed)
|
|
if err != nil || u.Host == "" {
|
|
return "", errors.New("webhook url must be an https URL")
|
|
}
|
|
if u.Scheme == "https" {
|
|
return trimmed, nil
|
|
}
|
|
if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) {
|
|
return trimmed, nil
|
|
}
|
|
return "", errors.New("webhook url must be https (http allowed only for private/self-hosted targets)")
|
|
}
|
|
|
|
func isPrivateWebhookHost(host string) bool {
|
|
h := strings.ToLower(strings.Trim(host, "[]"))
|
|
if h == "localhost" || h == "host.docker.internal" ||
|
|
strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") {
|
|
return true
|
|
}
|
|
if !strings.Contains(h, ".") && !strings.Contains(h, ":") {
|
|
return true // bare hostname — docker service name, unqualified LAN host
|
|
}
|
|
ip := net.ParseIP(h)
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
|
|
}
|
|
|
|
func validateWebhookEvents(events []string) ([]string, error) {
|
|
out := make([]string, 0, len(events))
|
|
seen := map[string]bool{}
|
|
for _, e := range events {
|
|
e = strings.TrimSpace(e)
|
|
if e == "" {
|
|
continue
|
|
}
|
|
if !webhookEventTypes[e] {
|
|
return nil, fmt.Errorf("unknown event type %q", e)
|
|
}
|
|
if !seen[e] {
|
|
seen[e] = true
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *PlatformService) webhookFor(ctx context.Context, actor *models.Actor, projectID, webhookID uuid.UUID, write bool) (db.CoreWebhook, error) {
|
|
roles := []string{"admin", "developer", "viewer"}
|
|
if write {
|
|
roles = []string{"admin", "developer"}
|
|
}
|
|
if err := s.requireProjectRole(ctx, actor, projectID, roles...); err != nil {
|
|
return db.CoreWebhook{}, err
|
|
}
|
|
row, err := s.repo.Queries().GetWebhookByID(ctx, webhookID)
|
|
if err != nil {
|
|
return db.CoreWebhook{}, err
|
|
}
|
|
if row.ProjectID != projectID {
|
|
return db.CoreWebhook{}, fmt.Errorf("webhook access denied")
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
func (s *PlatformService) ListWebhooks(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]WebhookSummary, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.repo.Queries().ListWebhooks(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]WebhookSummary, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, toWebhookSummary(row))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *PlatformService) CreateWebhook(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateWebhookInput, requestID string) (WebhookSummary, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
|
return WebhookSummary{}, err
|
|
}
|
|
hookURL, err := normalizeWebhookURL(input.URL)
|
|
if err != nil {
|
|
return WebhookSummary{}, err
|
|
}
|
|
events, err := validateWebhookEvents(input.Events)
|
|
if err != nil {
|
|
return WebhookSummary{}, err
|
|
}
|
|
secret := strings.TrimSpace(input.Secret)
|
|
generated := false
|
|
if secret == "" {
|
|
secret, err = randomToken(16)
|
|
if err != nil {
|
|
return WebhookSummary{}, err
|
|
}
|
|
generated = true
|
|
}
|
|
secretBytes, err := s.enc.Encrypt([]byte(secret))
|
|
if err != nil {
|
|
return WebhookSummary{}, err
|
|
}
|
|
enabled := true
|
|
if input.Enabled != nil {
|
|
enabled = *input.Enabled
|
|
}
|
|
var createdBy pgtype.UUID
|
|
if actor.UserID != nil {
|
|
createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
|
|
}
|
|
row, err := s.repo.Queries().CreateWebhook(ctx, db.CreateWebhookParams{
|
|
ProjectID: projectID,
|
|
Url: hookURL,
|
|
Secret: secretBytes,
|
|
Events: events,
|
|
Enabled: enabled,
|
|
CreatedByUserID: createdBy,
|
|
})
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "webhooks_project_id_url_key") {
|
|
return WebhookSummary{}, fmt.Errorf("a webhook for %s already exists", hookURL)
|
|
}
|
|
return WebhookSummary{}, err
|
|
}
|
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
if err == nil {
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "webhook.created", "webhook", row.ID.String(), map[string]any{
|
|
"url": row.Url, "events": events,
|
|
}))
|
|
}
|
|
summary := toWebhookSummary(row)
|
|
if generated {
|
|
summary.Secret = secret
|
|
}
|
|
return summary, nil
|
|
}
|
|
|
|
func (s *PlatformService) UpdateWebhook(ctx context.Context, actor *models.Actor, projectID, webhookID uuid.UUID, input UpdateWebhookInput, requestID string) (WebhookSummary, error) {
|
|
row, err := s.webhookFor(ctx, actor, projectID, webhookID, true)
|
|
if err != nil {
|
|
return WebhookSummary{}, err
|
|
}
|
|
hookURL := row.Url
|
|
if input.URL != nil {
|
|
if hookURL, err = normalizeWebhookURL(*input.URL); err != nil {
|
|
return WebhookSummary{}, err
|
|
}
|
|
}
|
|
events := row.Events
|
|
if input.Events != nil {
|
|
if events, err = validateWebhookEvents(input.Events); err != nil {
|
|
return WebhookSummary{}, err
|
|
}
|
|
}
|
|
enabled := row.Enabled
|
|
if input.Enabled != nil {
|
|
enabled = *input.Enabled
|
|
}
|
|
rotateSecret := input.Secret != nil
|
|
var secretBytes []byte
|
|
if rotateSecret {
|
|
secret := strings.TrimSpace(*input.Secret)
|
|
if secret == "" {
|
|
if secret, err = randomToken(16); err != nil {
|
|
return WebhookSummary{}, err
|
|
}
|
|
}
|
|
if secretBytes, err = s.enc.Encrypt([]byte(secret)); err != nil {
|
|
return WebhookSummary{}, err
|
|
}
|
|
}
|
|
updated, err := s.repo.Queries().UpdateWebhook(ctx, db.UpdateWebhookParams{
|
|
ID: webhookID,
|
|
ProjectID: projectID,
|
|
Url: hookURL,
|
|
Events: events,
|
|
Enabled: enabled,
|
|
Column6: rotateSecret,
|
|
Secret: secretBytes,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return WebhookSummary{}, err
|
|
}
|
|
if strings.Contains(err.Error(), "webhooks_project_id_url_key") {
|
|
return WebhookSummary{}, fmt.Errorf("a webhook for %s already exists", hookURL)
|
|
}
|
|
return WebhookSummary{}, err
|
|
}
|
|
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "webhook.updated", "webhook", webhookID.String(), map[string]any{
|
|
"url": updated.Url, "events": updated.Events, "enabled": updated.Enabled, "secret_rotated": rotateSecret,
|
|
}))
|
|
summary := toWebhookSummary(updated)
|
|
if rotateSecret && input.Secret != nil && strings.TrimSpace(*input.Secret) == "" {
|
|
// generated on rotation — return once
|
|
plain, decErr := s.enc.Decrypt(updated.Secret)
|
|
if decErr == nil {
|
|
summary.Secret = string(plain)
|
|
}
|
|
}
|
|
return summary, nil
|
|
}
|
|
|
|
func (s *PlatformService) DeleteWebhook(ctx context.Context, actor *models.Actor, projectID, webhookID uuid.UUID, requestID string) error {
|
|
row, err := s.webhookFor(ctx, actor, projectID, webhookID, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.repo.Queries().DeleteWebhook(ctx, db.DeleteWebhookParams{
|
|
ID: webhookID,
|
|
ProjectID: projectID,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "webhook.deleted", "webhook", webhookID.String(), map[string]any{
|
|
"url": row.Url,
|
|
}))
|
|
return nil
|
|
}
|
|
|
|
func (s *PlatformService) ListWebhookDeliveries(ctx context.Context, actor *models.Actor, projectID, webhookID uuid.UUID, limit int32) ([]WebhookDeliverySummary, error) {
|
|
if _, err := s.webhookFor(ctx, actor, projectID, webhookID, false); err != nil {
|
|
return nil, err
|
|
}
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
rows, err := s.repo.Queries().ListWebhookDeliveries(ctx, db.ListWebhookDeliveriesParams{
|
|
WebhookID: webhookID,
|
|
Limit: limit,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]WebhookDeliverySummary, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, toDeliverySummary(row))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// TestWebhook queues a webhook.test delivery to one endpoint.
|
|
func (s *PlatformService) TestWebhook(ctx context.Context, actor *models.Actor, projectID, webhookID uuid.UUID, requestID string) (WebhookDeliverySummary, error) {
|
|
row, err := s.webhookFor(ctx, actor, projectID, webhookID, true)
|
|
if err != nil {
|
|
return WebhookDeliverySummary{}, err
|
|
}
|
|
delivery, err := s.enqueueDelivery(ctx, row, WebhookEventTest, map[string]any{
|
|
"message": "primora webhook test",
|
|
})
|
|
if err != nil {
|
|
return WebhookDeliverySummary{}, err
|
|
}
|
|
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "webhook.tested", "webhook", webhookID.String(), map[string]any{
|
|
"url": row.Url,
|
|
}))
|
|
}
|
|
return toDeliverySummary(delivery), nil
|
|
}
|
|
|
|
// CreateDeployMarker records the marker in the audit log and fans out a
|
|
// deploy.marker event to matching webhooks.
|
|
func (s *PlatformService) CreateDeployMarker(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateDeployMarkerInput, requestID string) (DeployMarker, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
|
return DeployMarker{}, err
|
|
}
|
|
marker := DeployMarker{
|
|
Version: strings.TrimSpace(input.Version),
|
|
Ref: strings.TrimSpace(input.Ref),
|
|
Environment: strings.TrimSpace(input.Environment),
|
|
Note: strings.TrimSpace(input.Note),
|
|
RecordedAt: time.Now().UTC(),
|
|
}
|
|
if marker.Version == "" && marker.Ref == "" {
|
|
return DeployMarker{}, errors.New("version or ref required")
|
|
}
|
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
if err != nil {
|
|
return DeployMarker{}, err
|
|
}
|
|
meta := map[string]any{"version": marker.Version, "ref": marker.Ref, "environment": marker.Environment, "note": marker.Note}
|
|
if _, err := s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "deploy.marker", "deploy_marker", marker.Ref+marker.Version, meta)); err != nil {
|
|
return DeployMarker{}, err
|
|
}
|
|
s.publishEvent(ctx, projectID, WebhookEventDeployMarker, map[string]any{
|
|
"version": marker.Version,
|
|
"ref": marker.Ref,
|
|
"environment": marker.Environment,
|
|
"note": marker.Note,
|
|
"recorded_at": marker.RecordedAt,
|
|
})
|
|
return marker, nil
|
|
}
|
|
|
|
// dispatchEvent fans an event out to matching enabled webhooks. Deliveries are
|
|
// persisted first, then queued in-process — the DB row is the source of truth.
|
|
func (s *PlatformService) dispatchEvent(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
|
|
hooks, err := s.repo.Queries().ListWebhooksForEvent(ctx, db.ListWebhooksForEventParams{
|
|
ProjectID: projectID,
|
|
Column2: eventType,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, hook := range hooks {
|
|
_, _ = s.enqueueDelivery(ctx, hook, eventType, data)
|
|
}
|
|
}
|
|
|
|
func (s *PlatformService) enqueueDelivery(ctx context.Context, hook db.CoreWebhook, eventType string, data map[string]any) (db.CoreWebhookDelivery, error) {
|
|
payload, _ := json.Marshal(map[string]any{
|
|
"event": eventType,
|
|
"project_id": hook.ProjectID.String(),
|
|
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
|
"data": data,
|
|
})
|
|
delivery, err := s.repo.Queries().InsertWebhookDelivery(ctx, db.InsertWebhookDeliveryParams{
|
|
WebhookID: hook.ID,
|
|
EventType: eventType,
|
|
Payload: payload,
|
|
})
|
|
if err != nil {
|
|
return db.CoreWebhookDelivery{}, err
|
|
}
|
|
if s.dispatcher != nil {
|
|
s.dispatcher.push(delivery.ID)
|
|
}
|
|
return delivery, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// dispatcher — in-process worker, buffered queue, backoff retry
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type WebhookDispatcher struct {
|
|
repo queries
|
|
enc *secrets.Encryptor
|
|
logger *slog.Logger
|
|
http *http.Client
|
|
queue chan uuid.UUID
|
|
quit chan struct{}
|
|
wg sync.WaitGroup
|
|
timerMu sync.Mutex
|
|
timers map[uuid.UUID]*time.Timer
|
|
started sync.Once
|
|
}
|
|
|
|
// queries is the slice of db.Queries the dispatcher needs — kept narrow so
|
|
// tests can stub it without a database.
|
|
type queries interface {
|
|
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (db.CoreWebhookDelivery, error)
|
|
GetWebhookByID(ctx context.Context, id uuid.UUID) (db.CoreWebhook, error)
|
|
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]db.CoreWebhookDelivery, error)
|
|
MarkWebhookDelivery(ctx context.Context, params db.MarkWebhookDeliveryParams) error
|
|
}
|
|
|
|
func NewWebhookDispatcher(q queries, enc *secrets.Encryptor, logger *slog.Logger) *WebhookDispatcher {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &WebhookDispatcher{
|
|
repo: q,
|
|
enc: enc,
|
|
logger: logger,
|
|
http: &http.Client{Timeout: 10 * time.Second},
|
|
queue: make(chan uuid.UUID, 1024),
|
|
quit: make(chan struct{}),
|
|
timers: map[uuid.UUID]*time.Timer{},
|
|
}
|
|
}
|
|
|
|
// Start launches the worker and re-queues deliveries left pending by a crash.
|
|
func (d *WebhookDispatcher) Start(ctx context.Context) {
|
|
d.started.Do(func() {
|
|
d.wg.Add(1)
|
|
go d.run()
|
|
if pending, err := d.repo.ListPendingWebhookDeliveries(ctx, 200); err == nil {
|
|
for _, row := range pending {
|
|
d.push(row.ID)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func (d *WebhookDispatcher) Stop() {
|
|
close(d.quit)
|
|
d.timerMu.Lock()
|
|
for _, t := range d.timers {
|
|
t.Stop()
|
|
}
|
|
d.timerMu.Unlock()
|
|
d.wg.Wait()
|
|
}
|
|
|
|
func (d *WebhookDispatcher) push(id uuid.UUID) {
|
|
select {
|
|
case d.queue <- id:
|
|
case <-d.quit:
|
|
default:
|
|
// queue full — drop; the row stays pending and is retried on restart
|
|
}
|
|
}
|
|
|
|
func (d *WebhookDispatcher) run() {
|
|
defer d.wg.Done()
|
|
for {
|
|
select {
|
|
case id := <-d.queue:
|
|
d.deliver(context.Background(), id)
|
|
case <-d.quit:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// signBody is the canonical HMAC-SHA256 signature: hex of the keyed digest of
|
|
// the exact bytes sent. Receivers verify against the raw body.
|
|
func signBody(secret string, body []byte) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(body)
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func (d *WebhookDispatcher) deliver(ctx context.Context, deliveryID uuid.UUID) {
|
|
delivery, err := d.repo.GetWebhookDeliveryByID(ctx, deliveryID)
|
|
if err != nil || delivery.Status != "pending" {
|
|
return
|
|
}
|
|
hook, err := d.repo.GetWebhookByID(ctx, delivery.WebhookID)
|
|
if err != nil {
|
|
d.mark(delivery, "failed", nil, "webhook deleted", nil)
|
|
return
|
|
}
|
|
|
|
var statusCode *int32
|
|
var sendErr error
|
|
secret := ""
|
|
if len(hook.Secret) > 0 && d.enc != nil {
|
|
if plain, err := d.enc.Decrypt(hook.Secret); err == nil {
|
|
secret = string(plain)
|
|
}
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hook.Url, bytes.NewReader(delivery.Payload))
|
|
if err == nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Primora-Event", delivery.EventType)
|
|
req.Header.Set("X-Primora-Delivery", delivery.ID.String())
|
|
req.Header.Set("X-Primora-Signature", "sha256="+signBody(secret, delivery.Payload))
|
|
resp, doErr := d.http.Do(req)
|
|
if doErr != nil {
|
|
sendErr = doErr
|
|
} else {
|
|
code := int32(resp.StatusCode)
|
|
statusCode = &code
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
|
|
resp.Body.Close()
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
now := time.Now()
|
|
d.mark(delivery, "delivered", statusCode, "", &now)
|
|
return
|
|
}
|
|
sendErr = fmt.Errorf("endpoint returned %d", resp.StatusCode)
|
|
}
|
|
} else {
|
|
sendErr = err
|
|
}
|
|
|
|
attempts := delivery.Attempts + 1
|
|
if attempts >= webhookMaxAttempts {
|
|
d.mark(delivery, "failed", statusCode, errString(sendErr), nil)
|
|
return
|
|
}
|
|
retryAt := time.Now().Add(time.Duration(attempts*attempts) * 15 * time.Second)
|
|
d.mark(delivery, "pending", statusCode, errString(sendErr), nil, retryAt)
|
|
d.timerMu.Lock()
|
|
d.timers[delivery.ID] = time.AfterFunc(time.Duration(attempts*attempts)*15*time.Second, func() {
|
|
d.timerMu.Lock()
|
|
delete(d.timers, delivery.ID)
|
|
d.timerMu.Unlock()
|
|
d.push(delivery.ID)
|
|
})
|
|
d.timerMu.Unlock()
|
|
}
|
|
|
|
func (d *WebhookDispatcher) mark(delivery db.CoreWebhookDelivery, status string, statusCode *int32, lastError string, deliveredAt *time.Time, nextRetry ...time.Time) {
|
|
params := db.MarkWebhookDeliveryParams{
|
|
ID: delivery.ID,
|
|
Status: status,
|
|
LastError: lastError,
|
|
}
|
|
params.LastStatusCode = statusCode
|
|
if deliveredAt != nil {
|
|
params.DeliveredAt = pgtype.Timestamptz{Time: *deliveredAt, Valid: true}
|
|
}
|
|
if len(nextRetry) > 0 {
|
|
params.NextRetryAt = pgtype.Timestamptz{Time: nextRetry[0], Valid: true}
|
|
} else {
|
|
params.NextRetryAt = pgtype.Timestamptz{}
|
|
}
|
|
if err := d.repo.MarkWebhookDelivery(context.Background(), params); err != nil {
|
|
d.logger.Warn("webhook delivery update failed", "delivery", delivery.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
func errString(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
return err.Error()
|
|
}
|