Moderation entfernt, JSON-API unter /api

- Moderationsseite und Melde-Funktion (report) komplett ausgebaut; folgt
  als eigenständiges Projekt
- alle API-Endpunkte unter /api, dort ausschließlich JSON: auch 404, 405,
  Rate-Limit (429) und Panics (500)
- Fehler nur über HTTP-Status; stille DB-Fehler in stats, logout und
  Vote-Zählern liefern jetzt 500 statt Nullen
- /auth/headerbar entfernt (Frontend nutzt /api/user/info)
- Frontend auf /api und statusbasierte Auswertung umgestellt
- notes/api.md neu als Referenz der aktuellen API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-26 23:10:02 +02:00
co-authored by Claude Opus 5.5
parent 2714fdb1a5
commit 030a5bd943
21 changed files with 430 additions and 726 deletions
+45 -24
View File
@@ -223,53 +223,73 @@ func recordImpression(path, anonIP, asn string) {
// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite. Anders als
// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate
// rechnen. Öffentlich, kein Auth.
func handleStatsDetail(w http.ResponseWriter, r *http.Request) {
func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
var users, entries, toplevel, votesLeft, votesRight int64
db.QueryRow(`SELECT COUNT(*) FROM account`).Scan(&users)
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries)
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0`).Scan(&toplevel)
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'left'`).Scan(&votesLeft)
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'right'`).Scan(&votesRight)
if err := db.QueryRow(
`SELECT (SELECT COUNT(*) FROM account),
(SELECT COUNT(*) FROM entry WHERE deleted = 0),
(SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0),
(SELECT COUNT(*) FROM vote WHERE mode = 'left'),
(SELECT COUNT(*) FROM vote WHERE mode = 'right')`).
Scan(&users, &entries, &toplevel, &votesLeft, &votesRight); err != nil {
return Internal(err)
}
var imprTotal, visitorsTotal int64
db.QueryRow(`SELECT COALESCE(SUM(hits), 0)::bigint, COUNT(DISTINCT anon_ip) FROM impression`).
Scan(&imprTotal, &visitorsTotal)
if err := db.QueryRow(`SELECT COALESCE(SUM(hits), 0)::bigint, COUNT(DISTINCT anon_ip) FROM impression`).
Scan(&imprTotal, &visitorsTotal); err != nil {
return Internal(err)
}
// Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das
// Frontend dreht für die Achse auf aufsteigend.
daily := []map[string]any{}
if rows, err := db.Query(
rows, err := db.Query(
`SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip)
FROM impression
GROUP BY day
ORDER BY day DESC
LIMIT 30`); err == nil {
defer rows.Close()
for rows.Next() {
var day string
var hits, visitors int64
rows.Scan(&day, &hits, &visitors)
daily = append(daily, map[string]any{"day": day, "impressions": hits, "visitors": visitors})
LIMIT 30`)
if err != nil {
return Internal(err)
}
defer rows.Close()
for rows.Next() {
var day string
var hits, visitors int64
if err := rows.Scan(&day, &hits, &visitors); err != nil {
return Internal(err)
}
daily = append(daily, map[string]any{"day": day, "impressions": hits, "visitors": visitors})
}
if err := rows.Err(); err != nil {
return Internal(err)
}
// Netze nach Betreiber (autonomes System). Leere asn ('') -> "(unbekannt)".
topASNs := []map[string]any{}
if rows, err := db.Query(
rows, err = db.Query(
`SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a,
SUM(hits)::bigint AS h,
COUNT(DISTINCT anon_ip)
FROM impression
GROUP BY a
ORDER BY h DESC
LIMIT 10`); err == nil {
defer rows.Close()
for rows.Next() {
var asn string
var hits, networks int64
rows.Scan(&asn, &hits, &networks)
topASNs = append(topASNs, map[string]any{"asn": asn, "hits": hits, "networks": networks})
LIMIT 10`)
if err != nil {
return Internal(err)
}
defer rows.Close()
for rows.Next() {
var asn string
var hits, networks int64
if err := rows.Scan(&asn, &hits, &networks); err != nil {
return Internal(err)
}
topASNs = append(topASNs, map[string]any{"asn": asn, "hits": hits, "networks": networks})
}
if err := rows.Err(); err != nil {
return Internal(err)
}
writeJSON(w, http.StatusOK, map[string]any{
@@ -283,6 +303,7 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) {
"daily": daily,
"top_asns": topASNs,
})
return nil
}
// serveIndex liefert die Startseite. Eigene Route (statt FileServer-Catch-All),