346 lines
9.4 KiB
Go
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
|
|
}
|