package notification import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) const ( DefaultWebhookChannelID = "9bf6df3f-7a75-4f8c-91a0-b8d5a2548c21" WebhookSecretReference = "runtime:webhook-token" maxWebhookResponseBytes = 4 << 10 ) // SecretResolver resolves an opaque reference without ever putting the secret in // channel configuration or PostgreSQL. Implementations must allowlist references; // a database value must never become an arbitrary environment-variable reader. type SecretResolver interface { Resolve(context.Context, SecretRef) (string, error) } type SecretResolverFunc func(context.Context, SecretRef) (string, error) func (resolve SecretResolverFunc) Resolve(ctx context.Context, ref SecretRef) (string, error) { return resolve(ctx, ref) } type ChannelSenderFactory interface { Sender(context.Context, Channel) (ChannelSender, error) } type ChannelSenderFactoryFunc func(context.Context, Channel) (ChannelSender, error) func (factory ChannelSenderFactoryFunc) Sender(ctx context.Context, channel Channel) (ChannelSender, error) { return factory(ctx, channel) } type WebhookFactory struct { Secrets SecretResolver Client *http.Client AllowHTTP bool } func (factory WebhookFactory) Sender(ctx context.Context, channel Channel) (ChannelSender, error) { if channel.Type != "webhook" || factory.Secrets == nil { return nil, fmt.Errorf("%w: webhook factory is not configured", ErrInvalid) } endpoint, _ := channel.Configuration["url"].(string) timeout := 10 * time.Second if value, ok := number(channel.Configuration["timeoutSeconds"]); ok { timeout = time.Duration(value) * time.Second } secret, err := factory.Secrets.Resolve(ctx, channel.SecretRef) if err != nil { return nil, fmt.Errorf("resolve webhook credential: %w", err) } return NewWebhookSender(WebhookConfig{Endpoint: endpoint, BearerToken: secret, Timeout: timeout, AllowHTTP: factory.AllowHTTP}, factory.Client) } type WebhookConfig struct { Endpoint string BearerToken string Timeout time.Duration AllowHTTP bool } type WebhookSender struct { endpoint *url.URL token string client *http.Client } func NewWebhookSender(config WebhookConfig, client *http.Client) (*WebhookSender, error) { endpoint, err := url.Parse(strings.TrimSpace(config.Endpoint)) if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { return nil, fmt.Errorf("%w: webhook endpoint must be an absolute URL", ErrInvalid) } if endpoint.Scheme != "https" && !(config.AllowHTTP && endpoint.Scheme == "http") { return nil, fmt.Errorf("%w: webhook endpoint must use https", ErrInvalid) } if endpoint.User != nil || endpoint.Fragment != "" || endpoint.RawQuery != "" { return nil, fmt.Errorf("%w: webhook endpoint may not contain credentials, query parameters, or a fragment", ErrInvalid) } if strings.ContainsAny(config.BearerToken, "\r\n\x00") || len(config.BearerToken) > 4096 { return nil, fmt.Errorf("%w: webhook credential is invalid", ErrInvalid) } timeout := config.Timeout if timeout <= 0 { timeout = 10 * time.Second } if timeout < time.Second || timeout > 30*time.Second { return nil, fmt.Errorf("%w: webhook timeout must be between 1s and 30s", ErrInvalid) } if client == nil { client = &http.Client{} } bounded := *client bounded.Timeout = timeout bounded.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } return &WebhookSender{endpoint: endpoint, token: config.BearerToken, client: &bounded}, nil } type webhookPayload struct { Version string `json:"version"` DeliveryID string `json:"deliveryId"` IdempotencyKey string `json:"idempotencyKey"` EventType EventType `json:"eventType"` Subject string `json:"subject"` Body string `json:"body"` Attempt int `json:"attempt"` } func (sender *WebhookSender) Send(ctx context.Context, delivery Delivery) error { if sender == nil || sender.endpoint == nil || sender.client == nil { return ErrUnavailable } if err := ctx.Err(); err != nil { return err } if err := delivery.Validate(); err != nil { return err } payload, err := json.Marshal(webhookPayload{ Version: "1", DeliveryID: delivery.ID, IdempotencyKey: delivery.IdempotencyKey, EventType: delivery.EventType, Subject: delivery.Subject, Body: delivery.Body, Attempt: delivery.Attempt, }) if err != nil { return fmt.Errorf("encode webhook delivery: %w", err) } request, err := http.NewRequestWithContext(ctx, http.MethodPost, sender.endpoint.String(), bytes.NewReader(payload)) if err != nil { return fmt.Errorf("create webhook request: %w", err) } request.Header.Set("Content-Type", "application/json") request.Header.Set("User-Agent", "ITWorx-Pulse/1 notification-webhook") request.Header.Set("Idempotency-Key", delivery.IdempotencyKey) request.Header.Set("X-Pulse-Event", string(delivery.EventType)) request.Header.Set("X-Pulse-Attempt", strconv.Itoa(delivery.Attempt)) if sender.token != "" { request.Header.Set("Authorization", "Bearer "+sender.token) } response, err := sender.client.Do(request) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return err } return errors.New("webhook receiver is unreachable") } defer response.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxWebhookResponseBytes)) if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { return fmt.Errorf("webhook receiver returned HTTP %d", response.StatusCode) } return nil } func number(value any) (int64, bool) { switch typed := value.(type) { case float64: if typed != float64(int64(typed)) { return 0, false } return int64(typed), true case int: return int64(typed), true case int64: return typed, true case json.Number: parsed, err := typed.Int64() return parsed, err == nil default: return 0, false } }