Implement user reports
This commit is contained in:
parent
8f842a2df3
commit
14550b3797
21 changed files with 1191 additions and 10 deletions
14
README.md
14
README.md
|
|
@ -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
|
||||
- Detailed repeater popups (frequencies, color code, timeslots, hardware, power, antenna height, BrandMeister status)
|
||||
- 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
|
||||
- Coordinate display with Maidenhead grid locator
|
||||
- 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`
|
||||
- **Frontend:** TypeScript (bundled with esbuild), Leaflet, i18next
|
||||
- **Testing:** Vitest (92 unit tests)
|
||||
- **Testing:** Vitest (115 unit tests)
|
||||
- **Infrastructure:** Docker Compose, multi-stage Dockerfile
|
||||
|
||||
## 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 |
|
||||
| [Nominatim](https://nominatim.openstreetmap.org) | Address geocoding and autocomplete (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 or basic auth) |
|
||||
|
||||
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,13 @@ docker compose -f compose.test.yml run --rm test
|
|||
| `MIGRATIONS_DIR` | `migrations` | Path to SQL migration files |
|
||||
| `BM_SYNC` | *(disabled)* | Set to `true` to enable periodic BrandMeister device sync |
|
||||
| `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_USER` | *(none)* | ntfy username. When set, the token is sent as HTTP basic auth (`user:token`) |
|
||||
| `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.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
|
|
@ -85,6 +94,7 @@ docker compose -f compose.test.yml run --rm test
|
|||
| 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/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
|
||||
|
||||
|
|
@ -94,6 +104,8 @@ docker compose -f compose.test.yml run --rm test
|
|||
│ ├── db.go # DB queries, Repeater struct, geo helpers
|
||||
│ ├── handlers.go # Public API handlers
|
||||
│ ├── 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
|
||||
│ ├── seed.go # Database seeding from JSON dumps
|
||||
│ ├── migrate.go # Goose migration runner
|
||||
|
|
|
|||
|
|
@ -36,12 +36,15 @@ func main() {
|
|||
log.Println("BM device sync disabled (set BM_SYNC=true to enable)")
|
||||
}
|
||||
|
||||
notifier := newNtfyNotifierFromEnv()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/repeaters", handleRepeaters(db))
|
||||
mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(db))
|
||||
mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db))
|
||||
mux.HandleFunc("/api/repeater", handleRepeaterByID(db))
|
||||
mux.HandleFunc("/api/repeaters/search", handleSearchRepeaters(db))
|
||||
mux.HandleFunc("/api/reports", handleCreateReport(db, notifier))
|
||||
mux.HandleFunc("/api/version", handleVersion())
|
||||
|
||||
if adminToken := os.Getenv("ADMIN_TOKEN"); adminToken != "" {
|
||||
|
|
@ -53,6 +56,9 @@ func main() {
|
|||
adminAPI.HandleFunc("/admin/api/repeaters/save-bm", handleAdminSaveBMData(db))
|
||||
adminAPI.HandleFunc("/admin/api/repeaters/remove-bm-tag", handleAdminRemoveBMTag(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))
|
||||
mux.Handle("/admin/api/", adminAuth(adminToken, adminAPI))
|
||||
log.Println("Admin interface enabled at /admin/")
|
||||
}
|
||||
|
|
|
|||
18
backend/migrations/006_user_reports.sql
Normal file
18
backend/migrations/006_user_reports.sql
Normal 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;
|
||||
101
backend/ntfy.go
Normal file
101
backend/ntfy.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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
|
||||
}
|
||||
|
||||
return &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},
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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 != "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
346
backend/reports.go
Normal file
346
backend/reports.go
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
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}`))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -31,6 +31,11 @@ services:
|
|||
- ADMIN_TOKEN=adminparty
|
||||
- STATIC_DIR=/app/frontend/static
|
||||
- MIGRATIONS_DIR=/app/backend/migrations
|
||||
- NTFY_URL=${NTFY_URL:-}
|
||||
- NTFY_TOPIC=${NTFY_TOPIC:-}
|
||||
- NTFY_USER=${NTFY_USER:-}
|
||||
- NTFY_TOKEN=${NTFY_TOKEN:-}
|
||||
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:8080}
|
||||
|
||||
volumes:
|
||||
go-mod-cache:
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ services:
|
|||
- LISTEN_ADDR=:8080
|
||||
- BM_SYNC=true
|
||||
- ADMIN_TOKEN=${ADMIN_TOKEN:-}
|
||||
- NTFY_URL=${NTFY_URL:-}
|
||||
- NTFY_TOPIC=${NTFY_TOPIC:-}
|
||||
- NTFY_USER=${NTFY_USER:-}
|
||||
- NTFY_TOKEN=${NTFY_TOKEN:-}
|
||||
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { buildChannel, generateCpsXml, generateContactsCsv, CPS_CSV_HEADER, CPS_
|
|||
import { generateAnytoneContactsCsv, generateAnytoneChannelsCsv, ANYTONE_CH_HEADER } from "./cps-anytone";
|
||||
import { getSelectedBand as _getSelectedBand, getSelectedNetworks as _getSelectedNetworks } from "./filters";
|
||||
import { buildPopup as _buildPopup } from "./popup";
|
||||
import { validateReport } from "./report";
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
|
|
@ -88,6 +89,20 @@ import { buildPopup as _buildPopup } from "./popup";
|
|||
var cpsAtTgBtn = document.getElementById("cps-at-tg-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 cpsActiveRepeaters = {};
|
||||
var cpsTalkgroups = [];
|
||||
|
|
@ -502,6 +517,68 @@ import { buildPopup as _buildPopup } from "./popup";
|
|||
// === Popup ===
|
||||
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 ===
|
||||
function displayRepeaters(repeaters) {
|
||||
markerLayer.clearLayers();
|
||||
|
|
@ -1125,9 +1202,30 @@ import { buildPopup as _buildPopup } from "./popup";
|
|||
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 ===
|
||||
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) {
|
||||
if (e.key === "Escape" && cpsModal.style.display !== "none") {
|
||||
if (cpsAcList.children.length) {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,19 @@ describe("buildPopup", () => {
|
|||
expect(html).toContain("<script>");
|
||||
});
|
||||
|
||||
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" onclick="evil()"');
|
||||
});
|
||||
|
||||
it("uses translate function for labels", () => {
|
||||
const keys: string[] = [];
|
||||
const captureT = (key: string) => { keys.push(key); return key; };
|
||||
|
|
|
|||
|
|
@ -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>";
|
||||
if (r.inactive)
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
51
frontend/src/report.test.ts
Normal file
51
frontend/src/report.test.ts
Normal 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
41
frontend/src/report.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
|
|
@ -683,6 +683,100 @@
|
|||
.history-changes .old-val { color: var(--tag-red-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; }
|
||||
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -702,6 +796,7 @@
|
|||
<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="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>
|
||||
<button class="logout-btn" id="logout-btn">Logout</button>
|
||||
</div>
|
||||
|
|
@ -778,6 +873,19 @@
|
|||
<div class="history-modal-body" id="history-body"></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="history-modal-close" id="reports-close">×</button>
|
||||
</div>
|
||||
<div class="history-modal-body" id="reports-body"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
|
@ -838,6 +946,14 @@
|
|||
var historyBody = document.getElementById("history-body");
|
||||
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 reportsFilter = "open";
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return "";
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
|
@ -852,6 +968,7 @@
|
|||
loginWrap.style.display = "none";
|
||||
adminWrap.style.display = "block";
|
||||
fetchRepeaters();
|
||||
refreshReportsBadge();
|
||||
checkHash();
|
||||
}
|
||||
|
||||
|
|
@ -1619,6 +1736,131 @@
|
|||
historyClose.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();
|
||||
});
|
||||
});
|
||||
|
||||
reportsBtn.addEventListener("click", showReports);
|
||||
reportsClose.addEventListener("click", closeReports);
|
||||
reportsOverlay.addEventListener("click", closeReports);
|
||||
|
||||
detailClose.addEventListener("click", closeDetail);
|
||||
detailCancel.addEventListener("click", closeDetail);
|
||||
detailOverlay.addEventListener("click", closeDetail);
|
||||
|
|
@ -1627,7 +1869,9 @@
|
|||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape") {
|
||||
if (historyModal.classList.contains("open")) {
|
||||
if (reportsModal.classList.contains("open")) {
|
||||
closeReports();
|
||||
} else if (historyModal.classList.contains("open")) {
|
||||
closeHistory();
|
||||
} else if (detailPanel.classList.contains("open")) {
|
||||
closeDetail();
|
||||
|
|
|
|||
|
|
@ -164,6 +164,35 @@
|
|||
</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">×</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-browser-languagedetector.min.js"></script>
|
||||
<script src="lib/i18next-http-backend.min.js"></script>
|
||||
|
|
|
|||
|
|
@ -62,5 +62,24 @@
|
|||
"cps_at_step1_label": "Schritt 1: Talkgroups importieren",
|
||||
"cps_at_download_tg": "Talkgroups CSV herunterladen",
|
||||
"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)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,5 +62,24 @@
|
|||
"cps_at_step1_label": "Step 1: Import talk groups",
|
||||
"cps_at_download_tg": "Download Talkgroups CSV",
|
||||
"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)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,5 +62,24 @@
|
|||
"cps_at_step1_label": "Paso 1: Importar grupos",
|
||||
"cps_at_download_tg": "Descargar grupos CSV",
|
||||
"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)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,5 +62,24 @@
|
|||
"cps_at_step1_label": "\u00c9tape 1 : Importer les groupes",
|
||||
"cps_at_download_tg": "T\u00e9l\u00e9charger groupes CSV",
|
||||
"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)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,5 +62,24 @@
|
|||
"cps_at_step1_label": "Passo 1: Importa gruppi",
|
||||
"cps_at_download_tg": "Scarica gruppi CSV",
|
||||
"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)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,5 +64,24 @@
|
|||
"cps_at_step1_label": "Krok 1: Importuj grupy",
|
||||
"cps_at_download_tg": "Pobierz grupy CSV",
|
||||
"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)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1073,3 +1073,96 @@ html, body {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue