Files
notify-gateway/internal/outbound/http.go
T
2026-09-16 06:26:16 +02:00

129 lines
3.9 KiB
Go

// Package outbound implements HTTP delivery independently of ingress protocols.
package outbound
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/example/notify-gateway/internal/config"
)
type Response struct {
StatusCode int
Body string
Retryable bool
RetryAfter time.Duration
}
func Send(ctx context.Context, destination config.OutboundConfig, payload map[string]any) (Response, error) {
return SendWithID(ctx, destination, payload, "")
}
func SendWithID(ctx context.Context, destination config.OutboundConfig, payload map[string]any, id string) (Response, error) {
if err := config.ValidateOutbound(destination); err != nil {
return Response{}, err
}
b, err := json.Marshal(payload)
if err != nil {
return Response{}, fmt.Errorf("encode outbound payload: %w", err)
}
if !destination.Live {
preview, _ := json.Marshal(map[string]any{"dry_run": true, "provider": destination.Provider, "body": payload})
return Response{StatusCode: http.StatusOK, Body: string(preview)}, nil
}
if destination.Provider == "smtp" {
return sendSMTP(ctx, destination, payload, id)
}
token, err := config.Secret(destination.BearerToken)
if err != nil {
return Response{}, err
}
if destination.Provider == "ntfy" {
p := map[string]any{"topic": destination.Topic, "title": payload["title"], "message": payload["message"]}
priority := 0
switch v := payload["priority"].(type) {
case int:
priority = v
case float64:
priority = int(v)
}
if priority > 0 {
p["priority"] = min(priority, 5)
}
b, err = json.Marshal(p)
if err != nil {
return Response{}, err
}
}
u, _ := url.Parse(destination.URL)
if destination.Provider == "discord" {
q := u.Query()
q.Set("wait", "true")
u.RawQuery = q.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(b))
if err != nil {
return Response{}, fmt.Errorf("outbound request could not be created")
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
if destination.Provider == "gotify" {
req.Header.Set("X-Gotify-Key", token)
} else {
req.Header.Set("Authorization", "Bearer "+token)
}
}
if id != "" {
req.Header.Set("Idempotency-Key", id)
req.Header.Set("X-Notify-Gateway-Delivery", id)
}
timeout := destination.TimeoutS
if timeout == 0 {
timeout = 15
}
client := &http.Client{
Timeout: time.Duration(timeout) * time.Second,
// Never forward webhook credentials to a redirect destination.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := client.Do(req)
if err != nil {
// url.Error embeds the URL, including Discord's secret token.
if ctx.Err() != nil {
return Response{Retryable: true}, ctx.Err()
}
return Response{Retryable: true}, fmt.Errorf("outbound HTTP request failed (connection or timeout)")
}
defer resp.Body.Close()
_, readErr := io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
out := Response{StatusCode: resp.StatusCode, Retryable: RetryableHTTP(resp.StatusCode), RetryAfter: ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now())}
// Remote bodies may echo credentials or content. Only expose the status.
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return out, fmt.Errorf("outbound HTTP %d", resp.StatusCode)
}
if readErr != nil {
out.Retryable = true
return out, fmt.Errorf("outbound response could not be read")
}
return out, nil
}
func RetryableHTTP(code int) bool {
return code == 0 || code == 408 || code == 425 || code == 429 || code >= 500
}
func ParseRetryAfter(value string, now time.Time) time.Duration {
if n, err := strconv.ParseFloat(value, 64); err == nil && n > 0 && n <= 86400 {
return time.Duration(n * float64(time.Second))
}
if t, err := http.ParseTime(value); err == nil && t.After(now) {
return min(t.Sub(now), 24*time.Hour)
}
return 0
}