129 lines
3.9 KiB
Go
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
|
|
}
|