Ctrl K
ComunidadGuías…

internal/rest/conversions.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}