662285c8b5
Seitenaufrufe werden serverseitig pro (Tag, anonymisiertes Netz, Pfad) gezaehlt -- nur auf den HTML-Seiten-Routen, nicht auf API/Assets. Die Client-IP wird sofort maskiert (IPv4 /16, IPv6 /32); die vollstaendige IP wird nie gespeichert oder weiterverwendet. Optional loest eine lokale GeoLite2-ASN-DB (Env KVER_GEOIP_ASN) den Netzbetreiber aus dem bereits anonymisierten Netz auf -- auch dafuer wird nicht die volle IP herangezogen. Fehlt die DB, bleibt das Feld leer. Neuer Endpunkt GET /stats/detail liefert die Aggregate; die Tabelle impression speichert nur Zaehlwerte (kein Eventlog). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
165 lines
5.3 KiB
Go
165 lines
5.3 KiB
Go
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})
|
|
}
|