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:
co-authored by
Claude Opus 5.5
parent
2714fdb1a5
commit
030a5bd943
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user