package audit import ( "context" "fmt" "net/http" "strings" "time" ) // NtfyNotifier posts alerts to an ntfy topic URL. Used to surface a // degraded audit state (records buffered locally during a loki outage). type NtfyNotifier struct { topicURL string token string http *http.Client } // NewNtfyNotifier constructs a notifier for the given ntfy topic URL // (e.g. https://ntfy.sh/my-topic). token is an optional bearer for // protected ntfy instances; it is held here and only sent in the // Authorization header, never logged. Returns nil when topicURL is empty. func NewNtfyNotifier(topicURL, token string) *NtfyNotifier { if topicURL == "" { return nil } return &NtfyNotifier{ topicURL: strings.TrimRight(topicURL, "/"), token: token, http: &http.Client{Timeout: 10 * time.Second}, } } // Notify posts a message to the ntfy topic. func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.topicURL, strings.NewReader(msg)) if err != nil { return err } req.Header.Set("Title", "brain-capture audit degraded") req.Header.Set("Priority", "high") req.Header.Set("Tags", "warning,brain") if n.token != "" { req.Header.Set("Authorization", "Bearer "+n.token) } resp, err := n.http.Do(req) if err != nil { return fmt.Errorf("ntfy notify: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return &RejectedError{Status: resp.StatusCode} } return nil } // RejectedError is a non-2xx ntfy response. type RejectedError struct{ Status int } func (e *RejectedError) Error() string { return fmt.Sprintf("ntfy notify: status %d", e.Status) } // Permanent reports whether resending the same message cannot succeed: a 4xx // other than 429 (rate limited). Callers may drop such a message; anything // else (5xx, 429, transport errors) is an outage and must be retried. func (e *RejectedError) Permanent() bool { return e.Status >= 400 && e.Status < 500 && e.Status != http.StatusTooManyRequests }