- report-Tabelle wieder im Schema (kompatibel zur bestehenden auf PROD)
- POST /api/entry/{pid}/report, nur angemeldet, speichert Melder-uid;
erneutes Melden legt keine zweite offene Meldung an
- "Melden" auf der Beitragsseite für fremde, nicht gelöschte Beiträge
- Test und API-Doku
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
266 lines
9.3 KiB
Go
266 lines
9.3 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"runtime/debug"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/httprate"
|
|
)
|
|
|
|
func main() {
|
|
// KVER_DIR: Verzeichnis mit web/ (gebautes Frontend) und static/. Per
|
|
// Chdir werden alle relativen Pfade (auch Uploads) auf einmal verankert --
|
|
// die App ist damit nicht mehr an das Start-CWD gebunden.
|
|
if dir := os.Getenv("KVER_DIR"); dir != "" {
|
|
if err := os.Chdir(dir); err != nil {
|
|
log.Fatalf("KVER_DIR: %v", err)
|
|
}
|
|
}
|
|
|
|
dsn := os.Getenv("KVER_DSN")
|
|
if dsn == "" {
|
|
log.Fatal("KVER_DSN fehlt (z. B. postgres://kver:passwort@host:5432/kontrollverlust)")
|
|
}
|
|
if err := initDB(dsn); err != nil {
|
|
log.Fatalf("db init: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Optionale GeoLite2-ASN-Datenbank für die netzwerktopologische Einordnung
|
|
// der Impressions. Inaktiv, wenn KVER_GEOIP_ASN nicht gesetzt ist.
|
|
initGeoASN()
|
|
defer closeGeoASN()
|
|
|
|
// Impression-Writer erst nach DB und GeoASN starten und VOR deren Close
|
|
// stoppen (LIFO der defers) -- sonst könnte er nach dem Shutdown noch auf
|
|
// die geschlossene DB oder den unmap-ten GeoIP-Reader zugreifen.
|
|
startImpressionWorker()
|
|
defer stopImpressionWorker()
|
|
|
|
srv := &http.Server{
|
|
Addr: envOr("KVER_ADDR", ":8080"),
|
|
Handler: routes(),
|
|
// Timeouts gegen festhängende/langsame Verbindungen (Slowloris). Read
|
|
// und Write großzügig genug für 16-MB-Uploads über langsame Leitungen.
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ReadTimeout: 2 * time.Minute,
|
|
WriteTimeout: 2 * time.Minute,
|
|
IdleTimeout: 2 * time.Minute,
|
|
}
|
|
|
|
// Graceful Shutdown: auf SIGINT/SIGTERM laufende Requests zu Ende bedienen,
|
|
// dann über die defers Worker und DB sauber schließen.
|
|
idle := make(chan struct{})
|
|
go func() {
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
|
<-sig
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(ctx); err != nil {
|
|
log.Printf("shutdown: %v", err)
|
|
}
|
|
close(idle)
|
|
}()
|
|
|
|
log.Printf("kver listening on %s", srv.Addr)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("listen: %v", err)
|
|
}
|
|
<-idle
|
|
}
|
|
|
|
// envOr liest eine Umgebungsvariable mit Fallback auf einen Default.
|
|
func envOr(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// 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 das Frontend (frontend.go) und
|
|
// statische Dateien.
|
|
func routes() http.Handler {
|
|
r := chi.NewRouter()
|
|
// RealIP übernimmt X-Forwarded-For/X-Real-IP vom Reverse-Proxy, damit
|
|
// Logging und das IP-Rate-Limit unten den Client statt den Proxy sehen.
|
|
// Voraussetzung: die App ist nur über den Proxy erreichbar (Deployment
|
|
// bindet an 127.0.0.1, siehe notes/deploy.md) — sonst wären die Header
|
|
// spoofbar.
|
|
r.Use(middleware.RealIP)
|
|
r.Use(middleware.Logger)
|
|
r.Use(recoverJSON)
|
|
r.Use(secHeaders)
|
|
r.Use(checkOrigin)
|
|
|
|
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.Post("/entry/{pid}/report", api(handleReport))
|
|
r.Get("/user/info", api(handleUserInfo))
|
|
r.Post("/user/rename", api(handleUserRename))
|
|
r.Post("/user/avatar", api(handleSetAvatar))
|
|
r.Post("/user/delete", api(handleUserDelete))
|
|
})
|
|
})
|
|
|
|
// --- Frontend (React) ---
|
|
// Die Seitenrouten entsprechen denen des Client-Routers (frontend/src/
|
|
// main.tsx) und liefern alle dieselbe index.html. Impression-Tracking
|
|
// (anonymisiert, siehe stats.go) nur auf echten Seitenaufrufen, nicht auf
|
|
// API/Assets; die Statistik-Seite zählt wie bisher nicht mit.
|
|
r.With(trackImpression).Get("/", serveApp)
|
|
r.With(trackImpression).Get("/e/{pid}", serveApp)
|
|
r.With(trackImpression).Get("/u/{username}", serveApp)
|
|
r.Get("/stats", serveApp)
|
|
// Alte Adresse der Statistik-Seite (Lesezeichen, Links von außen).
|
|
r.Get("/stats.html", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/stats", http.StatusMovedPermanently)
|
|
})
|
|
r.Handle("/assets/*", serveAssets())
|
|
r.Get("/static/docs/{page}", serveDoc)
|
|
|
|
// Hochgeladene Medien + statische Dateien (Schriften, Bilder).
|
|
// nosniff (global via secHeaders) verhindert, dass der Browser eine
|
|
// durchgereichte (nicht re-kodierte) Datei als HTML/JS interpretiert ->
|
|
// schließt Polyglot/Stored-XSS über Bild-Uploads.
|
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
|
|
|
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).
|
|
// - Frame-Verbote gegen Clickjacking.
|
|
// - CSP als Defense-in-depth gegen XSS: das Frontend hat kein Inline-JS/CSS,
|
|
// daher reicht 'self' ohne unsafe-inline.
|
|
func secHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("X-Frame-Options", "DENY")
|
|
h.Set("Referrer-Policy", "same-origin")
|
|
h.Set("Content-Security-Policy",
|
|
"default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// checkOrigin weist zustandsändernde Cross-Site-Requests ab (CSRF-Schutz,
|
|
// zusätzlich zu SameSite=Lax für ältere Browser bzw. GET-Navigationen).
|
|
// Browser setzen bei Cross-Site-Requests immer einen Origin-Header; fehlt er
|
|
// (Nicht-Browser-Clients wie curl, gleiche Site bei manchen Browsern), lassen
|
|
// wir durch -- der Cookie-Schutz greift dort ohnehin nicht bzw. schon.
|
|
var checkOrigin = guard(func(_ http.ResponseWriter, r *http.Request) error {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
if o := r.Header.Get("Origin"); o != "" {
|
|
u, err := url.Parse(o)
|
|
if err != nil || u.Host != r.Host {
|
|
return Forbidden("csrf.bad_origin", "Anfrage von fremder Seite abgelehnt")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// truncateRunes kürzt s auf höchstens n Runen. Ein Byte-Slice s[:n] könnte
|
|
// mitten in eine Multibyte-Rune schneiden und kaputtes UTF-8 erzeugen (gleiches
|
|
// Problem wie beim Username-Kürzen, siehe sanitizeUsername).
|
|
func truncateRunes(s string, n int) string {
|
|
if r := []rune(s); len(r) > n {
|
|
return string(r[:n])
|
|
}
|
|
return s
|
|
}
|