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(` {{.Status}} · {{.Title}}

{{.Status}} · {{.Title}}

{{.Message}}

Fehlercode: {{.Code}}

Zum Feed

`)) // 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) }) } }