147 lines
5.4 KiB
Go
147 lines
5.4 KiB
Go
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
|
||
}
|