Umbau-Projekt (SSR und Rest)
This commit is contained in:
+146
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user