Files
Kontrollverlust/main.go
T
2026-09-03 22:20:55 +02:00

244 lines
9.2 KiB
Go

package main
import (
"context"
"encoding/json"
"log"
"net/http"
"net/url"
"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() {
// KVER_DIR: Verzeichnis mit web/ und static/. Per Chdir werden alle
// relativen Pfade (auch Uploads und ein relativer KVER_DB-Pfad) 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)
}
}
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()
// 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 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)
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))
// handleLogin bedient JSON- und Formular-Clients (siehe wantsJSON) und
// schreibt HTML-Antworten selbst -- api() greift nur noch für den
// JSON-Zweig.
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", handleLogout)
// Serverseitig gerenderte Auth-Seiten (noscript-Weg). Kein Rate-Limit: das
// bloße Anzeigen der Formulare ist kein Brute-Force-Vektor, die POSTs oben
// sind gedrosselt.
r.Get("/auth/login", page(showLogin))
r.Get("/auth/register", page(showRegister))
// Serverseitig gerenderte Feeds. Eigene Pfade, solange "/" und "/u/{name}"
// noch die JS-Varianten ausliefern -- so bleibt die Seite während des
// Umbaus durchgehend benutzbar. Sobald das Frontend auf gerendertem Markup
// aufsetzt, übernehmen diese Handler die ursprünglichen Pfade.
r.With(trackImpression).Get("/feed", page(showFeed))
r.With(trackImpression).Get("/u/{username}/beitraege", page(showUserFeed))
r.With(trackImpression).Get("/beitrag/{pid}", page(showThread))
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", page(handleModeration))
// 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))
// Bestätigungsseite nach einer Meldung ohne JavaScript. Reines Anzeigen,
// deshalb ohne Rate-Limit -- gemeldet wird im POST darüber.
r.Get("/entry/{pid}/gemeldet", page(showReported))
// --- 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))
// Bearbeitungsformular (noscript-Weg). In der requireAuth-Gruppe, weil
// nur der Autor den bisherigen Text zu sehen bekommen soll.
r.Get("/beitrag/{pid}/bearbeiten", page(showEditEntry))
// Löschbestätigung: das eigentliche Löschen bleibt ein POST, damit ein
// gefolgter Link (Linkvorschau, Crawler) nichts vernichtet.
r.Get("/beitrag/{pid}/loeschen", page(showDeleteEntry))
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
// 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)
})
}
// 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
}