DMRmap/backend/reports.go
2026-08-04 14:33:00 +02:00

346 lines
9.4 KiB
Go

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
}