package main import ( "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 token string clickBase string httpClient *http.Client } // newNtfyNotifierFromEnv builds a notifier from NTFY_URL / NTFY_TOPIC / // 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, "/"), 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.token != "" { auth = "access 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 applies the ntfy access token. It is a bearer credential: ntfy does // NOT accept it as the password half of a username:password pair (that yields // HTTP 401), only as a bearer token or as basic auth with an empty username. func (n *ntfyNotifier) setAuth(req *http.Request) { if n.token != "" { req.Header.Set("Authorization", "Bearer "+n.token) } }