Compare commits

...

4 commits

Author SHA1 Message Date
Marcus Kida
db7b88d8d4 Update Privacy 2026-08-04 15:13:59 +02:00
Marcus Kida
0c2cc6b081 Fix ntfy bearer token usage 2026-08-04 14:51:34 +02:00
Marcus Kida
a7ab445281 Add test push button in admin area 2026-08-04 14:43:08 +02:00
Marcus Kida
14550b3797 Implement user reports 2026-08-04 14:33:00 +02:00
22 changed files with 1301 additions and 13 deletions

View file

@ -14,6 +14,7 @@ A web app that visualizes DMR repeaters on an interactive map. Filter by band an
- Click-to-pin with adjustable radius search - Click-to-pin with adjustable radius search
- Detailed repeater popups (frequencies, color code, timeslots, hardware, power, antenna height, BrandMeister status) - Detailed repeater popups (frequencies, color code, timeslots, hardware, power, antenna height, BrandMeister status)
- Real-time Last Heard heatmap via BrandMeister WebSocket - Real-time Last Heard heatmap via BrandMeister WebSocket
- User reports for incorrect repeater data (offline, wrong frequency, changed callsign, other), reviewable in the admin area with optional ntfy push notifications
- CPS Studio: manage talk groups, set timeslots, export Motorola CPS 2.0 compatible XML - CPS Studio: manage talk groups, set timeslots, export Motorola CPS 2.0 compatible XML
- Coordinate display with Maidenhead grid locator - Coordinate display with Maidenhead grid locator
- Dark mode (follows system preference) - Dark mode (follows system preference)
@ -24,7 +25,7 @@ A web app that visualizes DMR repeaters on an interactive map. Filter by band an
- **Backend:** Go (stdlib `net/http`), PostgreSQL 17 via `pgx/v5`, migrations via `goose/v3` - **Backend:** Go (stdlib `net/http`), PostgreSQL 17 via `pgx/v5`, migrations via `goose/v3`
- **Frontend:** TypeScript (bundled with esbuild), Leaflet, i18next - **Frontend:** TypeScript (bundled with esbuild), Leaflet, i18next
- **Testing:** Vitest (92 unit tests) - **Testing:** Vitest (115 unit tests)
- **Infrastructure:** Docker Compose, multi-stage Dockerfile - **Infrastructure:** Docker Compose, multi-stage Dockerfile
## External Services ## External Services
@ -36,6 +37,7 @@ A web app that visualizes DMR repeaters on an interactive map. Filter by band an
| [BrandMeister WebSocket](https://wiki.brandmeister.network/index.php/API/Last_Heard) | Real-time Last Heard feed for heatmap via socket.io | None | | [BrandMeister WebSocket](https://wiki.brandmeister.network/index.php/API/Last_Heard) | Real-time Last Heard feed for heatmap via socket.io | None |
| [Nominatim](https://nominatim.openstreetmap.org) | Address geocoding and autocomplete (client-side) | None | | [Nominatim](https://nominatim.openstreetmap.org) | Address geocoding and autocomplete (client-side) | None |
| [OSRM](https://project-osrm.org) | Driving route calculation (client-side) | None | | [OSRM](https://project-osrm.org) | Driving route calculation (client-side) | None |
| [ntfy](https://ntfy.sh) | Push notification when a user report is submitted (optional) | Access token (bearer) |
The BrandMeister talk group registry is fetched once at Docker build time and bundled as `frontend/static/talkgroups.json`. It is used for talk group name resolution and autocomplete in CPS Studio. The BrandMeister talk group registry is fetched once at Docker build time and bundled as `frontend/static/talkgroups.json`. It is used for talk group name resolution and autocomplete in CPS Studio.
@ -75,6 +77,12 @@ docker compose -f compose.test.yml run --rm test
| `MIGRATIONS_DIR` | `migrations` | Path to SQL migration files | | `MIGRATIONS_DIR` | `migrations` | Path to SQL migration files |
| `BM_SYNC` | *(disabled)* | Set to `true` to enable periodic BrandMeister device sync | | `BM_SYNC` | *(disabled)* | Set to `true` to enable periodic BrandMeister device sync |
| `ADMIN_TOKEN` | *(disabled)* | Set to enable the admin interface at `/admin/` | | `ADMIN_TOKEN` | *(disabled)* | Set to enable the admin interface at `/admin/` |
| `NTFY_URL` | *(disabled)* | ntfy server base URL (e.g. `https://ntfy.example.com`). Together with `NTFY_TOPIC` it enables push notifications for new user reports |
| `NTFY_TOPIC` | *(disabled)* | ntfy topic that report notifications are published to |
| `NTFY_TOKEN` | *(none)* | ntfy access token, sent as `Authorization: Bearer`. Required for protected topics |
| `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. On startup the app logs either `ntfy notifications enabled: <topic URL> (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 ## API Endpoints
@ -85,6 +93,7 @@ docker compose -f compose.test.yml run --rm test
| POST | `/api/repeaters/route` | Repeaters along a route corridor (body: GeoJSON coordinates + `corridor` width) | | POST | `/api/repeaters/route` | Repeaters along a route corridor (body: GeoJSON coordinates + `corridor` width) |
| GET | `/api/repeater` | Single repeater by ID (query: `id`) | | GET | `/api/repeater` | Single repeater by ID (query: `id`) |
| GET | `/api/repeaters/search` | Free-text search (query: `q`) | | GET | `/api/repeaters/search` | Free-text search (query: `q`) |
| POST | `/api/reports` | Submit a user report about a repeater (body: `repeater_id`, `report_type``offline`, `frequency`, `callsign` or `other` — and `message`, max 300 characters). Rate limited to 5 reports per IP per hour |
## Project Structure ## Project Structure
@ -94,6 +103,8 @@ docker compose -f compose.test.yml run --rm test
│ ├── db.go # DB queries, Repeater struct, geo helpers │ ├── db.go # DB queries, Repeater struct, geo helpers
│ ├── handlers.go # Public API handlers │ ├── handlers.go # Public API handlers
│ ├── admin.go # Admin auth middleware + admin API │ ├── admin.go # Admin auth middleware + admin API
│ ├── reports.go # User report API (public + admin) and rate limiting
│ ├── ntfy.go # ntfy push notifications for new reports
│ ├── bmdevice.go # BrandMeister device sync │ ├── bmdevice.go # BrandMeister device sync
│ ├── seed.go # Database seeding from JSON dumps │ ├── seed.go # Database seeding from JSON dumps
│ ├── migrate.go # Goose migration runner │ ├── migrate.go # Goose migration runner

View file

@ -36,12 +36,15 @@ func main() {
log.Println("BM device sync disabled (set BM_SYNC=true to enable)") log.Println("BM device sync disabled (set BM_SYNC=true to enable)")
} }
notifier := newNtfyNotifierFromEnv()
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/repeaters", handleRepeaters(db)) mux.HandleFunc("/api/repeaters", handleRepeaters(db))
mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(db)) mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(db))
mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db)) mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db))
mux.HandleFunc("/api/repeater", handleRepeaterByID(db)) mux.HandleFunc("/api/repeater", handleRepeaterByID(db))
mux.HandleFunc("/api/repeaters/search", handleSearchRepeaters(db)) mux.HandleFunc("/api/repeaters/search", handleSearchRepeaters(db))
mux.HandleFunc("/api/reports", handleCreateReport(db, notifier))
mux.HandleFunc("/api/version", handleVersion()) mux.HandleFunc("/api/version", handleVersion())
if adminToken := os.Getenv("ADMIN_TOKEN"); adminToken != "" { if adminToken := os.Getenv("ADMIN_TOKEN"); adminToken != "" {
@ -53,6 +56,10 @@ func main() {
adminAPI.HandleFunc("/admin/api/repeaters/save-bm", handleAdminSaveBMData(db)) adminAPI.HandleFunc("/admin/api/repeaters/save-bm", handleAdminSaveBMData(db))
adminAPI.HandleFunc("/admin/api/repeaters/remove-bm-tag", handleAdminRemoveBMTag(db)) adminAPI.HandleFunc("/admin/api/repeaters/remove-bm-tag", handleAdminRemoveBMTag(db))
adminAPI.HandleFunc("/admin/api/repeaters/changelog", handleAdminChangelog(db)) adminAPI.HandleFunc("/admin/api/repeaters/changelog", handleAdminChangelog(db))
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)) mux.Handle("/admin/api/", adminAuth(adminToken, adminAPI))
log.Println("Admin interface enabled at /admin/") log.Println("Admin interface enabled at /admin/")
} }

View file

@ -0,0 +1,18 @@
-- +goose Up
CREATE TABLE user_reports (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
repeater_id INTEGER NOT NULL REFERENCES repeaters(id),
callsign TEXT NOT NULL DEFAULT '',
report_type TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
resolved_at TIMESTAMPTZ
);
CREATE INDEX idx_user_reports_repeater_id ON user_reports (repeater_id);
CREATE INDEX idx_user_reports_created_at ON user_reports (created_at);
CREATE INDEX idx_user_reports_status ON user_reports (status);
-- +goose Down
DROP TABLE IF EXISTS user_reports;

115
backend/ntfy.go Normal file
View file

@ -0,0 +1,115 @@
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)
}
}

378
backend/reports.go Normal file
View file

@ -0,0 +1,378 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
)
// maxReportMessageLen is the maximum length of the free-text message a user
// can attach to a report.
const maxReportMessageLen = 300
// reportTypeLabels maps the report types accepted by the API to their
// human-readable label used in notifications.
var reportTypeLabels = map[string]string{
"offline": "Repeater is offline",
"frequency": "Frequency is incorrect",
"callsign": "Callsign has changed",
"other": "Other",
}
func reportTypeLabel(t string) string {
if label, ok := reportTypeLabels[t]; ok {
return label
}
return t
}
type UserReport struct {
ID int64 `json:"id"`
CreatedAt string `json:"created_at"`
RepeaterID int `json:"repeater_id"`
Callsign string `json:"callsign"`
ReportType string `json:"report_type"`
Message string `json:"message"`
Status string `json:"status"`
ResolvedAt *string `json:"resolved_at"`
}
type reportsResponse struct {
Reports []UserReport `json:"reports"`
Total int `json:"total"`
Open int `json:"open"`
}
// === Public API ===
func handleCreateReport(db *sql.DB, notifier *ntfyNotifier) http.HandlerFunc {
limiter := newIPRateLimiter(5, time.Hour)
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var payload struct {
RepeaterID int `json:"repeater_id"`
ReportType string `json:"report_type"`
Message string `json:"message"`
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if _, ok := reportTypeLabels[payload.ReportType]; !ok {
http.Error(w, `{"error":"invalid report type"}`, http.StatusBadRequest)
return
}
message := strings.TrimSpace(payload.Message)
if utf8.RuneCountInString(message) > maxReportMessageLen {
http.Error(w, `{"error":"message too long"}`, http.StatusBadRequest)
return
}
if payload.ReportType == "other" && message == "" {
http.Error(w, `{"error":"message required"}`, http.StatusBadRequest)
return
}
rpt, err := queryRepeaterByID(db, payload.RepeaterID)
if err != nil || rpt == nil {
http.Error(w, `{"error":"repeater not found"}`, http.StatusNotFound)
return
}
// Only accepted reports count against the quota, so a user who mistypes
// the form does not lock themselves out.
if !limiter.allow(clientIP(r)) {
http.Error(w, `{"error":"too many reports, please try again later"}`, http.StatusTooManyRequests)
return
}
report := UserReport{
RepeaterID: rpt.ID,
Callsign: rpt.Callsign,
ReportType: payload.ReportType,
Message: message,
Status: "open",
}
var createdAt time.Time
err = db.QueryRow(
`INSERT INTO user_reports (repeater_id, callsign, report_type, message)
VALUES ($1, $2, $3, $4) RETURNING id, created_at`,
report.RepeaterID, report.Callsign, report.ReportType, report.Message,
).Scan(&report.ID, &createdAt)
if err != nil {
log.Printf("reports: insert failed for repeater %d: %v", report.RepeaterID, err)
http.Error(w, `{"error":"could not store report"}`, http.StatusInternalServerError)
return
}
report.CreatedAt = createdAt.Format("2006-01-02 15:04:05")
insertChangelog(db, ChangelogEntry{
RepeaterID: report.RepeaterID,
Callsign: report.Callsign,
Source: "user",
Action: "report",
Description: "User report: " + reportTypeLabel(report.ReportType),
NewValues: map[string]interface{}{
"report_type": report.ReportType,
"message": report.Message,
},
})
notifier.notifyReport(report)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"ok":true,"id":%d}`, report.ID)
}
}
// === Admin API ===
func handleAdminReports(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
status := r.URL.Query().Get("status")
if status != "open" && status != "resolved" {
status = ""
}
limit := 200
if v := r.URL.Query().Get("limit"); v != "" {
if p, err := strconv.Atoi(v); err == nil && p > 0 && p <= 500 {
limit = p
}
}
query := `SELECT id, created_at, repeater_id, callsign, report_type, message, status, resolved_at
FROM user_reports`
args := []interface{}{}
if status != "" {
query += ` WHERE status = $1`
args = append(args, status)
}
query += ` ORDER BY created_at DESC LIMIT ` + strconv.Itoa(limit)
rows, err := db.Query(query, args...)
if err != nil {
log.Printf("admin reports: query failed: %v", err)
http.Error(w, `{"error":"query failed"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
reports := []UserReport{}
for rows.Next() {
var rep UserReport
var createdAt time.Time
var resolvedAt *time.Time
if err := rows.Scan(&rep.ID, &createdAt, &rep.RepeaterID, &rep.Callsign,
&rep.ReportType, &rep.Message, &rep.Status, &resolvedAt); err != nil {
continue
}
rep.CreatedAt = createdAt.Format("2006-01-02 15:04:05")
if resolvedAt != nil {
formatted := resolvedAt.Format("2006-01-02 15:04:05")
rep.ResolvedAt = &formatted
}
reports = append(reports, rep)
}
var total, open int
db.QueryRow(`SELECT COUNT(*), COUNT(*) FILTER (WHERE status = 'open') FROM user_reports`).Scan(&total, &open)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(reportsResponse{Reports: reports, Total: total, Open: open})
}
}
func handleAdminUpdateReport(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var payload struct {
ID int64 `json:"id"`
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if payload.ID <= 0 {
http.Error(w, `{"error":"missing report id"}`, http.StatusBadRequest)
return
}
if payload.Status != "open" && payload.Status != "resolved" {
http.Error(w, `{"error":"status must be open or resolved"}`, http.StatusBadRequest)
return
}
var resolvedAt interface{}
if payload.Status == "resolved" {
resolvedAt = time.Now()
}
_, err := db.Exec(`UPDATE user_reports SET status = $1, resolved_at = $2 WHERE id = $3`,
payload.Status, resolvedAt, payload.ID)
if err != nil {
log.Printf("admin reports: status update failed for %d: %v", payload.ID, err)
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
}
// 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 {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
if err != nil || id <= 0 {
http.Error(w, `{"error":"missing or invalid id"}`, http.StatusBadRequest)
return
}
if _, err := db.Exec(`DELETE FROM user_reports WHERE id = $1`, id); err != nil {
log.Printf("admin reports: delete failed for %d: %v", id, err)
http.Error(w, `{"error":"delete failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
}
// === Rate limiting ===
// ipRateLimiter allows at most `limit` events per client IP within a sliding
// window. It keeps state in memory only — restarts reset all counters.
type ipRateLimiter struct {
mu sync.Mutex
hits map[string][]time.Time
limit int
window time.Duration
lastGC time.Time
}
func newIPRateLimiter(limit int, window time.Duration) *ipRateLimiter {
return &ipRateLimiter{
hits: make(map[string][]time.Time),
limit: limit,
window: window,
lastGC: time.Now(),
}
}
func (l *ipRateLimiter) allow(ip string) bool {
now := time.Now()
cutoff := now.Add(-l.window)
l.mu.Lock()
defer l.mu.Unlock()
if now.Sub(l.lastGC) > l.window {
for key, times := range l.hits {
if len(prune(times, cutoff)) == 0 {
delete(l.hits, key)
}
}
l.lastGC = now
}
recent := prune(l.hits[ip], cutoff)
if len(recent) >= l.limit {
l.hits[ip] = recent
return false
}
l.hits[ip] = append(recent, now)
return true
}
func prune(times []time.Time, cutoff time.Time) []time.Time {
kept := times[:0]
for _, t := range times {
if t.After(cutoff) {
kept = append(kept, t)
}
}
return kept
}
// clientIP returns the caller's IP, honouring the forwarding headers set by a
// reverse proxy. Used for rate limiting only — never stored.
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if first, _, found := strings.Cut(fwd, ","); found {
return strings.TrimSpace(first)
}
return strings.TrimSpace(fwd)
}
if real := r.Header.Get("X-Real-IP"); real != "" {
return strings.TrimSpace(real)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}

View file

@ -31,6 +31,10 @@ services:
- ADMIN_TOKEN=adminparty - ADMIN_TOKEN=adminparty
- STATIC_DIR=/app/frontend/static - STATIC_DIR=/app/frontend/static
- MIGRATIONS_DIR=/app/backend/migrations - MIGRATIONS_DIR=/app/backend/migrations
- NTFY_URL=${NTFY_URL:-}
- NTFY_TOPIC=${NTFY_TOPIC:-}
- NTFY_TOKEN=${NTFY_TOKEN:-}
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:8080}
volumes: volumes:
go-mod-cache: go-mod-cache:

View file

@ -20,6 +20,10 @@ services:
- LISTEN_ADDR=:8080 - LISTEN_ADDR=:8080
- BM_SYNC=true - BM_SYNC=true
- ADMIN_TOKEN=${ADMIN_TOKEN:-} - ADMIN_TOKEN=${ADMIN_TOKEN:-}
- NTFY_URL=${NTFY_URL:-}
- NTFY_TOPIC=${NTFY_TOPIC:-}
- NTFY_TOKEN=${NTFY_TOKEN:-}
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-}
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View file

@ -6,6 +6,7 @@ import { buildChannel, generateCpsXml, generateContactsCsv, CPS_CSV_HEADER, CPS_
import { generateAnytoneContactsCsv, generateAnytoneChannelsCsv, ANYTONE_CH_HEADER } from "./cps-anytone"; import { generateAnytoneContactsCsv, generateAnytoneChannelsCsv, ANYTONE_CH_HEADER } from "./cps-anytone";
import { getSelectedBand as _getSelectedBand, getSelectedNetworks as _getSelectedNetworks } from "./filters"; import { getSelectedBand as _getSelectedBand, getSelectedNetworks as _getSelectedNetworks } from "./filters";
import { buildPopup as _buildPopup } from "./popup"; import { buildPopup as _buildPopup } from "./popup";
import { validateReport } from "./report";
(function () { (function () {
"use strict"; "use strict";
@ -88,6 +89,20 @@ import { buildPopup as _buildPopup } from "./popup";
var cpsAtTgBtn = document.getElementById("cps-at-tg-btn"); var cpsAtTgBtn = document.getElementById("cps-at-tg-btn");
var cpsAtChBtn = document.getElementById("cps-at-ch-btn"); var cpsAtChBtn = document.getElementById("cps-at-ch-btn");
// === Report DOM + State ===
var reportModal = document.getElementById("report-modal");
var reportModalClose = document.getElementById("report-modal-close");
var reportModalBackdrop = document.getElementById("report-modal-backdrop");
var reportTargetEl = document.getElementById("report-target");
var reportMessage = document.getElementById("report-message");
var reportCharCount = document.getElementById("report-char-count");
var reportFeedback = document.getElementById("report-feedback");
var reportCancelBtn = document.getElementById("report-cancel");
var reportSubmitBtn = document.getElementById("report-submit");
var reportRepeaterId = 0;
var reportCloseTimer = null;
var cpsRepeaters = []; var cpsRepeaters = [];
var cpsActiveRepeaters = {}; var cpsActiveRepeaters = {};
var cpsTalkgroups = []; var cpsTalkgroups = [];
@ -502,6 +517,68 @@ import { buildPopup as _buildPopup } from "./popup";
// === Popup === // === Popup ===
function buildPopup(r) { return _buildPopup(r, t); } function buildPopup(r) { return _buildPopup(r, t); }
// === Report incorrect data ===
function openReportModal(id, callsign) {
clearTimeout(reportCloseTimer);
reportRepeaterId = id;
reportTargetEl.textContent = callsign ? callsign + " (#" + id + ")" : "#" + id;
reportMessage.value = "";
reportCharCount.textContent = "0";
reportFeedback.style.display = "none";
reportFeedback.className = "report-feedback";
reportSubmitBtn.disabled = false;
var defaultType = reportModal.querySelector('input[name="report-type"][value="offline"]');
if (defaultType) defaultType.checked = true;
reportModal.style.display = "flex";
translateDOM();
}
function closeReportModal() {
clearTimeout(reportCloseTimer);
reportModal.style.display = "none";
reportRepeaterId = 0;
}
function showReportFeedback(msg, success) {
reportFeedback.textContent = msg;
reportFeedback.className = success ? "report-feedback success" : "report-feedback";
reportFeedback.style.display = "";
}
function submitReport() {
var typeEl = reportModal.querySelector('input[name="report-type"]:checked');
var result = validateReport(reportRepeaterId, typeEl ? typeEl.value : "", reportMessage.value);
if (!result.ok) {
showReportFeedback(t(result.errorKey), false);
return;
}
reportSubmitBtn.disabled = true;
reportSubmitBtn.textContent = t("report_sending");
fetch("/api/reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(result.payload),
})
.then(function (resp) {
if (resp.status === 429) throw new Error("rate_limited");
if (!resp.ok) throw new Error("HTTP " + resp.status);
return resp.json();
})
.then(function () {
showReportFeedback(t("report_thanks"), true);
reportSubmitBtn.textContent = t("report_submit");
reportCloseTimer = setTimeout(closeReportModal, 2000);
})
.catch(function (err) {
console.error("Report error:", err);
showReportFeedback(err.message === "rate_limited" ? t("report_rate_limited") : t("report_error"), false);
reportSubmitBtn.disabled = false;
reportSubmitBtn.textContent = t("report_submit");
});
}
// === Display markers === // === Display markers ===
function displayRepeaters(repeaters) { function displayRepeaters(repeaters) {
markerLayer.clearLayers(); markerLayer.clearLayers();
@ -1125,9 +1202,30 @@ import { buildPopup as _buildPopup } from "./popup";
fetchRepeaters(); fetchRepeaters();
}); });
// === Report Events ===
document.addEventListener("click", function (e) {
var btn = e.target && e.target.closest ? e.target.closest(".rptr-report-btn") : null;
if (!btn) return;
e.preventDefault();
openReportModal(parseInt(btn.getAttribute("data-report-id"), 10), btn.getAttribute("data-report-callsign"));
});
reportModalClose.addEventListener("click", closeReportModal);
reportModalBackdrop.addEventListener("click", closeReportModal);
reportCancelBtn.addEventListener("click", closeReportModal);
reportSubmitBtn.addEventListener("click", submitReport);
reportMessage.addEventListener("input", function () {
reportCharCount.textContent = String(Array.from(reportMessage.value).length);
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && reportModal.style.display !== "none") {
closeReportModal();
}
});
// === CPS Studio Events === // === CPS Studio Events ===
cpsModalClose.addEventListener("click", closeCpsModal); cpsModalClose.addEventListener("click", closeCpsModal);
document.querySelector(".cps-modal-backdrop").addEventListener("click", closeCpsModal); cpsModal.querySelector(".cps-modal-backdrop").addEventListener("click", closeCpsModal);
document.addEventListener("keydown", function (e) { document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && cpsModal.style.display !== "none") { if (e.key === "Escape" && cpsModal.style.display !== "none") {
if (cpsAcList.children.length) { if (cpsAcList.children.length) {

View file

@ -86,6 +86,19 @@ describe("buildPopup", () => {
expect(html).toContain("&lt;script&gt;"); expect(html).toContain("&lt;script&gt;");
}); });
it("contains a report button carrying the repeater id and callsign", () => {
const html = buildPopup(makeRepeater(), mockT);
expect(html).toContain('class="rptr-report-btn"');
expect(html).toContain('data-report-id="1"');
expect(html).toContain('data-report-callsign="DB0ABC"');
expect(html).toContain("report_btn");
});
it("escapes the callsign in the report button attribute", () => {
const html = buildPopup(makeRepeater({ callsign: 'X" onclick="evil()' }), mockT);
expect(html).toContain('data-report-callsign="X&quot; onclick=&quot;evil()"');
});
it("uses translate function for labels", () => { it("uses translate function for labels", () => {
const keys: string[] = []; const keys: string[] = [];
const captureT = (key: string) => { keys.push(key); return key; }; const captureT = (key: string) => { keys.push(key); return key; };

View file

@ -51,6 +51,11 @@ export function buildPopup(r: Repeater, t: TranslateFunction): string {
html += "<tr><td>" + t("popup_antenna") + "</td><td>" + r.agl + " m AGL</td></tr>"; html += "<tr><td>" + t("popup_antenna") + "</td><td>" + r.agl + " m AGL</td></tr>";
if (r.inactive) if (r.inactive)
html += '<tr><td></td><td style="color:#ef5350;font-weight:600">' + t("popup_inactive") + '</td></tr>'; html += '<tr><td></td><td style="color:#ef5350;font-weight:600">' + t("popup_inactive") + '</td></tr>';
html += "</table></div>"; html += "</table>";
html +=
'<button type="button" class="rptr-report-btn" data-report-id="' + r.id +
'" data-report-callsign="' + escapeHtml(r.callsign) + '">' +
t("report_btn") + "</button>";
html += "</div>";
return html; return html;
} }

View file

@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { validateReport, REPORT_TYPES, REPORT_MAX_MESSAGE } from "./report";
describe("validateReport", () => {
it("accepts a known type without a message", () => {
const result = validateReport(262001, "offline", "");
expect(result).toEqual({
ok: true,
payload: { repeater_id: 262001, report_type: "offline", message: "" },
});
});
it("accepts every supported report type", () => {
REPORT_TYPES.forEach((type) => {
expect(validateReport(1, type, "details").ok).toBe(true);
});
});
it("trims the message", () => {
const result = validateReport(1, "frequency", " TX is 439.5125 ");
expect(result).toEqual({
ok: true,
payload: { repeater_id: 1, report_type: "frequency", message: "TX is 439.5125" },
});
});
it("rejects an unknown report type", () => {
expect(validateReport(1, "spam", "hi")).toEqual({ ok: false, errorKey: "report_err_type" });
});
it("rejects a missing repeater id", () => {
expect(validateReport(0, "offline", "")).toEqual({ ok: false, errorKey: "report_err_repeater" });
});
it("requires a message for the 'other' type", () => {
expect(validateReport(1, "other", " ")).toEqual({ ok: false, errorKey: "report_err_required" });
expect(validateReport(1, "other", "antenna was replaced").ok).toBe(true);
});
it("rejects messages longer than the limit", () => {
const tooLong = "a".repeat(REPORT_MAX_MESSAGE + 1);
expect(validateReport(1, "offline", tooLong)).toEqual({ ok: false, errorKey: "report_err_too_long" });
expect(validateReport(1, "offline", "a".repeat(REPORT_MAX_MESSAGE)).ok).toBe(true);
});
it("counts unicode characters, not UTF-16 code units", () => {
const emoji = "📻".repeat(REPORT_MAX_MESSAGE);
expect(validateReport(1, "offline", emoji).ok).toBe(true);
expect(validateReport(1, "offline", emoji + "📻").ok).toBe(false);
});
});

41
frontend/src/report.ts Normal file
View file

@ -0,0 +1,41 @@
/** User reports about incorrect repeater data. Mirrors backend/reports.go. */
export const REPORT_TYPES = ["offline", "frequency", "callsign", "other"] as const;
export type ReportType = (typeof REPORT_TYPES)[number];
/** Maximum length of the free-text message, enforced by the backend too. */
export const REPORT_MAX_MESSAGE = 300;
export interface ReportPayload {
repeater_id: number;
report_type: string;
message: string;
}
export type ReportValidation =
| { ok: true; payload: ReportPayload }
| { ok: false; errorKey: string };
/** Validate a report before sending it; errorKey is an i18n key. */
export function validateReport(repeaterId: number, reportType: string, message: string): ReportValidation {
const trimmed = (message || "").trim();
if (!repeaterId || repeaterId <= 0) {
return { ok: false, errorKey: "report_err_repeater" };
}
if ((REPORT_TYPES as readonly string[]).indexOf(reportType) === -1) {
return { ok: false, errorKey: "report_err_type" };
}
if (Array.from(trimmed).length > REPORT_MAX_MESSAGE) {
return { ok: false, errorKey: "report_err_too_long" };
}
if (reportType === "other" && trimmed === "") {
return { ok: false, errorKey: "report_err_required" };
}
return {
ok: true,
payload: { repeater_id: repeaterId, report_type: reportType, message: trimmed },
};
}

View file

@ -683,6 +683,100 @@
.history-changes .old-val { color: var(--tag-red-text); } .history-changes .old-val { color: var(--tag-red-text); }
.history-changes .new-val { color: var(--tag-green-text); } .history-changes .new-val { color: var(--tag-green-text); }
.history-empty { color: var(--text-faint); font-size: 13px; text-align: center; padding: 32px 0; } .history-empty { color: var(--text-faint); font-size: 13px; text-align: center; padding: 32px 0; }
.reports-badge {
display: inline-block;
min-width: 18px;
padding: 1px 5px;
margin-left: 6px;
border-radius: 9px;
background: var(--danger);
color: #fff;
font-size: 11px;
font-weight: 700;
text-align: center;
}
.reports-filter { display: flex; margin-right: auto; margin-left: 14px; }
.reports-filter-btn {
padding: 4px 10px;
border: 1px solid var(--border);
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
background: var(--bg);
cursor: pointer;
}
.reports-filter-btn:first-child { border-radius: 4px 0 0 4px; }
.reports-filter-btn:last-child { border-radius: 0 4px 4px 0; border-left: none; }
.reports-filter-btn.active { background: var(--tag-blue-bg); color: var(--tag-blue-text); border-color: var(--tag-blue-text); }
.report-entry {
border-bottom: 1px solid var(--border-light);
padding: 12px 0;
}
.report-entry:last-child { border-bottom: none; }
.report-entry.resolved { opacity: 0.55; }
.report-entry-meta {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 6px;
}
.report-type-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
background: var(--tag-gray-bg);
color: var(--tag-gray-text);
}
.report-type-offline { background: var(--tag-red-bg); color: var(--tag-red-text); }
.report-type-frequency { background: var(--tag-blue-bg); color: var(--tag-blue-text); }
.report-type-callsign { background: var(--tag-green-bg); color: var(--tag-green-text); }
.report-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
background: var(--tag-gray-bg);
color: var(--tag-gray-text);
}
.report-status-open { background: var(--tag-red-bg); color: var(--tag-red-text); }
.report-rptr-btn {
background: none;
border: none;
padding: 0;
font-size: 13px;
font-weight: 700;
color: var(--tag-blue-text);
cursor: pointer;
}
.report-rptr-btn:hover { text-decoration: underline; }
.report-msg {
font-size: 13px;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 8px;
}
.report-actions { display: flex; gap: 8px; }
.report-action-btn {
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
}
.report-action-btn:hover { background: var(--hover-bg); color: var(--text); }
.report-action-btn.danger { color: var(--danger); }
.report-action-btn.danger:hover { background: var(--tag-red-bg); }
</style> </style>
</head> </head>
<body> <body>
@ -702,6 +796,7 @@
<input type="text" class="search-input" id="search-input" placeholder="Search callsign, city, country, network..." /> <input type="text" class="search-input" id="search-input" placeholder="Search callsign, city, country, network..." />
<button class="topbar-btn" id="toggle-filters-btn">Filters</button> <button class="topbar-btn" id="toggle-filters-btn">Filters</button>
<button class="topbar-btn" id="fetch-all-bm-btn">Fetch All BM</button> <button class="topbar-btn" id="fetch-all-bm-btn">Fetch All BM</button>
<button class="topbar-btn" id="reports-btn">Reports<span class="reports-badge" id="reports-badge" style="display:none">0</span></button>
<span class="topbar-info" id="total-info"></span> <span class="topbar-info" id="total-info"></span>
<button class="logout-btn" id="logout-btn">Logout</button> <button class="logout-btn" id="logout-btn">Logout</button>
</div> </div>
@ -778,6 +873,20 @@
<div class="history-modal-body" id="history-body"></div> <div class="history-modal-body" id="history-body"></div>
</div> </div>
<div class="history-overlay" id="reports-overlay"></div>
<div class="history-modal" id="reports-modal">
<div class="history-modal-header">
<h3>User Reports</h3>
<div class="reports-filter">
<button class="reports-filter-btn active" data-report-filter="open">Open</button>
<button class="reports-filter-btn" data-report-filter="all">All</button>
</div>
<button class="report-action-btn" id="reports-test-btn">Test push</button>
<button class="history-modal-close" id="reports-close">&times;</button>
</div>
<div class="history-modal-body" id="reports-body"></div>
</div>
<script> <script>
(function () { (function () {
"use strict"; "use strict";
@ -838,6 +947,15 @@
var historyBody = document.getElementById("history-body"); var historyBody = document.getElementById("history-body");
var historyClose = document.getElementById("history-close"); var historyClose = document.getElementById("history-close");
var reportsBtn = document.getElementById("reports-btn");
var reportsBadge = document.getElementById("reports-badge");
var reportsOverlay = document.getElementById("reports-overlay");
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) { function escapeHtml(s) {
if (!s) return ""; if (!s) return "";
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@ -852,6 +970,7 @@
loginWrap.style.display = "none"; loginWrap.style.display = "none";
adminWrap.style.display = "block"; adminWrap.style.display = "block";
fetchRepeaters(); fetchRepeaters();
refreshReportsBadge();
checkHash(); checkHash();
} }
@ -1619,6 +1738,155 @@
historyClose.addEventListener("click", closeHistory); historyClose.addEventListener("click", closeHistory);
historyOverlay.addEventListener("click", closeHistory); historyOverlay.addEventListener("click", closeHistory);
// === User reports ===
function refreshReportsBadge() {
apiFetch("/admin/api/reports?status=open&limit=1")
.then(function (data) {
if (data.open > 0) {
reportsBadge.textContent = data.open;
reportsBadge.style.display = "";
} else {
reportsBadge.style.display = "none";
}
})
.catch(function () {});
}
function showReports() {
reportsOverlay.style.display = "block";
reportsModal.classList.add("open");
loadReports();
}
function closeReports() {
reportsOverlay.style.display = "none";
reportsModal.classList.remove("open");
}
function loadReports() {
reportsBody.innerHTML = '<div class="history-empty">Loading...</div>';
var url = "/admin/api/reports";
if (reportsFilter === "open") url += "?status=open";
apiFetch(url)
.then(function (data) {
if (data.open > 0) {
reportsBadge.textContent = data.open;
reportsBadge.style.display = "";
} else {
reportsBadge.style.display = "none";
}
renderReports(data.reports);
})
.catch(function (err) {
if (err.message === "unauthorized") return;
reportsBody.innerHTML = '<div class="history-empty">Failed to load reports</div>';
});
}
function renderReports(reports) {
if (!reports.length) {
reportsBody.innerHTML = '<div class="history-empty">No reports</div>';
return;
}
var html = "";
for (var i = 0; i < reports.length; i++) {
var rep = reports[i];
var resolved = rep.status === "resolved";
html += '<div class="report-entry' + (resolved ? " resolved" : "") + '">';
html += '<div class="report-entry-meta">';
html += '<span class="history-date">' + escapeHtml(rep.created_at) + '</span>';
html += '<span class="report-type-badge report-type-' + escapeHtml(rep.report_type) + '">' + escapeHtml(rep.report_type) + '</span>';
html += '<span class="report-status report-status-' + escapeHtml(rep.status) + '">' + escapeHtml(rep.status) + '</span>';
html += '<button class="report-rptr-btn" data-repeater-id="' + rep.repeater_id + '">' +
escapeHtml(rep.callsign || "") + ' (#' + rep.repeater_id + ')</button>';
html += '</div>';
if (rep.message) {
html += '<div class="report-msg">' + escapeHtml(rep.message) + '</div>';
}
html += '<div class="report-actions">';
html += '<button class="report-action-btn" data-report-id="' + rep.id + '" data-report-action="' +
(resolved ? "open" : "resolved") + '">' + (resolved ? "Reopen" : "Mark resolved") + '</button>';
html += '<button class="report-action-btn danger" data-report-id="' + rep.id + '" data-report-action="delete">Delete</button>';
html += '</div>';
html += '</div>';
}
reportsBody.innerHTML = html;
}
reportsBody.addEventListener("click", function (e) {
var rptrBtn = e.target.closest(".report-rptr-btn");
if (rptrBtn) {
closeReports();
openDetailByID(parseInt(rptrBtn.getAttribute("data-repeater-id"), 10));
return;
}
var actionBtn = e.target.closest(".report-action-btn");
if (!actionBtn) return;
var id = parseInt(actionBtn.getAttribute("data-report-id"), 10);
var action = actionBtn.getAttribute("data-report-action");
actionBtn.disabled = true;
var req;
if (action === "delete") {
if (!confirm("Delete this report?")) {
actionBtn.disabled = false;
return;
}
req = apiFetch("/admin/api/reports/delete?id=" + id, { method: "DELETE" });
} else {
req = apiFetch("/admin/api/reports/status", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: id, status: action }),
});
}
req.then(loadReports).catch(function (err) {
if (err.message === "unauthorized") return;
actionBtn.disabled = false;
alert("Action failed");
});
});
document.querySelectorAll(".reports-filter-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
document.querySelectorAll(".reports-filter-btn").forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
reportsFilter = btn.getAttribute("data-report-filter");
loadReports();
});
});
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);
detailClose.addEventListener("click", closeDetail); detailClose.addEventListener("click", closeDetail);
detailCancel.addEventListener("click", closeDetail); detailCancel.addEventListener("click", closeDetail);
detailOverlay.addEventListener("click", closeDetail); detailOverlay.addEventListener("click", closeDetail);
@ -1627,7 +1895,9 @@
document.addEventListener("keydown", function (e) { document.addEventListener("keydown", function (e) {
if (e.key === "Escape") { if (e.key === "Escape") {
if (historyModal.classList.contains("open")) { if (reportsModal.classList.contains("open")) {
closeReports();
} else if (historyModal.classList.contains("open")) {
closeHistory(); closeHistory();
} else if (detailPanel.classList.contains("open")) { } else if (detailPanel.classList.contains("open")) {
closeDetail(); closeDetail();

View file

@ -100,13 +100,50 @@
die Heatmap-Funktion aktiv einschalten. die Heatmap-Funktion aktiv einschalten.
</p> </p>
<h2>Fehlermeldungen zu Repeaterdaten</h2>
<p>
&Uuml;ber die Funktion &bdquo;Fehler melden&ldquo; k&ouml;nnen Sie uns auf fehlerhafte
Repeaterdaten hinweisen. Gespeichert werden dabei ausschlie&szlig;lich:
</p>
<ul>
<li>ID und Rufzeichen des betroffenen Repeaters</li>
<li>die gew&auml;hlte Art der Meldung (au&szlig;er Betrieb, falsche Frequenz, ge&auml;ndertes Rufzeichen oder Sonstiges)</li>
<li>Ihr optionaler Freitext (maximal 300 Zeichen)</li>
<li>Zeitpunkt der Meldung sowie deren Bearbeitungsstatus</li>
</ul>
<p>
Es werden weder Ihre IP-Adresse noch Name, E-Mail-Adresse oder sonstige Kontaktdaten
zu Ihrer Meldung gespeichert. Meldungen sind f&uuml;r uns damit keiner Person
zuzuordnen. Bitte geben Sie im Freitextfeld keine personenbezogenen Daten an.
</p>
<p>
Um massenhafte Falschmeldungen zu verhindern, ist die Anzahl der Meldungen pro
IP-Adresse begrenzt. Die IP-Adresse wird dabei lediglich kurzzeitig im
Arbeitsspeicher gez&auml;hlt und weder dauerhaft gespeichert noch Ihrer Meldung
zugeordnet.
</p>
<p>
Die Verarbeitung erfolgt ausschlie&szlig;lich zu dem Zweck, die gemeldeten Fehler zu
pr&uuml;fen und die Repeaterdaten zu korrigieren. Eine Nutzung zu anderen Zwecken,
eine Auswertung oder eine Weitergabe an Dritte findet nicht statt. Zur Bearbeitung
wird der Inhalt Ihrer Meldung an unseren Benachrichtigungsdienst (ntfy) &uuml;bermittelt
und im internen &Auml;nderungsprotokoll des betroffenen Repeaters vermerkt.
Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der
Richtigkeit der angezeigten Repeaterdaten). Die Abgabe einer Meldung ist freiwillig.
</p>
<p>
Meldungen werden gel&ouml;scht, sobald sie f&uuml;r die Bearbeitung nicht mehr
ben&ouml;tigt werden.
</p>
<h2>Ihre Rechte</h2> <h2>Ihre Rechte</h2>
<p> <p>
Sie haben das Recht auf Auskunft, Berichtigung, L&ouml;schung und Einschr&auml;nkung Sie haben das Recht auf Auskunft, Berichtigung, L&ouml;schung und Einschr&auml;nkung
der Verarbeitung Ihrer personenbezogenen Daten sowie das Recht auf Beschwerde bei der Verarbeitung Ihrer personenbezogenen Daten sowie das Recht auf Beschwerde bei
einer Aufsichtsbeh&ouml;rde. Da wir jedoch keine personenbezogenen Daten &uuml;ber die einer Aufsichtsbeh&ouml;rde. &Uuml;ber die oben genannten Server-Log-Dateien hinaus
oben genannten Server-Log-Dateien hinaus erheben, fallen diese Rechte in der erheben wir jedoch keine personenbezogenen Daten; auch Fehlermeldungen werden ohne
Praxis kaum ins Gewicht. IP-Adresse und ohne Kontaktdaten gespeichert und sind daher keiner Person
zuzuordnen.
</p> </p>
</div> </div>
</body> </body>

View file

@ -164,6 +164,35 @@
</div> </div>
</div> </div>
</div> </div>
<div id="report-modal" class="cps-modal" style="display:none">
<div class="cps-modal-backdrop" id="report-modal-backdrop"></div>
<div class="cps-modal-dialog report-modal-dialog">
<div class="cps-modal-header">
<h2 data-i18n="report_title">Report incorrect data</h2>
<button type="button" class="cps-modal-close" id="report-modal-close">&times;</button>
</div>
<div class="cps-modal-body">
<p class="cps-exp-note" data-i18n="report_intro">Something wrong with this repeater? Let us know — every report is reviewed manually.</p>
<div class="report-target" id="report-target"></div>
<div class="report-types">
<label class="report-type-label"><input type="radio" name="report-type" value="offline" checked> <span data-i18n="report_type_offline">Repeater is offline</span></label>
<label class="report-type-label"><input type="radio" name="report-type" value="frequency"> <span data-i18n="report_type_frequency">Frequency is incorrect</span></label>
<label class="report-type-label"><input type="radio" name="report-type" value="callsign"> <span data-i18n="report_type_callsign">Callsign has changed</span></label>
<label class="report-type-label"><input type="radio" name="report-type" value="other"> <span data-i18n="report_type_other">Other</span></label>
</div>
<label class="report-message-label" for="report-message" data-i18n="report_message_label">Message (optional)</label>
<textarea id="report-message" class="report-message" rows="4" maxlength="300" data-i18n-placeholder="report_message_placeholder" placeholder="Additional details (max. 300 characters)"></textarea>
<div class="report-counter"><span id="report-char-count">0</span>/300</div>
<div class="report-feedback" id="report-feedback" style="display:none"></div>
</div>
<div class="cps-modal-footer">
<div class="cps-step-buttons">
<button type="button" class="cps-btn-secondary" id="report-cancel" data-i18n="report_cancel">Cancel</button>
<button type="button" class="cps-btn-primary" id="report-submit" data-i18n="report_submit">Send report</button>
</div>
</div>
</div>
</div>
<script src="lib/i18next.min.js"></script> <script src="lib/i18next.min.js"></script>
<script src="lib/i18next-browser-languagedetector.min.js"></script> <script src="lib/i18next-browser-languagedetector.min.js"></script>
<script src="lib/i18next-http-backend.min.js"></script> <script src="lib/i18next-http-backend.min.js"></script>

View file

@ -62,5 +62,24 @@
"cps_at_step1_label": "Schritt 1: Talkgroups importieren", "cps_at_step1_label": "Schritt 1: Talkgroups importieren",
"cps_at_download_tg": "Talkgroups CSV herunterladen", "cps_at_download_tg": "Talkgroups CSV herunterladen",
"cps_at_step2_label": "Schritt 2: Kanäle importieren", "cps_at_step2_label": "Schritt 2: Kanäle importieren",
"cps_at_download_ch": "Kanäle CSV herunterladen" "cps_at_download_ch": "Kanäle CSV herunterladen",
"report_btn": "Fehler melden",
"report_title": "Falsche Daten melden",
"report_intro": "Stimmt etwas an diesem Relais nicht? Sag uns Bescheid. Jede Meldung wird manuell geprüft.",
"report_type_offline": "Relais ist außer Betrieb",
"report_type_frequency": "Frequenz ist falsch",
"report_type_callsign": "Rufzeichen hat sich geändert",
"report_type_other": "Sonstiges",
"report_message_label": "Nachricht (optional)",
"report_message_placeholder": "Weitere Details (max. 300 Zeichen)",
"report_cancel": "Abbrechen",
"report_submit": "Meldung senden",
"report_sending": "Wird gesendet...",
"report_thanks": "Danke! Deine Meldung wurde übermittelt.",
"report_error": "Meldung konnte nicht gesendet werden. Bitte später erneut versuchen.",
"report_rate_limited": "Zu viele Meldungen. Bitte später erneut versuchen.",
"report_err_repeater": "Unbekanntes Relais.",
"report_err_type": "Bitte wähle aus, was nicht stimmt.",
"report_err_required": "Bitte beschreibe das Problem.",
"report_err_too_long": "Nachricht ist zu lang (max. 300 Zeichen)."
} }

View file

@ -62,5 +62,24 @@
"cps_at_step1_label": "Step 1: Import talk groups", "cps_at_step1_label": "Step 1: Import talk groups",
"cps_at_download_tg": "Download Talkgroups CSV", "cps_at_download_tg": "Download Talkgroups CSV",
"cps_at_step2_label": "Step 2: Import channels", "cps_at_step2_label": "Step 2: Import channels",
"cps_at_download_ch": "Download Channels CSV" "cps_at_download_ch": "Download Channels CSV",
"report_btn": "Report an issue",
"report_title": "Report incorrect data",
"report_intro": "Something wrong with this repeater? Let us know. Every report is reviewed manually.",
"report_type_offline": "Repeater is offline",
"report_type_frequency": "Frequency is incorrect",
"report_type_callsign": "Callsign has changed",
"report_type_other": "Other",
"report_message_label": "Message (optional)",
"report_message_placeholder": "Additional details (max. 300 characters)",
"report_cancel": "Cancel",
"report_submit": "Send report",
"report_sending": "Sending...",
"report_thanks": "Thanks! Your report has been submitted.",
"report_error": "Could not submit report. Please try again later.",
"report_rate_limited": "Too many reports. Please try again later.",
"report_err_repeater": "Unknown repeater.",
"report_err_type": "Please choose what is wrong.",
"report_err_required": "Please describe the problem.",
"report_err_too_long": "Message is too long (max. 300 characters)."
} }

View file

@ -62,5 +62,24 @@
"cps_at_step1_label": "Paso 1: Importar grupos", "cps_at_step1_label": "Paso 1: Importar grupos",
"cps_at_download_tg": "Descargar grupos CSV", "cps_at_download_tg": "Descargar grupos CSV",
"cps_at_step2_label": "Paso 2: Importar canales", "cps_at_step2_label": "Paso 2: Importar canales",
"cps_at_download_ch": "Descargar canales CSV" "cps_at_download_ch": "Descargar canales CSV",
"report_btn": "Informar de un error",
"report_title": "Informar de datos incorrectos",
"report_intro": "¿Algo no está bien en este repetidor? Cuéntanoslo. Cada informe se revisa manualmente.",
"report_type_offline": "El repetidor está fuera de servicio",
"report_type_frequency": "La frecuencia es incorrecta",
"report_type_callsign": "El indicativo ha cambiado",
"report_type_other": "Otro",
"report_message_label": "Mensaje (opcional)",
"report_message_placeholder": "Detalles adicionales (máx. 300 caracteres)",
"report_cancel": "Cancelar",
"report_submit": "Enviar informe",
"report_sending": "Enviando...",
"report_thanks": "¡Gracias! Tu informe se ha enviado.",
"report_error": "No se pudo enviar el informe. Inténtalo de nuevo más tarde.",
"report_rate_limited": "Demasiados informes. Inténtalo de nuevo más tarde.",
"report_err_repeater": "Repetidor desconocido.",
"report_err_type": "Elige qué es lo que falla.",
"report_err_required": "Describe el problema.",
"report_err_too_long": "El mensaje es demasiado largo (máx. 300 caracteres)."
} }

View file

@ -62,5 +62,24 @@
"cps_at_step1_label": "\u00c9tape 1 : Importer les groupes", "cps_at_step1_label": "\u00c9tape 1 : Importer les groupes",
"cps_at_download_tg": "T\u00e9l\u00e9charger groupes CSV", "cps_at_download_tg": "T\u00e9l\u00e9charger groupes CSV",
"cps_at_step2_label": "\u00c9tape 2 : Importer les canaux", "cps_at_step2_label": "\u00c9tape 2 : Importer les canaux",
"cps_at_download_ch": "T\u00e9l\u00e9charger canaux CSV" "cps_at_download_ch": "T\u00e9l\u00e9charger canaux CSV",
"report_btn": "Signaler un probl\u00e8me",
"report_title": "Signaler des donn\u00e9es incorrectes",
"report_intro": "Un probl\u00e8me avec ce relais ? Dites-le-nous. Chaque signalement est v\u00e9rifi\u00e9 manuellement.",
"report_type_offline": "Le relais est hors service",
"report_type_frequency": "La fr\u00e9quence est incorrecte",
"report_type_callsign": "L'indicatif a chang\u00e9",
"report_type_other": "Autre",
"report_message_label": "Message (facultatif)",
"report_message_placeholder": "D\u00e9tails suppl\u00e9mentaires (max. 300 caract\u00e8res)",
"report_cancel": "Annuler",
"report_submit": "Envoyer le signalement",
"report_sending": "Envoi...",
"report_thanks": "Merci ! Votre signalement a \u00e9t\u00e9 envoy\u00e9.",
"report_error": "Impossible d'envoyer le signalement. R\u00e9essayez plus tard.",
"report_rate_limited": "Trop de signalements. R\u00e9essayez plus tard.",
"report_err_repeater": "Relais inconnu.",
"report_err_type": "Choisissez ce qui ne va pas.",
"report_err_required": "D\u00e9crivez le probl\u00e8me.",
"report_err_too_long": "Message trop long (max. 300 caract\u00e8res)."
} }

View file

@ -62,5 +62,24 @@
"cps_at_step1_label": "Passo 1: Importa gruppi", "cps_at_step1_label": "Passo 1: Importa gruppi",
"cps_at_download_tg": "Scarica gruppi CSV", "cps_at_download_tg": "Scarica gruppi CSV",
"cps_at_step2_label": "Passo 2: Importa canali", "cps_at_step2_label": "Passo 2: Importa canali",
"cps_at_download_ch": "Scarica canali CSV" "cps_at_download_ch": "Scarica canali CSV",
"report_btn": "Segnala un problema",
"report_title": "Segnala dati errati",
"report_intro": "C'è qualcosa che non va in questo ripetitore? Faccelo sapere. Ogni segnalazione viene verificata manualmente.",
"report_type_offline": "Il ripetitore è fuori servizio",
"report_type_frequency": "La frequenza è errata",
"report_type_callsign": "Il nominativo è cambiato",
"report_type_other": "Altro",
"report_message_label": "Messaggio (facoltativo)",
"report_message_placeholder": "Dettagli aggiuntivi (max. 300 caratteri)",
"report_cancel": "Annulla",
"report_submit": "Invia segnalazione",
"report_sending": "Invio...",
"report_thanks": "Grazie! La tua segnalazione è stata inviata.",
"report_error": "Impossibile inviare la segnalazione. Riprova più tardi.",
"report_rate_limited": "Troppe segnalazioni. Riprova più tardi.",
"report_err_repeater": "Ripetitore sconosciuto.",
"report_err_type": "Scegli cosa non va.",
"report_err_required": "Descrivi il problema.",
"report_err_too_long": "Messaggio troppo lungo (max. 300 caratteri)."
} }

View file

@ -64,5 +64,24 @@
"cps_at_step1_label": "Krok 1: Importuj grupy", "cps_at_step1_label": "Krok 1: Importuj grupy",
"cps_at_download_tg": "Pobierz grupy CSV", "cps_at_download_tg": "Pobierz grupy CSV",
"cps_at_step2_label": "Krok 2: Importuj kana\u0142y", "cps_at_step2_label": "Krok 2: Importuj kana\u0142y",
"cps_at_download_ch": "Pobierz kana\u0142y CSV" "cps_at_download_ch": "Pobierz kana\u0142y CSV",
"report_btn": "Zg\u0142o\u015b b\u0142\u0105d",
"report_title": "Zg\u0142o\u015b nieprawid\u0142owe dane",
"report_intro": "Co\u015b si\u0119 nie zgadza z tym przemiennikiem? Daj nam zna\u0107. Ka\u017cde zg\u0142oszenie sprawdzamy r\u0119cznie.",
"report_type_offline": "Przemiennik nie dzia\u0142a",
"report_type_frequency": "Cz\u0119stotliwo\u015b\u0107 jest nieprawid\u0142owa",
"report_type_callsign": "Znak wywo\u0142awczy si\u0119 zmieni\u0142",
"report_type_other": "Inne",
"report_message_label": "Wiadomo\u015b\u0107 (opcjonalnie)",
"report_message_placeholder": "Dodatkowe szczeg\u00f3\u0142y (maks. 300 znak\u00f3w)",
"report_cancel": "Anuluj",
"report_submit": "Wy\u015blij zg\u0142oszenie",
"report_sending": "Wysy\u0142anie...",
"report_thanks": "Dzi\u0119kujemy! Zg\u0142oszenie zosta\u0142o wys\u0142ane.",
"report_error": "Nie uda\u0142o si\u0119 wys\u0142a\u0107 zg\u0142oszenia. Spr\u00f3buj ponownie p\u00f3\u017aniej.",
"report_rate_limited": "Zbyt wiele zg\u0142osze\u0144. Spr\u00f3buj ponownie p\u00f3\u017aniej.",
"report_err_repeater": "Nieznany przemiennik.",
"report_err_type": "Wybierz, co jest nie tak.",
"report_err_required": "Opisz problem.",
"report_err_too_long": "Wiadomo\u015b\u0107 jest za d\u0142uga (maks. 300 znak\u00f3w)."
} }

View file

@ -1073,3 +1073,96 @@ html, body {
height: 100%; height: 100%;
} }
} }
/* Report Modal */
.rptr-report-btn {
display: block;
width: 100%;
margin-top: 10px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--clear-btn-bg);
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.rptr-report-btn:hover {
background: var(--clear-btn-hover);
color: var(--text);
}
.report-modal-dialog {
max-width: 460px;
}
.report-target {
font-size: 13px;
font-weight: 700;
color: var(--text);
margin-bottom: 10px;
}
.report-types {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.report-type-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text);
cursor: pointer;
}
.report-message-label {
display: block;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 5px;
}
.report-message {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--input-bg);
color: var(--text);
font-family: inherit;
font-size: 13px;
resize: vertical;
outline: none;
}
.report-message:focus {
border-color: #4CAF50;
}
.report-counter {
font-size: 11px;
color: var(--text-fainter);
text-align: right;
margin-top: 4px;
}
.report-feedback {
margin-top: 10px;
font-size: 12px;
font-weight: 600;
color: #ef5350;
}
.report-feedback.success {
color: #4CAF50;
}