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 }