This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user