274 lines
9.0 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|