Files
Kontrollverlust/htmlpage.go
T
2026-09-03 22:20:55 +02:00

274 lines
9.0 KiB
Go

package main
import (
"embed"
"html/template"
"net/http"
"strings"
)
// Templates werden einkompiliert statt zur Laufzeit gelesen. Zwei Gründe: das
// Deployment braucht kein zusätzliches Verzeichnis, und -- wichtiger -- sie
// dürfen NICHT unter web/ liegen, weil das komplett per FileServer ausgeliefert
// wird (siehe routes()). Eine Vorlage dort wäre über HTTP abrufbar.
//
//go:embed templates/*.html
var tmplFS embed.FS
// pages hält je Seite ein eigenes Template-Set aus base.html + der Seitendatei.
// Getrennte Sets, weil jede Seite ihren eigenen "content"-Block definiert --
// in einem gemeinsamen Set würde der letzte alle anderen überschreiben.
var pages = map[string]*template.Template{}
// tmplFuncs.fieldErr vereinheitlicht die beiden Fehlerformen für die Vorlage:
// ein Fehler mit einzelnem Field (Login) und einer mit Fields-Map
// (Registrierung, alle Felder auf einmal) werden gleich abgefragt.
var tmplFuncs = template.FuncMap{
"fieldErr": func(e *Error, field string) string {
if e == nil {
return ""
}
if msg, ok := e.Fields[field]; ok {
return msg
}
if e.Field == field {
return e.Msg
}
return ""
},
"content": renderContent,
"avatar": avatarURL,
"profile": profileURL,
"time": fmtTime,
"timeAttr": fmtTimeAttr,
}
// pageFiles listet je Seite die zusätzlich benötigten Vorlagen. base.html
// kommt überall dazu; Seiten mit Beitragslisten brauchen zusätzlich die
// gemeinsamen entry-/pager-Blöcke.
var pageFiles = map[string][]string{
"login": nil,
"register": nil,
"feed": {"templates/entries.html"},
"userfeed": {"templates/entries.html"},
"reported": nil,
"thread": {"templates/entries.html"},
"edit": nil,
"delete": nil,
}
func init() {
for name, extra := range pageFiles {
files := append([]string{"templates/base.html", "templates/" + name + ".html"}, extra...)
pages[name] = template.Must(
template.New(name).Funcs(tmplFuncs).ParseFS(tmplFS, files...))
}
}
// pageData ist der Rahmen jeder Seite. Form trägt die zuletzt eingegebenen
// Werte, damit ein Fehler den Nutzer nicht sein Formular neu tippen lässt.
// ReturnTo ist der Ort, an den Aktions-Formulare zurückführen sollen.
type pageData struct {
Title string
Err *Error
Form any
ReturnTo string
}
// render gibt eine Seite mit Status 200 aus.
func render(w http.ResponseWriter, r *http.Request, name string, data pageData) {
data.ReturnTo = returnTo(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
pages[name].ExecuteTemplate(w, "base", data)
}
// returnTo bestimmt den Rücksprungort für die Formulare einer Seite.
//
// Bei GET ist das die Seite selbst -- inklusive Query, damit Filter (Nutzer-
// Feed, Suche, Seitenzahl) automatisch mitreisen, ohne dass die Formulare sie
// einzeln kennen müssen. Bei POST wird das mitgeschickte return_to
// weitergereicht, damit ein neu gerendertes Formular sein Ziel behält.
func returnTo(r *http.Request) string {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
return safeReturn(r.URL.RequestURI(), "/")
}
return safeReturn(r.FormValue("return_to"), "/")
}
// safeReturn akzeptiert nur seiteneigene Pfade. Ungeprüft in http.Redirect
// gesteckt wäre return_to ein Open-Redirect: ein Link auf die eigene Domain,
// der auf einer fremden landet -- die klassische Phishing-Tarnung.
//
// Abgelehnt wird deshalb alles, was nicht eindeutig ein absoluter Pfad auf
// dieser Seite ist:
//
// - "//host/x" protokoll-relativ, zeigt auf eine fremde Domain
// - `/\host/x` manche Browser normalisieren \ zu / -> wie oben
// - "https://x" absolute URL
// - "u/alice" relativ; würde je nach Basis irgendwo landen
// - "\r\n..." Steuerzeichen könnten weitere Header einschleusen
func safeReturn(v, fallback string) string {
if v == "" || len(v) > 512 {
return fallback
}
if v[0] != '/' || strings.HasPrefix(v, "//") || strings.HasPrefix(v, `/\`) {
return fallback
}
if strings.ContainsAny(v, "\r\n\x00") {
return fallback
}
return v
}
// redirectBack schickt nach einer erfolgreichen Aktion dorthin zurück, wo sie
// ausgelöst wurde -- der noscript-Ersatz für "DOM aktualisieren, Seite stehen
// lassen". 303 statt 302, damit der Browser die Zielseite per GET holt und ein
// Reload die Aktion nicht wiederholt.
func redirectBack(w http.ResponseWriter, r *http.Request, fallback string) {
http.Redirect(w, r, safeReturn(r.FormValue("return_to"), fallback), http.StatusSeeOther)
}
// failForm rendert ein Formular nach einem Fehler neu -- mit Meldung und den
// bisherigen Eingaben. Das ist der noscript-Ersatz für die Fehlermeldung, die
// das JS-Frontend sonst per fetch nachlädt.
//
// Serverfehler landen bewusst NICHT im Formular: bei einem 500 ist unklar, ob
// die Eingabe überhaupt das Problem war, und ein "bitte nochmal absenden" wäre
// die falsche Aufforderung.
func failForm(w http.ResponseWriter, r *http.Request, name string, data pageData, err error) {
e := asError(err)
if e.Kind == KindInternal {
renderError(w, e)
return
}
data.Err = e
data.ReturnTo = returnTo(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(statusOf(e.Kind))
pages[name].ExecuteTemplate(w, "base", data)
}
// failRequest gibt einen Fehler in dem Format aus, das der Client erwartet.
// Für Endpunkte, die beide Seiten bedienen: ein Browser-Formular darf niemals
// rohes JSON zu sehen bekommen -- ohne JavaScript gibt es nichts, was es
// übersetzen könnte.
func failRequest(w http.ResponseWriter, r *http.Request, err error) {
if wantsJSON(r) {
writeAPIError(w, err)
return
}
renderError(w, err)
}
// wantsJSON entscheidet, welcher Adapter antwortet. Das JS-Frontend schickt
// Accept: application/json (siehe api in feed.js), ein Browser-Formular
// schickt text/html -- derselbe Endpunkt bedient damit beide, ohne dass die
// Logik doppelt existiert.
func wantsJSON(r *http.Request) bool {
return strings.Contains(r.Header.Get("Accept"), "application/json")
}
// HTML-Adapter: das Gegenstück zu writeAPIError. Derselbe Fehler aus derselben
// Taxonomie, nur als Seite statt als JSON -- welchen Statuscode er bekommt,
// entscheidet weiterhin statusOf, damit die beiden Adapter nicht auseinander
// laufen können.
//
// Die Seite kommt bewusst ohne JavaScript aus: sie ist der Weg, auf dem ein
// Nutzer mit abgeschaltetem JS (TOR Browser auf "Safest", I2P) überhaupt
// erfährt, was schiefgegangen ist.
type errorPageData struct {
Status int
Title string
Message string
Code string
}
var errorTmpl = template.Must(template.New("error").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>{{.Status}} · {{.Title}}</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<main>
<h1>{{.Status}} · {{.Title}}</h1>
<p>{{.Message}}</p>
<p class="muted">Fehlercode: {{.Code}}</p>
<p><a href="/feed">Zum Feed</a></p>
</main>
</body>
</html>`))
// titleForKind liefert die Überschrift der Fehlerseite. Bewusst getrennt von
// der Msg des Fehlers: die Überschrift ordnet grob ein ("Nicht gefunden"), die
// Meldung sagt konkret, was los ist ("Beitrag nicht gefunden").
func titleForKind(k Kind) string {
switch k {
case KindInvalid:
return "Ungültige Anfrage"
case KindUnauth:
return "Anmeldung erforderlich"
case KindForbidden:
return "Nicht erlaubt"
case KindNotFound:
return "Nicht gefunden"
case KindConflict:
return "Konflikt"
case KindTooLarge:
return "Zu groß"
case KindRateLimited:
return "Zu viele Anfragen"
case KindUnavailable:
return "Nicht verfügbar"
default:
return "Serverfehler"
}
}
// renderError ist der einzige Ausgang für Fehler im HTML-Adapter.
func renderError(w http.ResponseWriter, err error) {
e := asError(err)
logInternal(e)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(statusOf(e.Kind))
// Execute-Fehler werden verschluckt: der Status steht schon in der
// Leitung, eine zweite Antwort ginge nicht mehr.
errorTmpl.Execute(w, errorPageData{
Status: statusOf(e.Kind),
Title: titleForKind(e.Kind),
Message: e.Msg,
Code: e.Code,
})
}
// page hängt einen fehlerliefernden Handler als HTML-Seite ein -- das
// Gegenstück zu api(). Derselbe Handler kann an beiden Wrappern hängen; das
// Ausgabeformat entscheidet allein die Route.
func page(h handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
renderError(w, err)
}
}
}
// pageMiddleware ist das HTML-Gegenstück zu apiMiddleware. Der Prüfschritt darf
// über den ResponseWriter, weil manche Prüfungen vor der Antwort noch Header
// setzen müssen (Basic-Auth braucht WWW-Authenticate).
func pageMiddleware(check func(http.ResponseWriter, *http.Request) error) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := check(w, r); err != nil {
renderError(w, err)
return
}
next.ServeHTTP(w, r)
})
}
}