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