internal/rest/conversions.go
Rama main · réplica de lectura del repositorio original.
package rest
import (
"context"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
"github.com/sarathsp06/sparrow/internal/webhooks"
"github.com/sarathsp06/sparrow/internal/webhooks/store"
)
// conversionService is the slice of the service that webhook response
// conversion consumes: secret masking plus batch subscription lookup.
type conversionService interface {
webhooks.SecretRevealer
ListSubscriptionsByWebhookIDs(ctx context.Context, webhookIDs []uuid.UUID) ([]*store.EventSubscription, error)
}
// maskSecret masks a plaintext secret for API responses. Only the whsec_
// prefix (if present) stays visible; no plaintext secret bytes are exposed.
func maskSecret(secret string) string {
if secret == "" {
return ""
}
const prefix = "whsec_"
if rest, ok := strings.CutPrefix(secret, prefix); ok {
return prefix + strings.Repeat("•", len(rest))
}
return strings.Repeat("•", len(secret))
}
// maskEncryptedSecret decrypts an encrypted webhook secret and masks it for
// safe display in API responses.
func maskEncryptedSecret(encrypted []byte, svc webhooks.SecretRevealer) string {
if len(encrypted) == 0 {
return ""
}
plaintext, err := svc.DecryptWebhookSecret(encrypted)
if err != nil || plaintext == "" {
return "••••••"
}
return maskSecret(plaintext)
}
// maskSecretHeaders decrypts encrypted secret headers and masks every value.
func maskSecretHeaders(encrypted []byte, svc webhooks.SecretRevealer) map[string]string {
if len(encrypted) == 0 {
return nil
}
decrypted, err := svc.DecryptSecretHeaders(encrypted)
if err != nil || len(decrypted) == 0 {
return nil
}
masked := make(map[string]string, len(decrypted))
for k := range decrypted {
masked[k] = "••••••"
}
return masked
}
// getWebhookEventsMap batch-fetches subscribed event names for multiple
// webhooks in a single query, keyed by webhook id string.
func getWebhookEventsMap(ctx context.Context, svc conversionService, regs []*store.WebhookRegistration) map[string][]string {
if len(regs) == 0 {
return map[string][]string{}
}
ids := make([]uuid.UUID, len(regs))
for i, r := range regs {
ids[i] = r.ID
}
subs, err := svc.ListSubscriptionsByWebhookIDs(ctx, ids)
if err != nil {
slog.ErrorContext(ctx, "failed to batch-fetch subscriptions", "error", err)
return map[string][]string{}
}
result := make(map[string][]string, len(ids))
for _, sub := range subs {
key := sub.WebhookID.String()
result[key] = append(result[key], sub.EventName)
}
return result
}
// WebhookHTTPConfigOut is the REST representation of per-webhook HTTP
// delivery configuration.
type WebhookHTTPConfigOut struct {
MaxRetries int `json:"max_retries" doc:"Retries after the first attempt before a delivery is marked failed (up to max_retries + 1 attempts)."`
RetryBackoffSeconds int `json:"retry_backoff_seconds" doc:"Base retry delay, in seconds; the delay after attempt N is retry_backoff_seconds * 2^(N-1), capped at 24 hours, minus a random 0-20%."`
CaptureResponseBody bool `json:"capture_response_body" doc:"false: up to 1 KiB of each response body is stored; true: up to SPARROW_MAX_CAPTURED_RESPONSE_BYTES (default 1 MiB)."`
FollowRedirects bool `json:"follow_redirects" doc:"Whether HTTP redirects returned by the endpoint are followed."`
VerifySSL bool `json:"verify_ssl" doc:"Whether the endpoint's TLS certificate is verified."`
RequestTimeoutSeconds int `json:"request_timeout_seconds" doc:"Per-attempt request timeout, in seconds."`
ExpectedStatusCodes []int32 `json:"expected_status_codes,omitempty" doc:"HTTP status codes treated as a successful delivery."`
WebhookSecret string `json:"webhook_secret,omitempty" doc:"HMAC signing secret. Shown in full only once, in the response to webhook registration; masked on every later read."`
UserAgent string `json:"user_agent,omitempty" doc:"Stored value; deliveries currently always send User-Agent: Sparrow-Webhook/<server version> unless headers override it."`
ContentType string `json:"content_type,omitempty" doc:"Stored value; deliveries currently send Content-Type: application/json unless headers override it."`
RateLimitRPS *float64 `json:"rate_limit_rps,omitempty" doc:"Delivery rate limit for this webhook, in requests per second; absent means no limit."`
}
// WebhookOut is the REST representation of a registered webhook (list/get).
// Secrets are always masked; use the value returned at creation time to
// retrieve the real secret once.
type WebhookOut struct {
WebhookID string `json:"webhook_id" doc:"Webhook id (UUID)."`
Consumer string `json:"consumer" doc:"Consumer this webhook belongs to."`
Events []string `json:"events" doc:"Event type names this webhook is currently subscribed to, derived from its subscriptions."`
URL string `json:"url" doc:"HTTP endpoint deliveries are POSTed to."`
Headers map[string]string `json:"headers,omitempty" doc:"Static HTTP headers sent with every delivery."`
Active bool `json:"active" doc:"Whether deliveries are sent. false: paused by an operator or auto-disabled; new deliveries are held as paused."`
Description string `json:"description,omitempty" doc:"Human-readable note about this webhook."`
Health string `json:"health" enum:"healthy,degraded,unhealthy,unknown" doc:"Health label from the last health evaluation (see getWebhookHealth)."`
SecretHeaders map[string]string `json:"secret_headers,omitempty" doc:"Encrypted secret header names, with values always masked. Update them via PATCH secret_headers; send an empty string to remove one."`
SigningPublicKey string `json:"signing_public_key,omitempty" doc:"Hex-encoded Ed25519 public key for verifying the v1a, delivery signature. Safe to expose; there is no private-key equivalent to mask."`
SignatureType string `json:"signature_type" enum:"hmac,ed25519" doc:"Signing scheme. \"hmac\" (default) signs every delivery with HMAC-SHA256 (v1,); \"ed25519\" adds an Ed25519 signature (v1a,) alongside the HMAC one."`
HTTPConfig WebhookHTTPConfigOut `json:"http_config" doc:"Per-webhook HTTP delivery configuration."`
RequiresTransform bool `json:"requires_transform" doc:"Whether every subscription must carry an enabled transform_template because the receiver only accepts transformed payloads."`
AutoDisabledAt string `json:"auto_disabled_at,omitempty" format:"date-time" doc:"Set when Sparrow paused this webhook itself because its receiver kept failing (see SPARROW_AUTO_DISABLE_AFTER), RFC3339. Cleared when the webhook is resumed."`
AutoDisabledReason string `json:"auto_disabled_reason,omitempty" doc:"Why Sparrow auto-disabled this webhook. Cleared when the webhook is resumed."`
CreatedAt string `json:"created_at" format:"date-time" doc:"Creation timestamp, RFC3339."`
UpdatedAt string `json:"updated_at" format:"date-time" doc:"Last-modified timestamp, RFC3339."`
}
func toWebhookOut(reg *store.WebhookRegistration, events []string, svc webhooks.SecretRevealer) WebhookOut {
codes := make([]int32, len(reg.ExpectedStatusCodes))
for i, c := range reg.ExpectedStatusCodes {
codes[i] = int32(c) //nolint:gosec // HTTP status codes, validated to 100-599 on save
}
return WebhookOut{
WebhookID: reg.ID.String(),
Consumer: reg.Consumer,
Events: events,
URL: reg.URL,
Headers: reg.Headers,
Active: reg.Active,
Description: reg.Description,
Health: string(reg.Health),
SecretHeaders: maskSecretHeaders(reg.SecretHeaders, svc),
SigningPublicKey: svc.WebhookSigningPublicKeyHex(reg.Ed25519PrivateKey),
SignatureType: string(reg.SignatureType),
RequiresTransform: reg.RequiresTransform,
HTTPConfig: WebhookHTTPConfigOut{
MaxRetries: reg.MaxRetries,
RetryBackoffSeconds: reg.RetryBackoffSeconds,
CaptureResponseBody: reg.CaptureResponseBody,
FollowRedirects: reg.FollowRedirects,
VerifySSL: reg.VerifySSL,
RequestTimeoutSeconds: reg.RequestTimeoutSeconds,
ExpectedStatusCodes: codes,
WebhookSecret: maskEncryptedSecret(reg.WebhookSecret, svc),
UserAgent: reg.UserAgent,
ContentType: reg.ContentType,
RateLimitRPS: reg.RateLimitRPS,
},
AutoDisabledAt: formatOptionalTime(reg.AutoDisabledAt),
AutoDisabledReason: derefString(reg.AutoDisabledReason),
CreatedAt: reg.CreatedAt.Format(time.RFC3339Nano),
UpdatedAt: reg.UpdatedAt.Format(time.RFC3339Nano),
}
}
// formatOptionalTime formats t as RFC 3339, or "" when it is nil.
func formatOptionalTime(t *time.Time) string {
if t == nil {
return ""
}
return t.Format(time.RFC3339Nano)
}
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}