package main import ( "crypto/sha256" "crypto/subtle" "html/template" "log" "net/http" "time" ) // modBasicAuth schützt die Moderationsseite per HTTP Basic Auth direkt in der // App. Nötig, weil TOR-Zugriffe direkt an die Go-App gehen und NICHT über Caddy // laufen -- der proxy-seitige Schutz greift dort also nicht. Credentials kommen // aus KVER_MOD_USER/KVER_MOD_PASS und werden einmalig beim Routing-Aufbau // gelesen. Fail-closed: sind sie nicht gesetzt, bleibt die Seite gesperrt (503), // damit sie nie versehentlich offen ist. Fehler gehen wie überall als JSON // hinaus; der Browser zeigt den Login-Dialog trotzdem (WWW-Authenticate). Vergleich über SHA-256 + ConstantTime, // damit weder Inhalt noch Länge der Credentials über Timing durchsickern. func modBasicAuth(next http.Handler) http.Handler { wantUser := envOr("KVER_MOD_USER", "") wantPass := envOr("KVER_MOD_PASS", "") // Einmalig beim Routing-Aufbau warnen statt bei jedem Request: der Hinweis // richtet sich an den Betreiber, nicht an den Besucher. Die Namen der // Umgebungsvariablen bleiben deshalb auch aus der Fehlerseite heraus -- // die Seite ist öffentlich erreichbar. if wantUser == "" || wantPass == "" { log.Printf("moderation gesperrt: KVER_MOD_USER/KVER_MOD_PASS nicht gesetzt") } return guard(func(w http.ResponseWriter, r *http.Request) error { if wantUser == "" || wantPass == "" { return Unavailable("mod.not_configured", "Die Moderationsseite ist nicht konfiguriert.") } u, p, ok := r.BasicAuth() uSum, wuSum := sha256.Sum256([]byte(u)), sha256.Sum256([]byte(wantUser)) pSum, wpSum := sha256.Sum256([]byte(p)), sha256.Sum256([]byte(wantPass)) userOK := subtle.ConstantTimeCompare(uSum[:], wuSum[:]) == 1 passOK := subtle.ConstantTimeCompare(pSum[:], wpSum[:]) == 1 if !ok || !userOK || !passOK { // Der Header muss vor dem Rendern stehen, sonst fragt der Browser // nicht nach Zugangsdaten -- deshalb bekommt der Prüfschritt hier // den ResponseWriter. w.Header().Set("WWW-Authenticate", `Basic realm="Moderation", charset="UTF-8"`) return Unauth("mod.auth_required", "Zugang erforderlich.") } return nil })(next) } // Moderationsseite: spiegelt die report-Tabelle als serverseitig gerenderte, // JS-freie HTML-Tabelle (offene Meldungen zuerst). Bewusst OHNE App-seitige // Auth -- der Zugriff wird ausschließlich vom vorgelagerten Reverse-Proxy // (Caddy: erzwingt TLS + Basic-Auth) geschützt. Die Route darf daher niemals // direkt, am Proxy vorbei, erreichbar sein. // // html/template escaped reason/content automatisch -> der attacker-kontrollierte // Meldegrund kann kein Markup einschleusen. Kein Inline-CSS/JS wegen der CSP; // das gemeinsame Stylesheet wird als externe (same-origin) Datei verlinkt. type reportRow struct { PID int64 Reason string When string Handled bool Username string Content string } var moderationTmpl = template.Must(template.New("moderation").Parse(` Moderation · Meldungen

Meldungen

{{len .}} Meldung(en), offene zuerst.

{{range .}} {{else}} {{end}}
StatusZeitBeitragAutorInhalt (Auszug)Grund
{{if .Handled}}erledigt{{else}}offen{{end}} {{.When}} {{.PID}} {{if .Username}}{{.Username}}{{else}}[gelöscht/unbekannt]{{end}} {{.Content}} {{if .Reason}}{{.Reason}}{{else}}–{{end}}
Keine Meldungen.
`)) func handleModeration(w http.ResponseWriter, r *http.Request) error { // Offene Meldungen zuerst (handled ASC), innerhalb dessen die jüngsten oben. rows, err := db.Query( `SELECT r.pid, r.reason, r.created_at, r.handled, COALESCE(u.username, ''), COALESCE(e.content, '') FROM report r LEFT JOIN entry e ON e.pid = r.pid LEFT JOIN user u ON u.uid = e.uid ORDER BY r.handled ASC, r.created_at DESC`) if err != nil { return Internal(err) } defer rows.Close() reports := []reportRow{} for rows.Next() { var rr reportRow var created int64 var handled int if err := rows.Scan(&rr.PID, &rr.Reason, &created, &handled, &rr.Username, &rr.Content); err != nil { return Internal(err) } rr.Handled = handled != 0 rr.When = time.Unix(created, 0).UTC().Format("2006-01-02 15:04 UTC") // Inhalt nur als kurzer Auszug -- die Volansicht hängt am Beitrags-Link. // Runenweise kürzen, damit der Schnitt kein Multibyte-Zeichen zerreißt. if excerpt := truncateRunes(rr.Content, 120); excerpt != rr.Content { rr.Content = excerpt + "…" } reports = append(reports, rr) } // rows.Err() nach der Schleife: ein Fehler mitten im Iterieren würde sonst // als "einfach zu Ende" durchgehen und eine unvollständige Liste als // vollständige Moderationsansicht ausliefern. if err := rows.Err(); err != nil { return Internal(err) } w.Header().Set("Content-Type", "text/html; charset=utf-8") moderationTmpl.Execute(w, reports) return nil }