Files
ITWorx-Pulse-Public/internal/notification/webhook.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

183 lines
5.9 KiB
Go

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
}
}