Umbau-Projekt (SSR und Rest)

This commit is contained in:
2026-09-03 22:20:55 +02:00
parent 13363cb568
commit c66df283d1
37 changed files with 3719 additions and 344 deletions
+146
View File
@@ -0,0 +1,146 @@
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. 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 pageMiddleware(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
}