Files
restic-manager/internal/notification/ntfy.go
T
steve a99864c649 notification: B3 — Content-Type header + URL trim
Fixes flagged in spec review of f0a323e: ntfy POSTs need explicit
Content-Type: text/plain (the spec calls for it; ntfy works without
but explicit beats inferred); trim trailing slashes from server URL
to avoid double-slash when operators paste 'https://ntfy.sh/'.
2026-05-04 19:38:16 +01:00

107 lines
3.2 KiB
Go

package notification
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// NtfyConfig is the per-channel JSON shape stored AEAD-encrypted in
// notification_channels.config.
type NtfyConfig struct {
ServerURL string `json:"server_url"`
Topic string `json:"topic"`
AccessToken string `json:"access_token,omitempty"`
}
// NtfyChannel delivers alerts to an ntfy server using POST with
// ntfy-specific headers (Title, Priority, Tags, Click). One instance
// per configured channel row. Reused across sends — http.Client is
// goroutine-safe.
type NtfyChannel struct {
cfg NtfyConfig
defaultPriority string // "min"/"low"/"default"/"high"/"urgent" or ""
client *http.Client
}
// NewNtfyChannel builds an ntfy channel with a 5s http.Client timeout.
// defaultPriority is the channel-configured fallback when no
// severity-specific mapping applies; pass "" to use the built-in
// fallbacks (4 for warning, 3 for everything else).
func NewNtfyChannel(cfg NtfyConfig, defaultPriority string) *NtfyChannel {
if cfg.ServerURL == "" {
cfg.ServerURL = "https://ntfy.sh"
}
return &NtfyChannel{
cfg: cfg,
defaultPriority: defaultPriority,
client: &http.Client{Timeout: 5 * time.Second},
}
}
// Kind returns "ntfy" for log enrichment and dispatcher routing.
func (c *NtfyChannel) Kind() string { return "ntfy" }
// Send delivers the payload as a plain-text POST to <server>/<topic>
// with ntfy headers. Returns (statusCode, latency, err). 4xx/5xx
// responses are returned as errors with the status code set.
func (c *NtfyChannel) Send(ctx context.Context, p Payload) (int, time.Duration, error) {
server := strings.TrimRight(c.cfg.ServerURL, "/")
url := server + "/" + c.cfg.Topic
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(p.Message))
if err != nil {
return 0, 0, fmt.Errorf("ntfy: build request: %w", err)
}
req.Header.Set("Content-Type", "text/plain")
req.Header.Set("Title", fmt.Sprintf("[%s] %s %s", p.Severity, p.HostName, p.Kind))
req.Header.Set("Tags", p.Severity+","+p.Kind)
req.Header.Set("Priority", priorityForSeverity(p.Severity, c.defaultPriority))
if p.Link != "" {
req.Header.Set("Click", p.Link)
}
if c.cfg.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.cfg.AccessToken)
}
t0 := time.Now()
res, err := c.client.Do(req)
latency := time.Since(t0)
if err != nil {
return 0, latency, fmt.Errorf("ntfy: do: %w", err)
}
defer func() { _ = res.Body.Close() }()
// Drain body to keep the connection reusable.
_, _ = io.Copy(io.Discard, res.Body)
if res.StatusCode >= 400 {
return res.StatusCode, latency, fmt.Errorf("ntfy: http %d", res.StatusCode)
}
return res.StatusCode, latency, nil
}
// priorityForSeverity maps a severity string to an ntfy numeric priority
// string. critical always returns "5" regardless of defaultPri. For
// other severities, defaultPri is returned when non-empty, otherwise
// "4" for warning and "3" for everything else.
func priorityForSeverity(severity, defaultPri string) string {
switch severity {
case "critical":
return "5"
case "warning":
if defaultPri != "" {
return defaultPri
}
return "4"
default:
if defaultPri != "" {
return defaultPri
}
return "3"
}
}