mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51: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>
250 lines
8.2 KiB
Go
250 lines
8.2 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
|
)
|
|
|
|
// Inbound hooks are project-scoped public ingest endpoints:
|
|
// POST /api/v1/hooks/<token>. A hook either republishes the body as an
|
|
// `inbound.received` domain event (webhooks + realtime fan out for free) or
|
|
// triggers a scheduled-job run with the body as the payload. When a secret is
|
|
// set, callers must sign the raw body with X-Primora-Signature — the same
|
|
// HMAC-SHA256 scheme outbound deliveries use.
|
|
|
|
const (
|
|
WebhookEventInboundReceived = "inbound.received"
|
|
|
|
InboundModeEvent = "event"
|
|
InboundModeJob = "job"
|
|
)
|
|
|
|
type InboundHookSummary struct {
|
|
ID string `json:"id"`
|
|
ProjectID string `json:"project_id"`
|
|
Name string `json:"name"`
|
|
Token string `json:"token"`
|
|
URL string `json:"url"`
|
|
Mode string `json:"mode"`
|
|
JobID string `json:"job_id,omitempty"`
|
|
HasSecret bool `json:"has_secret"`
|
|
Enabled bool `json:"enabled"`
|
|
LastReceivedAt *time.Time `json:"last_received_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type CreateInboundHookInput struct {
|
|
Name string `json:"name" validate:"required"`
|
|
Mode string `json:"mode"`
|
|
JobID string `json:"job_id"`
|
|
Secret string `json:"secret"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
|
|
type InboundReceiveResult struct {
|
|
Received bool `json:"received"`
|
|
Event string `json:"event,omitempty"`
|
|
RunID string `json:"run_id,omitempty"`
|
|
}
|
|
|
|
func inboundHookSummary(row db.CoreInboundHook, publicURL string) InboundHookSummary {
|
|
out := InboundHookSummary{
|
|
ID: row.ID.String(),
|
|
ProjectID: row.ProjectID.String(),
|
|
Name: row.Name,
|
|
Token: row.Token,
|
|
URL: strings.TrimSuffix(publicURL, "/") + "/api/v1/hooks/" + row.Token,
|
|
Mode: row.Mode,
|
|
HasSecret: len(row.Secret) > 0,
|
|
Enabled: row.Enabled,
|
|
CreatedAt: row.CreatedAt.Time,
|
|
}
|
|
if row.JobID.Valid {
|
|
out.JobID = uuid.UUID(row.JobID.Bytes).String()
|
|
}
|
|
if row.LastReceivedAt.Valid {
|
|
t := row.LastReceivedAt.Time
|
|
out.LastReceivedAt = &t
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *PlatformService) ListInboundHooks(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]InboundHookSummary, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.repo.Queries().ListInboundHooks(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]InboundHookSummary, len(rows))
|
|
for i, r := range rows {
|
|
out[i] = inboundHookSummary(r, s.publicURL)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *PlatformService) CreateInboundHook(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateInboundHookInput, requestID string) (InboundHookSummary, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
|
return InboundHookSummary{}, err
|
|
}
|
|
if !projectSecretNameRe.MatchString(input.Name) {
|
|
return InboundHookSummary{}, fmt.Errorf("invalid hook name %q — use [A-Za-z_][A-Za-z0-9_]*", input.Name)
|
|
}
|
|
mode := input.Mode
|
|
if mode == "" {
|
|
mode = InboundModeEvent
|
|
}
|
|
var jobID pgtype.UUID
|
|
if mode == InboundModeJob {
|
|
parsed, err := uuid.Parse(input.JobID)
|
|
if err != nil {
|
|
return InboundHookSummary{}, errors.New("job mode requires a valid job_id")
|
|
}
|
|
job, err := s.repo.Queries().GetScheduledJobByID(ctx, parsed)
|
|
if err != nil || job.ProjectID != projectID {
|
|
return InboundHookSummary{}, errors.New("job not found in this project")
|
|
}
|
|
jobID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
} else if mode != InboundModeEvent {
|
|
return InboundHookSummary{}, fmt.Errorf("invalid mode %q — event or job", input.Mode)
|
|
}
|
|
secretBytes := []byte{}
|
|
if input.Secret != "" {
|
|
if s.enc == nil {
|
|
return InboundHookSummary{}, errors.New("encryption is not configured — cannot store hook secrets")
|
|
}
|
|
var err error
|
|
if secretBytes, err = s.enc.Encrypt([]byte(input.Secret)); err != nil {
|
|
return InboundHookSummary{}, err
|
|
}
|
|
}
|
|
token := "prm_hook_" + randHex(16)
|
|
enabled := true
|
|
if input.Enabled != nil {
|
|
enabled = *input.Enabled
|
|
}
|
|
row, err := s.repo.Queries().CreateInboundHook(ctx, db.CreateInboundHookParams{
|
|
ProjectID: projectID,
|
|
Name: input.Name,
|
|
Token: token,
|
|
Mode: mode,
|
|
JobID: jobID,
|
|
Secret: secretBytes,
|
|
Enabled: enabled,
|
|
})
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "inbound_hooks_project_id_name_key") {
|
|
return InboundHookSummary{}, fmt.Errorf("an inbound hook named %s already exists", input.Name)
|
|
}
|
|
return InboundHookSummary{}, err
|
|
}
|
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
if err == nil {
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.created", "inbound_hook", row.ID.String(), map[string]any{
|
|
"name": row.Name, "mode": row.Mode,
|
|
}))
|
|
}
|
|
return inboundHookSummary(row, s.publicURL), nil
|
|
}
|
|
|
|
func (s *PlatformService) DeleteInboundHook(ctx context.Context, actor *models.Actor, projectID, hookID uuid.UUID, requestID string) error {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.repo.Queries().GetInboundHook(ctx, db.GetInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.repo.Queries().DeleteInboundHook(ctx, db.DeleteInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil {
|
|
return err
|
|
}
|
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
if err == nil {
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.deleted", "inbound_hook", hookID.String(), nil))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReceiveInboundHook handles POST /api/v1/hooks/:token — unauthenticated; the
|
|
// token is the credential. Signature check applies when a secret is set.
|
|
func (s *PlatformService) ReceiveInboundHook(ctx context.Context, token string, signature string, body []byte) (InboundReceiveResult, error) {
|
|
hook, err := s.repo.Queries().GetInboundHookByToken(ctx, token)
|
|
if err != nil {
|
|
return InboundReceiveResult{}, err // pgx.ErrNoRows → 404
|
|
}
|
|
if !hook.Enabled {
|
|
return InboundReceiveResult{}, errors.New("hook is disabled")
|
|
}
|
|
if len(hook.Secret) > 0 {
|
|
if s.enc == nil {
|
|
return InboundReceiveResult{}, errors.New("encryption is not configured")
|
|
}
|
|
plain, err := s.enc.Decrypt(hook.Secret)
|
|
if err != nil {
|
|
return InboundReceiveResult{}, err
|
|
}
|
|
want := "sha256=" + signBody(string(plain), body)
|
|
if subtle.ConstantTimeCompare([]byte(signature), []byte(want)) != 1 {
|
|
return InboundReceiveResult{}, errors.New("invalid signature")
|
|
}
|
|
}
|
|
var payload map[string]any
|
|
if len(body) > 0 {
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return InboundReceiveResult{}, fmt.Errorf("invalid JSON body: %w", err)
|
|
}
|
|
}
|
|
_ = s.repo.Queries().TouchInboundHook(ctx, hook.ID)
|
|
|
|
switch hook.Mode {
|
|
case InboundModeJob:
|
|
if s.scheduler == nil {
|
|
return InboundReceiveResult{}, errors.New("scheduler unavailable")
|
|
}
|
|
if !hook.JobID.Valid {
|
|
return InboundReceiveResult{}, errors.New("hook has no job configured")
|
|
}
|
|
job, err := s.repo.Queries().GetScheduledJobByID(ctx, hook.JobID.Bytes)
|
|
if err != nil {
|
|
return InboundReceiveResult{}, err
|
|
}
|
|
if payload != nil {
|
|
raw, _ := json.Marshal(payload)
|
|
job.Payload = raw // received body replaces the configured payload
|
|
}
|
|
run, err := s.scheduler.enqueue(ctx, job, "hook")
|
|
if err != nil {
|
|
return InboundReceiveResult{}, err
|
|
}
|
|
return InboundReceiveResult{Received: true, Event: WebhookEventJobRun, RunID: run.ID.String()}, nil
|
|
default:
|
|
s.publishEvent(ctx, hook.ProjectID, WebhookEventInboundReceived, map[string]any{
|
|
"hook_id": hook.ID.String(),
|
|
"hook_name": hook.Name,
|
|
"payload": payload,
|
|
})
|
|
return InboundReceiveResult{Received: true, Event: WebhookEventInboundReceived}, nil
|
|
}
|
|
}
|
|
|
|
func randHex(n int) string {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|