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
+88 -52
View File
@@ -3,11 +3,13 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"runtime/debug"
"syscall"
"time"
@@ -98,6 +100,10 @@ func envOr(key, fallback string) string {
// routes baut den HTTP-Handler mit allen Endpunkten. In main() und in den
// Tests identisch verwendet.
//
// Aufteilung: alles unter /api spricht ausschließlich JSON -- auch 404, 405,
// Rate-Limit und Panics. Der Rest liefert die HTML-Seiten und Dateien des
// Frontends.
func routes() http.Handler {
r := chi.NewRouter()
// RealIP übernimmt X-Forwarded-For/X-Real-IP vom Reverse-Proxy, damit
@@ -107,65 +113,65 @@ func routes() http.Handler {
// spoofbar.
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(recoverJSON)
r.Use(secHeaders)
r.Use(checkOrigin)
// --- Auth (öffentlich) ---
// Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt bremst
// zusätzlich, aber das Limit kappt automatisiertes Durchprobieren früh.
r.Group(func(r chi.Router) {
r.Use(httprate.LimitByIP(20, time.Minute))
r.Post("/auth/login", api(handleLogin))
r.Post("/auth/newuser", api(handleNewUser))
r.Route("/api", func(r chi.Router) {
r.NotFound(api(func(http.ResponseWriter, *http.Request) error {
return NotFound("api.not_found", "Unbekannter Endpunkt.")
}))
r.MethodNotAllowed(api(func(http.ResponseWriter, *http.Request) error {
return MethodNotAllowed("api.method_not_allowed", "Methode nicht erlaubt.")
}))
// --- Auth (öffentlich) ---
// Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt
// bremst zusätzlich, aber das Limit kappt automatisiertes Durchprobieren
// früh.
r.Group(func(r chi.Router) {
r.Use(limitByIP(20, time.Minute))
r.Post("/auth/login", api(handleLogin))
r.Post("/auth/newuser", api(handleNewUser))
})
// Logout als POST: SameSite=Lax schickt Cookies bei Top-Level-GET-
// Navigationen mit -- als GET könnte eine fremde Seite Nutzer per Link
// ausloggen (CSRF). POST ist durch Lax + checkOrigin gedeckt.
r.Post("/auth/logout", api(handleLogout))
r.Get("/auth/sessioninfo", api(handleSessionInfo))
r.Get("/stats", api(handleStats))
r.Get("/stats/detail", api(handleStatsDetail))
// --- Entries (öffentlich) ---
r.Get("/entry/feed/{page}", api(handleFeed))
r.Get("/entry/{pid}/votes", api(handleVotes))
r.Get("/entry/{pid}/thread", api(handleThread))
// --- User (öffentlich) ---
r.Get("/u/{username}/info", api(handleUserPage))
r.Get("/u/{username}/feed/{page}", api(handleUserFeed))
// --- Geschützt (Session erforderlich) ---
r.Group(func(r chi.Router) {
r.Use(requireAuth)
r.With(blockTOR).Post("/entry/create", api(handleCreateEntry))
r.Post("/entry/{pid}/vote", api(handleVote))
r.Post("/entry/{pid}/bump", api(handleBump))
r.Post("/entry/{pid}/edit", api(handleEditEntry))
r.Post("/entry/{pid}/delete", api(handleDeleteEntry))
r.Get("/user/info", api(handleUserInfo))
r.Post("/user/rename", api(handleUserRename))
r.Post("/user/avatar", api(handleSetAvatar))
r.Post("/user/delete", api(handleUserDelete))
})
})
// Logout als POST: SameSite=Lax schickt Cookies bei Top-Level-GET-
// Navigationen mit -- als GET könnte eine fremde Seite Nutzer per Link
// ausloggen (CSRF). POST ist durch Lax + checkOrigin gedeckt.
r.Post("/auth/logout", handleLogout)
r.Get("/auth/sessioninfo", api(handleSessionInfo))
r.Get("/auth/headerbar", handleHeaderbar)
r.Get("/stats", handleStats)
r.Get("/stats/detail", handleStatsDetail)
// Moderations-Queue (Meldungen). Basic-Auth direkt in der App (modBasicAuth),
// weil TOR-Zugriffe die Go-App direkt erreichen und NICHT über Caddy laufen --
// ein rein proxy-seitiger Schutz hätte diese Lücke offen gelassen. Caddy
// erzwingt zusätzlich TLS für den Clearnet-Weg.
r.With(modBasicAuth).Get("/moderation", api(handleModeration))
// HTML-Seiten: mit Impression-Tracking (anonymisiert, siehe stats.go). Nur
// echte Seitenaufrufe, nicht API/Assets.
// --- HTML-Seiten ---
// Mit Impression-Tracking (anonymisiert, siehe stats.go). Nur echte
// Seitenaufrufe, nicht API/Assets.
r.With(trackImpression).Get("/", serveIndex)
// --- Entries ---
r.Get("/entry/feed/{page}", api(handleFeed))
r.Get("/entry/{pid}/votes", api(handleVotes))
r.Get("/entry/{pid}/thread", api(handleThread))
r.With(trackImpression).Get("/e/{pid}", serveEntryPage)
// Melden: öffentlich (jeder darf melden, Moderationshilfe), aber pro IP
// gedrosselt, damit der auth-lose Endpunkt nicht zum Spam-Kanal wird.
r.With(httprate.LimitByIP(10, time.Minute)).Post("/entry/{pid}/report", api(handleReport))
// --- User (öffentlich) ---
r.With(trackImpression).Get("/u/{username}", serveUserPage)
r.Get("/u/{username}/info", api(handleUserPage))
r.Get("/u/{username}/feed/{page}", api(handleUserFeed))
// --- Geschützt (Session erforderlich) ---
r.Group(func(r chi.Router) {
r.Use(requireAuth)
r.With(blockTOR).Post("/entry/create", api(handleCreateEntry))
r.Post("/entry/{pid}/vote", api(handleVote))
r.Post("/entry/{pid}/bump", api(handleBump))
r.Post("/entry/{pid}/edit", api(handleEditEntry))
r.Post("/entry/{pid}/delete", api(handleDeleteEntry))
r.Get("/user/info", api(handleUserInfo))
r.Post("/user/rename", api(handleUserRename))
r.Post("/user/avatar", api(handleSetAvatar))
r.Post("/user/delete", api(handleUserDelete))
})
// Hochgeladene Medien + alte statische Assets. nosniff (global via
// secHeaders) verhindert, dass der Browser eine durchgereichte (nicht
@@ -173,12 +179,42 @@ func routes() http.Handler {
// Polyglot/Stored-XSS über Bild-Uploads.
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// Neues JS-Frontend
// Frontend-Dateien (JS, CSS, weitere Seiten)
r.Handle("/*", http.FileServer(http.Dir("web")))
return r
}
// limitByIP ist httprate.LimitByIP mit JSON-Antwort statt des Klartext-429.
func limitByIP(n int, window time.Duration) func(http.Handler) http.Handler {
return httprate.Limit(n, window,
httprate.WithKeyFuncs(httprate.KeyByIP),
httprate.WithLimitHandler(api(func(http.ResponseWriter, *http.Request) error {
return RateLimited("rate.limited", "Zu viele Anfragen. Bitte kurz warten.")
})),
)
}
// recoverJSON ersetzt middleware.Recoverer: eine Panic wird geloggt (mit Stack)
// und als normaler 500 im JSON-Fehlerformat beantwortet. http.ErrAbortHandler
// ist ein gewollter Verbindungsabbruch und wird durchgereicht.
func recoverJSON(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
rec := recover()
if rec == nil {
return
}
if rec == http.ErrAbortHandler {
panic(rec)
}
log.Printf("panic: %v\n%s", rec, debug.Stack())
writeAPIError(w, Internal(fmt.Errorf("panic: %v", rec)))
}()
next.ServeHTTP(w, r)
})
}
// secHeaders setzt Standard-Security-Header auf alle Antworten:
// - nosniff: Browser darf den deklarierten Content-Type nicht überstimmen
// (wichtig für durchgereichte GIF-Uploads, siehe storeImage).