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 }