mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-11 08:06:13 +00:00
Add sequence action-node storage and execution, template conditional rendering, lead progress state, profile updates, webhook fan-out throttling, and supporting repository fixes.
532 lines
18 KiB
Go
532 lines
18 KiB
Go
// Package webhook is the customer-facing event-delivery system. Other
|
|
// services call Dispatch() with a (orgID, eventType, payload). The
|
|
// service fans out to every subscribed endpoint by enqueueing rows in
|
|
// webhook_deliveries; a background DeliveryWorker drains the queue.
|
|
package webhook
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/warmbly/warmbly/internal/infrastructure/cache"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// SignatureHeader is the HTTP header carrying the HMAC signature on each
|
|
// outbound webhook POST. Format matches Stripe's: `t=<unix>,v1=<hex>`.
|
|
const SignatureHeader = "X-Warmbly-Signature"
|
|
|
|
// EventHeader carries the event type so subscribers can route without
|
|
// having to parse the body.
|
|
const EventHeader = "X-Warmbly-Event"
|
|
|
|
// EventIDHeader carries the unique event identifier so subscribers can
|
|
// dedupe replays.
|
|
const EventIDHeader = "X-Warmbly-Event-Id"
|
|
|
|
// DispatchSink receives every dispatched event so other subsystems (notably
|
|
// third-party integration actions: Slack pings, CRM upserts) can react to the
|
|
// same event vocabulary that drives customer webhooks, without every call site
|
|
// having to know about them. Wired optionally via WireDispatchSink.
|
|
type DispatchSink func(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any)
|
|
|
|
// Service is the call-site API. Internal events call Dispatch — the
|
|
// service writes one delivery row per matching endpoint and returns
|
|
// immediately. The DeliveryWorker drains the queue asynchronously.
|
|
type Service interface {
|
|
// Dispatch finds endpoints subscribed to (orgID, eventType) and
|
|
// enqueues a delivery for each. Returns the event ID so callers can
|
|
// log/audit. If no endpoints match, this is a no-op.
|
|
Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) (uuid.UUID, error)
|
|
|
|
// WireDispatchSink attaches a single fan-out sink invoked for every
|
|
// dispatched event (even when no webhook endpoint matches). Idempotent
|
|
// replacement; pass nil to detach.
|
|
WireDispatchSink(sink DispatchSink)
|
|
|
|
// WireThrottle attaches a Redis-backed per-org, per-event-type dispatch
|
|
// throttle. resolveLimit returns the org's per-minute cap on how many events
|
|
// of a single type it may fan out (plan-based — see organization
|
|
// WebhookDispatchLimit); over the cap, further events of that type in the
|
|
// same minute are dropped (logged) instead of reaching webhooks or
|
|
// integration sinks. The guard against a campaign "notify" action — or any
|
|
// per-contact event — flooding a customer's endpoints. The resolved cap is
|
|
// cached briefly so resolveLimit is not hit on every event. Pass a nil cache
|
|
// or nil resolver to disable (fail-open).
|
|
WireThrottle(c *cache.Cache, resolveLimit func(ctx context.Context, orgID uuid.UUID) int)
|
|
|
|
// Endpoint CRUD wrappers.
|
|
CreateEndpoint(ctx context.Context, orgID uuid.UUID, url, description string, eventTypes []string, enabled bool) (*models.WebhookEndpointWithSecret, error)
|
|
UpdateEndpoint(ctx context.Context, orgID, endpointID uuid.UUID, url, description string, eventTypes []string, enabled bool) (*models.WebhookEndpoint, error)
|
|
DeleteEndpoint(ctx context.Context, orgID, endpointID uuid.UUID) error
|
|
RotateSecret(ctx context.Context, orgID, endpointID uuid.UUID) (string, error)
|
|
ListEndpoints(ctx context.Context, orgID uuid.UUID) ([]models.WebhookEndpoint, error)
|
|
ListDeliveries(ctx context.Context, orgID, endpointID uuid.UUID, limit int) ([]models.WebhookDelivery, error)
|
|
}
|
|
|
|
type service struct {
|
|
repo repository.WebhookRepository
|
|
now func() time.Time
|
|
sink DispatchSink
|
|
cache *cache.Cache
|
|
resolveLimit func(ctx context.Context, orgID uuid.UUID) int
|
|
}
|
|
|
|
func NewService(repo repository.WebhookRepository) Service {
|
|
return &service{repo: repo, now: time.Now}
|
|
}
|
|
|
|
func (s *service) WireDispatchSink(sink DispatchSink) {
|
|
s.sink = sink
|
|
}
|
|
|
|
func (s *service) WireThrottle(c *cache.Cache, resolveLimit func(ctx context.Context, orgID uuid.UUID) int) {
|
|
s.cache = c
|
|
s.resolveLimit = resolveLimit
|
|
}
|
|
|
|
// StaticLimit adapts a fixed per-minute cap into a throttle resolver, for
|
|
// callers without plan context (e.g. the consumer, which dispatches lower-volume
|
|
// reply/warmup events rather than per-contact campaign fan-out).
|
|
func StaticLimit(perMinute int) func(context.Context, uuid.UUID) int {
|
|
return func(context.Context, uuid.UUID) int { return perMinute }
|
|
}
|
|
|
|
// orgLimit returns the org's per-minute dispatch cap, caching the resolved
|
|
// value in Redis for a minute so resolveLimit (a plan lookup) is not hit on
|
|
// every event.
|
|
func (s *service) orgLimit(ctx context.Context, orgID uuid.UUID) int {
|
|
key := "wh:limit:" + orgID.String()
|
|
if v, err := s.cache.Get(ctx, key).Int(); err == nil && v > 0 {
|
|
return v
|
|
}
|
|
limit := s.resolveLimit(ctx, orgID)
|
|
if limit > 0 {
|
|
s.cache.Set(ctx, key, limit, time.Minute)
|
|
}
|
|
return limit
|
|
}
|
|
|
|
// throttled reports whether this (org, eventType) has exceeded its per-minute
|
|
// dispatch cap. Fixed one-minute window in Redis; fail-open on any cache error
|
|
// so a Redis hiccup never silently swallows events. A no-op (returns false)
|
|
// when no throttle is wired.
|
|
func (s *service) throttled(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType) bool {
|
|
if s.cache == nil || s.resolveLimit == nil {
|
|
return false
|
|
}
|
|
limit := s.orgLimit(ctx, orgID)
|
|
if limit <= 0 {
|
|
return false // resolver opted out / fail-open
|
|
}
|
|
bucket := s.now().Unix() / 60
|
|
key := fmt.Sprintf("wh:disp:%s:%s:%d", orgID, eventType, bucket)
|
|
n, err := s.cache.Incr(ctx, key).Result()
|
|
if err != nil {
|
|
return false // fail-open
|
|
}
|
|
if n == 1 {
|
|
// First hit in this window — set a short TTL so the key self-expires.
|
|
s.cache.Expire(ctx, key, 2*time.Minute)
|
|
}
|
|
if n > int64(limit) {
|
|
log.Warn().
|
|
Str("org_id", orgID.String()).
|
|
Str("event_type", string(eventType)).
|
|
Int64("count", n).
|
|
Int("limit_per_min", limit).
|
|
Msg("Webhook dispatch throttled — dropping event for this minute")
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *service) Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) (uuid.UUID, error) {
|
|
eventID := uuid.New()
|
|
|
|
// Global per-org, per-event-type fan-out throttle. Stops a per-contact
|
|
// event source (notably a campaign "notify" action over a large lead list)
|
|
// from flooding the org's webhooks and integration sinks. Checked before
|
|
// both the sink and endpoint enqueue so an over-cap event reaches neither.
|
|
if s.throttled(ctx, orgID, eventType) {
|
|
return eventID, nil
|
|
}
|
|
|
|
// Fan the event to non-webhook subscribers (integration actions) first,
|
|
// independently of whether any webhook endpoint is configured.
|
|
if s.sink != nil {
|
|
s.sink(ctx, orgID, eventType, data)
|
|
}
|
|
|
|
endpoints, err := s.repo.MatchingEndpoints(ctx, orgID, eventType)
|
|
if err != nil {
|
|
return eventID, err
|
|
}
|
|
if len(endpoints) == 0 {
|
|
return eventID, nil
|
|
}
|
|
|
|
payload := models.WebhookPayload{
|
|
ID: eventID,
|
|
EventType: eventType,
|
|
OrganizationID: orgID,
|
|
CreatedAt: s.now().UTC(),
|
|
Data: data,
|
|
}
|
|
payloadJSON, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return eventID, fmt.Errorf("marshal webhook payload: %w", err)
|
|
}
|
|
|
|
for i := range endpoints {
|
|
delivery := &models.WebhookDelivery{
|
|
EndpointID: endpoints[i].ID,
|
|
OrganizationID: orgID,
|
|
EventType: string(eventType),
|
|
EventID: eventID,
|
|
Payload: payloadJSON,
|
|
}
|
|
if err := s.repo.EnqueueDelivery(ctx, delivery); err != nil {
|
|
log.Warn().Err(err).Str("endpoint_id", endpoints[i].ID.String()).Msg("Failed to enqueue webhook delivery")
|
|
}
|
|
}
|
|
return eventID, nil
|
|
}
|
|
|
|
func (s *service) CreateEndpoint(ctx context.Context, orgID uuid.UUID, rawURL, description string, eventTypes []string, enabled bool) (*models.WebhookEndpointWithSecret, error) {
|
|
if err := validateURL(rawURL); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateEventTypes(eventTypes); err != nil {
|
|
return nil, err
|
|
}
|
|
secret, err := generateSecret()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
endpoint := &models.WebhookEndpoint{
|
|
ID: uuid.New(),
|
|
OrganizationID: orgID,
|
|
URL: rawURL,
|
|
Description: description,
|
|
EventTypes: eventTypes,
|
|
Enabled: enabled,
|
|
}
|
|
if err := s.repo.CreateEndpoint(ctx, endpoint, secret); err != nil {
|
|
return nil, err
|
|
}
|
|
return &models.WebhookEndpointWithSecret{
|
|
WebhookEndpoint: *endpoint,
|
|
Secret: secret,
|
|
}, nil
|
|
}
|
|
|
|
func (s *service) UpdateEndpoint(ctx context.Context, orgID, endpointID uuid.UUID, rawURL, description string, eventTypes []string, enabled bool) (*models.WebhookEndpoint, error) {
|
|
if err := validateURL(rawURL); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateEventTypes(eventTypes); err != nil {
|
|
return nil, err
|
|
}
|
|
endpoint := &models.WebhookEndpoint{
|
|
ID: endpointID,
|
|
OrganizationID: orgID,
|
|
URL: rawURL,
|
|
Description: description,
|
|
EventTypes: eventTypes,
|
|
Enabled: enabled,
|
|
}
|
|
if err := s.repo.UpdateEndpoint(ctx, endpoint); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.GetEndpoint(ctx, orgID, endpointID)
|
|
}
|
|
|
|
func (s *service) DeleteEndpoint(ctx context.Context, orgID, endpointID uuid.UUID) error {
|
|
return s.repo.DeleteEndpoint(ctx, orgID, endpointID)
|
|
}
|
|
|
|
func (s *service) RotateSecret(ctx context.Context, orgID, endpointID uuid.UUID) (string, error) {
|
|
secret, err := generateSecret()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := s.repo.RotateSecret(ctx, orgID, endpointID, secret); err != nil {
|
|
return "", err
|
|
}
|
|
return secret, nil
|
|
}
|
|
|
|
func (s *service) ListEndpoints(ctx context.Context, orgID uuid.UUID) ([]models.WebhookEndpoint, error) {
|
|
return s.repo.ListEndpointsForOrg(ctx, orgID)
|
|
}
|
|
|
|
func (s *service) ListDeliveries(ctx context.Context, orgID, endpointID uuid.UUID, limit int) ([]models.WebhookDelivery, error) {
|
|
return s.repo.ListDeliveriesForEndpoint(ctx, orgID, endpointID, limit)
|
|
}
|
|
|
|
// generateSecret returns a 32-byte cryptographically-random hex string.
|
|
// 64 hex chars ≈ 256 bits — comfortable margin for HMAC-SHA256.
|
|
func generateSecret() (string, error) {
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return "whsec_" + hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
// ValidateOutboundURL is the exported SSRF/HTTPS guard reused by any subsystem
|
|
// that stores a user-supplied URL we will later POST to (e.g. third-party
|
|
// integration actions such as Discord/generic webhooks). Same policy as the
|
|
// customer-webhook endpoints: HTTPS + publicly-routable host, unless
|
|
// WARMBLY_ALLOW_UNSAFE_WEBHOOK_URLS=true for local/self-hosted development.
|
|
func ValidateOutboundURL(raw string) error {
|
|
return validateURL(raw)
|
|
}
|
|
|
|
// validateURL keeps malformed entries and obvious SSRF targets out of the
|
|
// table. Public webhook endpoints must use HTTPS and route to public hosts.
|
|
// Local/self-hosted development can set WARMBLY_ALLOW_UNSAFE_WEBHOOK_URLS=true
|
|
// to permit HTTP and private targets.
|
|
func validateURL(raw string) error {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return fmt.Errorf("url is required")
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid url: %w", err)
|
|
}
|
|
allowUnsafe := strings.EqualFold(os.Getenv("WARMBLY_ALLOW_UNSAFE_WEBHOOK_URLS"), "true")
|
|
if u.Scheme != "https" && !(allowUnsafe && u.Scheme == "http") {
|
|
return fmt.Errorf("url scheme must be https")
|
|
}
|
|
if u.Host == "" {
|
|
return fmt.Errorf("url must have a host")
|
|
}
|
|
if !allowUnsafe && isPrivateWebhookHost(u.Hostname()) {
|
|
return fmt.Errorf("url host must be publicly routable")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isPrivateWebhookHost(host string) bool {
|
|
host = strings.Trim(strings.ToLower(host), "[]")
|
|
if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
return ip.IsLoopback() ||
|
|
ip.IsPrivate() ||
|
|
ip.IsLinkLocalUnicast() ||
|
|
ip.IsLinkLocalMulticast() ||
|
|
ip.IsUnspecified()
|
|
}
|
|
|
|
func validateEventTypes(eventTypes []string) error {
|
|
for _, t := range eventTypes {
|
|
if !models.IsValidWebhookEventType(t) {
|
|
return fmt.Errorf("unknown event type: %s", t)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Sign produces the HMAC-SHA256 hex digest of `t.payload` using the secret.
|
|
// Subscribers verify by recomputing the same digest from the timestamp +
|
|
// body + their stored secret.
|
|
func Sign(secret string, timestamp time.Time, payload []byte) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
fmt.Fprintf(mac, "%d.", timestamp.Unix())
|
|
mac.Write(payload)
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// FormatSignatureHeader returns the `t=<unix>,v1=<hex>` value the
|
|
// dispatcher sets on every outbound POST.
|
|
func FormatSignatureHeader(timestamp time.Time, signature string) string {
|
|
return fmt.Sprintf("t=%d,v1=%s", timestamp.Unix(), signature)
|
|
}
|
|
|
|
// DeliveryWorker drains the webhook_deliveries queue. Multiple workers can
|
|
// run concurrently — ClaimDueDeliveries uses SKIP LOCKED to prevent
|
|
// duplicate dispatch.
|
|
type DeliveryWorker struct {
|
|
repo repository.WebhookRepository
|
|
httpClient *http.Client
|
|
batchSize int
|
|
pollEvery time.Duration
|
|
}
|
|
|
|
// DeliveryWorkerOptions configures the worker. Sensible defaults are
|
|
// applied for zero values.
|
|
type DeliveryWorkerOptions struct {
|
|
BatchSize int
|
|
PollEvery time.Duration
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
func NewDeliveryWorker(repo repository.WebhookRepository, opts DeliveryWorkerOptions) *DeliveryWorker {
|
|
if opts.BatchSize <= 0 {
|
|
opts.BatchSize = 25
|
|
}
|
|
if opts.PollEvery <= 0 {
|
|
opts.PollEvery = 2 * time.Second
|
|
}
|
|
if opts.HTTPClient == nil {
|
|
opts.HTTPClient = &http.Client{Timeout: 15 * time.Second}
|
|
}
|
|
return &DeliveryWorker{
|
|
repo: repo,
|
|
httpClient: opts.HTTPClient,
|
|
batchSize: opts.BatchSize,
|
|
pollEvery: opts.PollEvery,
|
|
}
|
|
}
|
|
|
|
// Run blocks until ctx is cancelled, draining the delivery queue on a
|
|
// fixed interval. Errors are logged but do not stop the loop — the queue
|
|
// stays in Postgres, so a crashed dispatcher resumes on restart.
|
|
func (w *DeliveryWorker) Run(ctx context.Context) {
|
|
t := time.NewTicker(w.pollEvery)
|
|
defer t.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
w.tick(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *DeliveryWorker) tick(ctx context.Context) {
|
|
deliveries, err := w.repo.ClaimDueDeliveries(ctx, w.batchSize)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Failed to claim webhook deliveries")
|
|
return
|
|
}
|
|
for i := range deliveries {
|
|
w.deliver(ctx, &deliveries[i])
|
|
}
|
|
}
|
|
|
|
func (w *DeliveryWorker) deliver(ctx context.Context, d *models.WebhookDelivery) {
|
|
secret, err := w.repo.GetEndpointSecret(ctx, d.EndpointID)
|
|
if err != nil {
|
|
w.fail(ctx, d, nil, fmt.Sprintf("load secret: %v", err), "")
|
|
return
|
|
}
|
|
|
|
timestamp := time.Now().UTC()
|
|
signature := Sign(secret, timestamp, d.Payload)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "", bytes.NewReader(d.Payload))
|
|
if err != nil {
|
|
w.fail(ctx, d, nil, fmt.Sprintf("build request: %v", err), "")
|
|
return
|
|
}
|
|
|
|
// Set URL via the endpoint lookup so we always use the current value.
|
|
endpoint, err := w.repo.GetEndpoint(ctx, d.OrganizationID, d.EndpointID)
|
|
if err != nil || endpoint == nil {
|
|
w.fail(ctx, d, nil, "endpoint missing", "")
|
|
return
|
|
}
|
|
|
|
parsed, err := url.Parse(endpoint.URL)
|
|
if err != nil {
|
|
w.fail(ctx, d, nil, fmt.Sprintf("parse endpoint url: %v", err), "")
|
|
return
|
|
}
|
|
req.URL = parsed
|
|
req.Host = parsed.Host
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "Warmbly-Webhooks/1.0")
|
|
req.Header.Set(SignatureHeader, FormatSignatureHeader(timestamp, signature))
|
|
req.Header.Set(EventHeader, d.EventType)
|
|
req.Header.Set(EventIDHeader, d.EventID.String())
|
|
|
|
resp, err := w.httpClient.Do(req)
|
|
if err != nil {
|
|
w.fail(ctx, d, nil, fmt.Sprintf("http error: %v", err), "")
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
bodyExcerpt := readBodyExcerpt(resp.Body, 1024)
|
|
|
|
// 2xx = success. 4xx = caller's fault, do not retry indefinitely (still
|
|
// retry briefly in case it's a transient validation flap). 5xx = retry.
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
if err := w.repo.MarkDelivered(ctx, d.ID, resp.StatusCode); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to mark webhook delivered")
|
|
}
|
|
_ = w.repo.UpdateEndpointHealthOnSuccess(ctx, d.EndpointID)
|
|
return
|
|
}
|
|
w.fail(ctx, d, &resp.StatusCode, fmt.Sprintf("non-2xx response: %d", resp.StatusCode), bodyExcerpt)
|
|
}
|
|
|
|
func (w *DeliveryWorker) fail(ctx context.Context, d *models.WebhookDelivery, status *int, reason, bodyExcerpt string) {
|
|
_ = w.repo.UpdateEndpointHealthOnFailure(ctx, d.EndpointID, reason)
|
|
|
|
if d.AttemptCount >= d.MaxAttempts {
|
|
if err := w.repo.MarkAbandoned(ctx, d.ID, reason); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to mark webhook abandoned")
|
|
}
|
|
return
|
|
}
|
|
nextAttempt := time.Now().UTC().Add(backoffFor(d.AttemptCount))
|
|
if err := w.repo.MarkRetry(ctx, d.ID, nextAttempt, status, reason, bodyExcerpt); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to schedule webhook retry")
|
|
}
|
|
}
|
|
|
|
// backoffFor returns the wait between attempts: doubles each retry,
|
|
// capped at 1 hour. Attempt 1 (just failed) → 30s, then 1m, 2m, 4m, 8m,
|
|
// 16m, 32m, capped at 60m. With max_attempts=8 the longest possible window
|
|
// before abandonment is ~2 hours.
|
|
func backoffFor(attempt int) time.Duration {
|
|
const cap = time.Hour
|
|
base := 30 * time.Second
|
|
for i := 0; i < attempt-1; i++ {
|
|
base *= 2
|
|
if base >= cap {
|
|
return cap
|
|
}
|
|
}
|
|
return base
|
|
}
|
|
|
|
func readBodyExcerpt(r io.Reader, max int) string {
|
|
if r == nil {
|
|
return ""
|
|
}
|
|
buf := make([]byte, max)
|
|
n, _ := io.ReadFull(r, buf)
|
|
return string(buf[:n])
|
|
}
|