From a7ab445281c9900c327ab5d0955a7dfad5b11467 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Tue, 4 Aug 2026 14:43:08 +0200 Subject: [PATCH] Add test push button in admin area --- README.md | 2 +- backend/main.go | 1 + backend/ntfy.go | 28 +++++++++++++++++++++++++--- backend/reports.go | 32 ++++++++++++++++++++++++++++++++ frontend/static/admin.html | 26 ++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4a184ed..85ca6d3 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ docker compose -f compose.test.yml run --rm test | `NTFY_TOKEN` | *(none)* | ntfy access token. Sent as `Authorization: Bearer` when no username is set | | `PUBLIC_BASE_URL` | *(none)* | Public base URL of the site (e.g. `https://dmrmap.de`). Used to add a click-through link to the admin view in ntfy notifications | -Both `NTFY_URL` and `NTFY_TOPIC` must be set for notifications; otherwise reports are stored but nothing is pushed. +Both `NTFY_URL` and `NTFY_TOPIC` must be set for notifications; otherwise reports are stored but nothing is pushed. On startup the app logs either `ntfy notifications enabled: (auth: ...)` or `ntfy notifications disabled`. The **Test push** button in the admin Reports view publishes a test notification and shows the upstream error if it fails. ## API Endpoints diff --git a/backend/main.go b/backend/main.go index d210470..fd4be27 100644 --- a/backend/main.go +++ b/backend/main.go @@ -59,6 +59,7 @@ func main() { adminAPI.HandleFunc("/admin/api/reports", handleAdminReports(db)) adminAPI.HandleFunc("/admin/api/reports/status", handleAdminUpdateReport(db)) adminAPI.HandleFunc("/admin/api/reports/delete", handleAdminDeleteReport(db)) + adminAPI.HandleFunc("/admin/api/reports/test-notification", handleAdminTestNotification(notifier)) mux.Handle("/admin/api/", adminAuth(adminToken, adminAPI)) log.Println("Admin interface enabled at /admin/") } diff --git a/backend/ntfy.go b/backend/ntfy.go index 2ff2a21..d4e08c3 100644 --- a/backend/ntfy.go +++ b/backend/ntfy.go @@ -30,13 +30,23 @@ func newNtfyNotifierFromEnv() *ntfyNotifier { return nil } - return &ntfyNotifier{ + 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 @@ -52,12 +62,24 @@ func (n *ntfyNotifier) notifyReport(rep UserReport) { }() } +// 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 } - body += fmt.Sprintf("\n\nRepeater ID: %d", rep.RepeaterID) + 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 { @@ -71,7 +93,7 @@ func (n *ntfyNotifier) send(rep UserReport) error { req.Header.Set("Title", title) req.Header.Set("Tags", "warning,radio") req.Header.Set("Priority", "default") - if n.clickBase != "" { + if n.clickBase != "" && rep.RepeaterID > 0 { req.Header.Set("Click", fmt.Sprintf("%s/admin/#%d", n.clickBase, rep.RepeaterID)) } n.setAuth(req) diff --git a/backend/reports.go b/backend/reports.go index 82277d0..2316a9e 100644 --- a/backend/reports.go +++ b/backend/reports.go @@ -246,6 +246,38 @@ func handleAdminUpdateReport(db *sql.DB) http.HandlerFunc { } } +// handleAdminTestNotification publishes a test push so ntfy credentials can be +// verified without filing a report. Unlike the report path it waits for the +// result and returns the upstream error verbatim. +func handleAdminTestNotification(notifier *ntfyNotifier) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + + if notifier == nil { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte(`{"error":"ntfy is not configured (NTFY_URL and NTFY_TOPIC must be set)"}`)) + return + } + + if err := notifier.sendTest(); err != nil { + log.Printf("ntfy: test notification failed: %v", err) + w.WriteHeader(http.StatusBadGateway) + json.NewEncoder(w).Encode(map[string]string{ + "error": err.Error(), + "url": notifier.url, + }) + return + } + + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "url": notifier.url}) + } +} + func handleAdminDeleteReport(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { diff --git a/frontend/static/admin.html b/frontend/static/admin.html index 7f652ac..b175669 100644 --- a/frontend/static/admin.html +++ b/frontend/static/admin.html @@ -881,6 +881,7 @@ +
@@ -952,6 +953,7 @@ var reportsModal = document.getElementById("reports-modal"); var reportsBody = document.getElementById("reports-body"); var reportsClose = document.getElementById("reports-close"); + var reportsTestBtn = document.getElementById("reports-test-btn"); var reportsFilter = "open"; function escapeHtml(s) { @@ -1857,6 +1859,30 @@ }); }); + reportsTestBtn.addEventListener("click", function () { + reportsTestBtn.disabled = true; + // Not apiFetch: the error body carries the ntfy failure reason. + fetch("/admin/api/reports/test-notification", { + method: "POST", + headers: { "Authorization": "Bearer " + token }, + }) + .then(function (resp) { + return resp.json().then(function (data) { return { ok: resp.ok, data: data }; }); + }) + .then(function (r) { + reportsTestBtn.disabled = false; + if (r.ok) { + alert("Test notification published to " + r.data.url); + } else { + alert("Test notification failed: " + (r.data.error || "unknown error")); + } + }) + .catch(function (err) { + reportsTestBtn.disabled = false; + alert("Test notification failed: " + err.message); + }); + }); + reportsBtn.addEventListener("click", showReports); reportsClose.addEventListener("click", closeReports); reportsOverlay.addEventListener("click", closeReports);