package main import ( "context" "encoding/json" "log" "net/http" "os" "os/signal" "syscall" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/httprate" ) func main() { if err := initDB(envOr("KVER_DB", "kver.db")); 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() 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 die DB sauber schließen (WAL-Checkpoint) statt hart abzubrechen. 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. 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(middleware.Recoverer) r.Use(secHeaders) // --- 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", handleLogin) r.Post("/auth/newuser", handleNewUser) }) r.Get("/auth/logout", handleLogout) r.Get("/auth/sessioninfo", handleSessionInfo) r.Get("/auth/headerbar", handleHeaderbar) r.Get("/stats", handleStats) r.Get("/stats/detail", handleStatsDetail) // 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}", handleFeed) r.Get("/entry/{pid}/votes", handleVotes) r.Get("/entry/{pid}/thread", handleThread) r.With(trackImpression).Get("/e/{pid}", serveEntryPage) // --- User (öffentlich) --- r.With(trackImpression).Get("/u/{username}", serveUserPage) r.Get("/u/{username}/info", handleUserPage) r.Get("/u/{username}/feed/{page}", handleUserFeed) // --- Geschützt (Session erforderlich) --- r.Group(func(r chi.Router) { r.Use(requireAuth) r.Post("/entry/create", handleCreateEntry) r.Post("/entry/{pid}/vote", handleVote) r.Post("/entry/{pid}/edit", handleEditEntry) r.Post("/entry/{pid}/delete", handleDeleteEntry) r.Get("/user/info", handleUserInfo) r.Post("/user/rename", handleUserRename) r.Post("/user/avatar", handleSetAvatar) r.Post("/user/delete", handleUserDelete) }) // Hochgeladene Medien + alte statische Assets. 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")))) // Neues JS-Frontend r.Handle("/*", http.FileServer(http.Dir("web"))) return 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) }) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) }