DMRmap/backend/ntfy.go
2026-08-04 14:43:08 +02:00

123 lines
3.5 KiB
Go

package main
import (
"encoding/base64"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
)
// ntfyNotifier pushes user reports to an ntfy server. It is nil when the
// required environment variables are not set, in which case notify is a no-op.
type ntfyNotifier struct {
url string // full topic URL, e.g. https://ntfy.example.com/dmrmap
user string
token string
clickBase string
httpClient *http.Client
}
// newNtfyNotifierFromEnv builds a notifier from NTFY_URL / NTFY_TOPIC /
// NTFY_USER / NTFY_TOKEN. Returns nil when notifications are not configured.
func newNtfyNotifierFromEnv() *ntfyNotifier {
base := strings.TrimSpace(os.Getenv("NTFY_URL"))
topic := strings.TrimSpace(os.Getenv("NTFY_TOPIC"))
if base == "" || topic == "" {
log.Println("ntfy notifications disabled (set NTFY_URL and NTFY_TOPIC to enable)")
return nil
}
n := &ntfyNotifier{
url: strings.TrimRight(base, "/") + "/" + strings.TrimLeft(topic, "/"),
user: strings.TrimSpace(os.Getenv("NTFY_USER")),
token: strings.TrimSpace(os.Getenv("NTFY_TOKEN")),
clickBase: strings.TrimRight(strings.TrimSpace(os.Getenv("PUBLIC_BASE_URL")), "/"),
httpClient: &http.Client{Timeout: 10 * time.Second},
}
auth := "none"
if n.user != "" && n.token != "" {
auth = "basic (" + n.user + ")"
} else if n.token != "" {
auth = "bearer token"
}
log.Printf("ntfy notifications enabled: %s (auth: %s)", n.url, auth)
return n
}
// notifyReport sends a push notification for a new user report. It never
// blocks the caller and never fails the request that triggered it.
func (n *ntfyNotifier) notifyReport(rep UserReport) {
if n == nil {
return
}
go func() {
if err := n.send(rep); err != nil {
log.Printf("ntfy: failed to publish report %d: %v", rep.ID, err)
}
}()
}
// sendTest publishes a test notification and reports the outcome synchronously,
// so the admin UI can surface configuration problems.
func (n *ntfyNotifier) sendTest() error {
return n.send(UserReport{
Callsign: "TEST",
ReportType: "other",
Message: "Test notification from DMRmap admin.",
})
}
func (n *ntfyNotifier) send(rep UserReport) error {
body := reportTypeLabel(rep.ReportType)
if rep.Message != "" {
body += "\n\n" + rep.Message
}
if rep.RepeaterID > 0 {
body += fmt.Sprintf("\n\nRepeater ID: %d", rep.RepeaterID)
}
req, err := http.NewRequest(http.MethodPost, n.url, strings.NewReader(body))
if err != nil {
return err
}
title := fmt.Sprintf("Report: %s", rep.Callsign)
if rep.Callsign == "" {
title = fmt.Sprintf("Report: #%d", rep.RepeaterID)
}
req.Header.Set("Title", title)
req.Header.Set("Tags", "warning,radio")
req.Header.Set("Priority", "default")
if n.clickBase != "" && rep.RepeaterID > 0 {
req.Header.Set("Click", fmt.Sprintf("%s/admin/#%d", n.clickBase, rep.RepeaterID))
}
n.setAuth(req)
resp, err := n.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("ntfy returned HTTP %d", resp.StatusCode)
}
return nil
}
// setAuth uses basic auth when a username is configured (ntfy accepts an
// access token as the password), and bearer auth for a token on its own.
func (n *ntfyNotifier) setAuth(req *http.Request) {
switch {
case n.user != "" && n.token != "":
creds := base64.StdEncoding.EncodeToString([]byte(n.user + ":" + n.token))
req.Header.Set("Authorization", "Basic "+creds)
case n.token != "":
req.Header.Set("Authorization", "Bearer "+n.token)
}
}