Files
Kontrollverlust/moderation.go
T
irrlichtandClaude Opus 5.5 c6ab082f8c SSR-Renderer entfernt, Fokus auf SPA + JSON-API
Die Kernlogik bleibt von der Schnittstelle getrennt; der letzte SSR-Stand
liegt im Tag ssr-archiv. Die Moderationsseite bleibt, liefert Fehler aber
als JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-26 21:28:34 +02:00

148 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(`<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Moderation · Meldungen</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<main>
<h1>Meldungen</h1>
<p class="muted">{{len .}} Meldung(en), offene zuerst.</p>
<table border="1" cellpadding="6" cellspacing="0">
<thead>
<tr><th>Status</th><th>Zeit</th><th>Beitrag</th><th>Autor</th><th>Inhalt (Auszug)</th><th>Grund</th></tr>
</thead>
<tbody>
{{range .}}
<tr>
<td>{{if .Handled}}erledigt{{else}}<strong>offen</strong>{{end}}</td>
<td>{{.When}}</td>
<td><a href="/e/{{.PID}}">{{.PID}}</a></td>
<td>{{if .Username}}{{.Username}}{{else}}[gelöscht/unbekannt]{{end}}</td>
<td>{{.Content}}</td>
<td>{{if .Reason}}{{.Reason}}{{else}}<span class="muted">–</span>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="6" class="muted">Keine Meldungen.</td></tr>
{{end}}
</tbody>
</table>
</main>
</body>
</html>`))
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
}