Public source validation / validate (push) Failing after 3m8s
183 lines
5.9 KiB
Go
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
|
|
}
|
|
}
|