From c6ab082f8c7c070cec14db656b95f2f74ba0ae43 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sat, 26 Sep 2026 21:28:34 +0200 Subject: [PATCH] SSR-Renderer entfernt, Fokus auf SPA + JSON-API Die Kernlogik bleibt von der Schnittstelle getrennt; der letzte SSR-Stand liegt im Tag ssr-archiv. Die Moderationsseite bleibt, liefert Fehler aber als JSON. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q --- Containerfile | 4 - auth.go | 98 +--- endpoints_test.go | 1068 +-------------------------------------- entry.go | 158 +----- feedview.go | 539 -------------------- handler.go | 12 +- htmlpage.go | 273 ---------- htmlpage_test.go | 119 ----- main.go | 27 +- middleware.go | 8 +- moderation.go | 5 +- notes/todo.md | 2 +- templates/base.html | 25 - templates/delete.html | 30 -- templates/edit.html | 19 - templates/entries.html | 83 --- templates/feed.html | 37 -- templates/login.html | 16 - templates/register.html | 23 - templates/reported.html | 15 - templates/thread.html | 44 -- templates/userfeed.html | 11 - user.go | 4 +- web/css/app.css | 19 +- web/index.html | 5 +- web/js/feed.js | 9 +- 26 files changed, 50 insertions(+), 2603 deletions(-) delete mode 100644 feedview.go delete mode 100644 htmlpage.go delete mode 100644 htmlpage_test.go delete mode 100644 templates/base.html delete mode 100644 templates/delete.html delete mode 100644 templates/edit.html delete mode 100644 templates/entries.html delete mode 100644 templates/feed.html delete mode 100644 templates/login.html delete mode 100644 templates/register.html delete mode 100644 templates/reported.html delete mode 100644 templates/thread.html delete mode 100644 templates/userfeed.html diff --git a/Containerfile b/Containerfile index 1168fb2..77411ab 100644 --- a/Containerfile +++ b/Containerfile @@ -4,10 +4,6 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY *.go ./ -# templates/ wird per go:embed ins Binary kompiliert (siehe htmlpage.go) und -# muss deshalb schon in der Build-Stage vorliegen -- in der Laufzeit-Stage -# braucht es das Verzeichnis dann nicht mehr. -COPY templates/ templates/ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /kver . # Laufzeit-Stage: minimales Alpine, eigener unprivilegierter Nutzer. diff --git a/auth.go b/auth.go index 1a3c6e2..f27d0c6 100644 --- a/auth.go +++ b/auth.go @@ -58,52 +58,15 @@ func getSession(r *http.Request) (*Session, bool) { // absichtlich derselbe Fehler -- sonst wird der Endpunkt zum Nutzernamen-Orakel. var errBadCredentials = Unauth("auth.bad_credentials", "Nutzername oder Passwort ist falsch.") -// loginForm trägt die Eingaben über einen fehlgeschlagenen Versuch hinweg. -// Bewusst ohne Passwort: das wird nie zurück ins Formular geschrieben. -type loginForm struct { - User string -} - -// showLogin ist die serverseitig gerenderte Login-Seite -- der Weg für Clients -// ohne JavaScript (TOR Browser auf "Safest", I2P). -func showLogin(w http.ResponseWriter, r *http.Request) error { - if _, ok := getSession(r); ok { - http.Redirect(w, r, "/", http.StatusSeeOther) - return nil - } - render(w, r, "login", pageData{Title: "Login"}) - return nil -} - -// handleLogin bedient beide Adapter: das JS-Frontend (Accept: application/json) -// bekommt JSON, ein abgeschicktes Browser-Formular bekommt einen Redirect bzw. -// das neu gerenderte Formular. Die Logik dazwischen existiert nur einmal. func handleLogin(w http.ResponseWriter, r *http.Request) error { - json := wantsJSON(r) - - // fail wählt die zum Client passende Fehlerdarstellung. Der Rückgabewert - // geht an den Wrapper; bei HTML ist die Antwort schon geschrieben, deshalb - // nil. - fail := func(username string, err error) error { - if json { - return err - } - failForm(w, r, "login", pageData{Title: "Login", Form: loginForm{User: username}}, err) - return nil - } - if _, ok := getSession(r); ok { - if !json { - http.Redirect(w, r, "/", http.StatusSeeOther) - return nil - } writeJSON(w, http.StatusOK, map[string]string{"status": "already_logged_in"}) return nil } username := sanitizeUsername(r.FormValue("user")) if len(username) < 3 { - return fail(username, Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user")) + return Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user") } // Gleiche Meldung für "Nutzer existiert nicht" und "Passwort falsch": sonst @@ -112,33 +75,24 @@ func handleLogin(w http.ResponseWriter, r *http.Request) error { var hash []byte err := db.QueryRow(`SELECT uid, password FROM user WHERE username = ?`, username).Scan(&uid, &hash) if err == sql.ErrNoRows { - return fail(username, errBadCredentials) + return errBadCredentials } else if err != nil { return Internal(err) } if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass"))) != nil { - return fail(username, errBadCredentials) + return errBadCredentials } if err := startSession(w, r, uid); err != nil { return err } - // POST -> Redirect (303) statt einer gerenderten Seite: sonst zeigt ein - // Reload den "Formular erneut senden?"-Dialog und legt eine zweite Session - // an (Post/Redirect/Get). - if !json { - http.Redirect(w, r, "/", http.StatusSeeOther) - return nil - } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) return nil } -// startSession legt eine Session an und setzt den Cookie. Von Login und -// Registrierung gemeinsam genutzt -- die Registrierung ohne JavaScript muss -// direkt einloggen, weil es sonst zwei Formulare hintereinander bräuchte. +// startSession legt eine Session an und setzt den Cookie. func startSession(w http.ResponseWriter, r *http.Request, uid int64) error { now := time.Now().Unix() // timeout ist clientgesteuert -> serverseitig deckeln, damit niemand eine @@ -200,7 +154,6 @@ func handleLogout(w http.ResponseWriter, r *http.Request) { // abzubrechen -- der Nutzer soll nicht Feld für Feld durchprobieren müssen. // Deshalb hier Fields (Feld -> Meldung) statt eines einzelnen Field. func handleNewUser(w http.ResponseWriter, r *http.Request) error { - json := wantsJSON(r) fields := map[string]string{} username := sanitizeUsername(r.FormValue("user")) @@ -224,62 +177,25 @@ func handleNewUser(w http.ResponseWriter, r *http.Request) error { fields["pass2"] = "Passwort und Passwortbestätigung sind ungleich." } - // fail wählt die zum Client passende Fehlerdarstellung -- wie bei - // handleLogin: JSON-Clients bekommen den Fehler über den Wrapper, ein - // Browser-Formular wird mit Meldungen neu gerendert. - fail := func(err error) error { - if json { - return err - } - failForm(w, r, "register", pageData{Title: "Registrieren", Form: registerForm{User: username}}, err) - return nil - } - if len(fields) > 0 { e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.") e.Fields = fields - return fail(e) + return e } - uid, err := createUser(username, pass1) + _, err := createUser(username, pass1) if errors.Is(err, errUsernameTaken) { // Rennen zwischen Vorab-Prüfung und INSERT: der UNIQUE-Constraint hat // gegriffen -> als normale Validierungsmeldung statt 500 melden. - return fail(errUsernameTaken) + return errUsernameTaken } else if err != nil { return Internal(err) } - // Ohne JavaScript direkt einloggen und weiterleiten: das JS-Frontend macht - // genau das (zweiter Aufruf auf /auth/login), und ein zweites Formular nur - // zum Anmelden wäre eine unnötige Hürde. - if !json { - if err := startSession(w, r, uid); err != nil { - return err - } - http.Redirect(w, r, "/", http.StatusSeeOther) - return nil - } writeJSON(w, http.StatusCreated, map[string]string{"username": username}) return nil } -// registerForm trägt die Eingaben über einen fehlgeschlagenen Versuch hinweg -- -// wieder ohne die Passwörter. -type registerForm struct { - User string -} - -// showRegister ist die serverseitig gerenderte Registrierungsseite. -func showRegister(w http.ResponseWriter, r *http.Request) error { - if _, ok := getSession(r); ok { - http.Redirect(w, r, "/", http.StatusSeeOther) - return nil - } - render(w, r, "register", pageData{Title: "Registrieren"}) - return nil -} - func handleSessionInfo(w http.ResponseWriter, r *http.Request) error { s, ok := getSession(r) if !ok { diff --git a/endpoints_test.go b/endpoints_test.go index 877bcf0..a613b52 100644 --- a/endpoints_test.go +++ b/endpoints_test.go @@ -1,18 +1,12 @@ package main import ( - "bytes" "encoding/json" "fmt" - "image" - "image/png" - "io" - "mime/multipart" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" - "os" "path/filepath" "strings" "testing" @@ -45,11 +39,7 @@ func newClient(t *testing.T) *http.Client { return &http.Client{Jar: jar} } -// postForm spricht die Endpunkte als API-Client an (Accept: application/json, -// wie das JS-Frontend). Ohne den Header liefern Endpunkte mit HTML-Zweig -- -// derzeit /auth/login und /auth/newuser -- einen Redirect, dem der Client -// folgt; der Test würde dann den Status der Zielseite prüfen statt den des -// Endpunkts. Für den noscript-Weg gibt es postFormHTML. +// postForm spricht die Endpunkte an wie das JS-Frontend (Accept: application/json). func postForm(t *testing.T, c *http.Client, urlStr string, form url.Values) *http.Response { t.Helper() req, err := http.NewRequest(http.MethodPost, urlStr, strings.NewReader(form.Encode())) @@ -288,85 +278,6 @@ func TestBump(t *testing.T) { } } -// TestRepostOhneJavaScript deckt den Repost-Weg ohne JS ab: der Knopf muss im -// Feed stehen, der Klick per 303 an den Beitrag zurückführen, danach gesperrt -// sein -- und ein erneuter Klick im Cooldown eine HTML-Seite liefern, nicht die -// JSON-Zeile, die ein Browser roh anzeigen würde. -func TestRepostOhneJavaScript(t *testing.T) { - srv := newTestServer(t) - alice := registerAndLogin(t, srv, "alice") - pid := createEntry(t, alice, srv, "hebt euch") - - // Angemeldet: Repost-Formular mit Rücksprungziel, Knopf noch frei. - _, page := getHTML(t, alice, srv.URL+"/feed") - if !strings.Contains(page, fmt.Sprintf(`action="/entry/%d/bump"`, pid)) { - t.Fatalf("kein repost-formular im feed:\n%s", excerpt(page, "interactions")) - } - if !strings.Contains(page, "Repost (0)") { - t.Errorf("repost-zähler falsch:\n%s", excerpt(page, "bump")) - } - if strings.Contains(page, `class="bump" type="submit" disabled`) { - t.Errorf("repost-knopf ohne bisherigen repost gesperrt:\n%s", excerpt(page, "bump")) - } - - // Reposten -> 303 zurück an den Beitrag. - resp := postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), - url.Values{"return_to": {fmt.Sprintf("/feed#e%d", pid)}}) - resp.Body.Close() - if resp.StatusCode != http.StatusSeeOther { - t.Fatalf("repost: erwartet 303, bekam %d", resp.StatusCode) - } - if got, want := resp.Header.Get("Location"), fmt.Sprintf("/feed#e%d", pid); got != want { - t.Fatalf("repost: Location = %q, erwartet %q", got, want) - } - - // Jetzt im Cooldown: Zähler hoch, Knopf gesperrt, Wartezeit sichtbar. - _, page = getHTML(t, alice, srv.URL+"/feed") - if !strings.Contains(page, "Repost (1)") { - t.Errorf("repost-zähler nicht erhöht:\n%s", excerpt(page, "bump")) - } - if !strings.Contains(page, `class="bump" type="submit" disabled`) { - t.Errorf("repost-knopf im cooldown nicht gesperrt:\n%s", excerpt(page, "bump")) - } - - // Erneuter Repost im Cooldown -> HTML-Fehlerseite, nicht rohes JSON. - resp = postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{}) - cooldown := body(t, resp) - if resp.StatusCode != http.StatusTooManyRequests { - t.Fatalf("repost im cooldown: erwartet 429, bekam %d", resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { - t.Fatalf("repost im cooldown: erwartet HTML, bekam %q", ct) - } - if !strings.Contains(cooldown, "erst kürzlich repostet") { - t.Errorf("keine verständliche meldung:\n%s", cooldown) - } - - // Der JSON-Client bekommt weiterhin JSON mit Nutzdaten. - resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{}) - jsonBody := body(t, resp) - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { - t.Fatalf("API-Client: erwartet JSON, bekam %q", ct) - } - if !strings.Contains(jsonBody, `"retry_after"`) { - t.Errorf("JSON-Antwort ohne retry_after:\n%s", jsonBody) - } - - // Nicht angemeldet: kein Formular im Feed, POST -> 401 als HTML. - _, anonPage := getHTML(t, newClient(t), srv.URL+"/feed") - if strings.Contains(anonPage, fmt.Sprintf(`action="/entry/%d/bump"`, pid)) { - t.Error("repost-formular für nicht angemeldete besucher") - } - resp = postFormHTML(t, newClient(t), fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{}) - page = body(t, resp) - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("anon repost: erwartet 401, bekam %d", resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { - t.Fatalf("anon repost: erwartet HTML, bekam %q", ct) - } -} - // createReply erstellt eine Antwort auf parentPID und liefert deren pid. func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content string, parentPID int64) int64 { t.Helper() @@ -784,980 +695,3 @@ func TestBlockTOREntry(t *testing.T) { t.Fatalf("Nicht-TOR-Beitrag: erwartet 201, bekam %d", resp.StatusCode) } } - -// postFormHTML schickt ein Formular wie ein Browser ohne JavaScript: kein -// Accept: application/json, und Redirects werden nicht verfolgt, damit der -// 303 selbst prüfbar bleibt. -func postFormHTML(t *testing.T, c *http.Client, urlStr string, form url.Values) *http.Response { - t.Helper() - prev := c.CheckRedirect - c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } - defer func() { c.CheckRedirect = prev }() - - resp, err := c.PostForm(urlStr, form) - if err != nil { - t.Fatalf("POST %s: %v", urlStr, err) - } - return resp -} - -func body(t *testing.T, resp *http.Response) string { - t.Helper() - b, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - t.Fatalf("body lesen: %v", err) - } - return string(b) -} - -// TestLoginOhneJavaScript deckt den noscript-Weg ab: das Formular muss ohne JS -// abrufbar und abschickbar sein, Fehler müssen in der HTML-Antwort stehen (nicht -// als JSON, das ein Browser roh anzeigen würde), und der Erfolgsfall muss per -// Redirect enden (Post/Redirect/Get). -func TestLoginOhneJavaScript(t *testing.T) { - srv := newTestServer(t) - registerAndLogin(t, srv, "alice") - - c := newClient(t) - resp, err := c.Get(srv.URL + "/auth/login") - if err != nil { - t.Fatalf("GET /auth/login: %v", err) - } - page := body(t, resp) - if resp.StatusCode != http.StatusOK { - t.Fatalf("login-seite: erwartet 200, bekam %d", resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { - t.Fatalf("login-seite: erwartet HTML, bekam %q", ct) - } - // Ohne echtes form/action wäre die Seite ohne JS nicht bedienbar. - if !strings.Contains(page, `method="post"`) || !strings.Contains(page, `action="/auth/login"`) { - t.Fatalf("login-seite ohne abschickbares Formular:\n%s", page) - } - - // Falsches Passwort: Formular neu, mit Meldung und erhaltenem Nutzernamen. - resp = postFormHTML(t, newClient(t), srv.URL+"/auth/login", - url.Values{"user": {"alice"}, "pass": {"falsch"}}) - page = body(t, resp) - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("falsches passwort: erwartet 401, bekam %d", resp.StatusCode) - } - if !strings.Contains(page, "Nutzername oder Passwort ist falsch.") { - t.Fatalf("fehlermeldung fehlt in der HTML-Antwort:\n%s", page) - } - if !strings.Contains(page, `value="alice"`) { - t.Fatalf("nutzername nicht ins formular zurückgeschrieben:\n%s", page) - } - // Das Passwort darf nie zurück ins Formular. - if strings.Contains(page, "falsch\"") { - t.Fatalf("passwort landet im HTML:\n%s", page) - } - - // Richtiges Passwort: 303 auf die Startseite, Session-Cookie gesetzt. - anon := newClient(t) - resp = postFormHTML(t, anon, srv.URL+"/auth/login", - url.Values{"user": {"alice"}, "pass": {"supersecret1"}}) - resp.Body.Close() - if resp.StatusCode != http.StatusSeeOther { - t.Fatalf("login ok: erwartet 303, bekam %d", resp.StatusCode) - } - if loc := resp.Header.Get("Location"); loc != "/" { - t.Fatalf("login ok: erwartet Location /, bekam %q", loc) - } - var got bool - for _, ck := range resp.Cookies() { - if ck.Name == "session" && ck.Value != "" { - got = true - } - } - if !got { - t.Fatal("login ok: kein session-cookie gesetzt") - } -} - -// TestRegistrierungOhneJavaScript spiegelt TestLoginOhneJavaScript für die -// Registrierung. Zusätzlich geprüft: alle Feldfehler erscheinen auf einmal -// (nicht Feld für Feld), und der Erfolgsfall loggt direkt ein -- sonst bräuchte -// es ohne JS zwei Formulare hintereinander. -func TestRegistrierungOhneJavaScript(t *testing.T) { - srv := newTestServer(t) - - c := newClient(t) - resp, err := c.Get(srv.URL + "/auth/register") - if err != nil { - t.Fatalf("GET /auth/register: %v", err) - } - pageHTML := body(t, resp) - if resp.StatusCode != http.StatusOK { - t.Fatalf("register-seite: erwartet 200, bekam %d", resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { - t.Fatalf("register-seite: erwartet HTML, bekam %q", ct) - } - if !strings.Contains(pageHTML, `method="post"`) || !strings.Contains(pageHTML, `action="/auth/newuser"`) { - t.Fatalf("register-seite ohne abschickbares Formular:\n%s", pageHTML) - } - - // Mehrere Fehler gleichzeitig: zu kurzer Name, zu kurzes Passwort, - // abweichende Wiederholung. Alle drei müssen in der Antwort stehen. - resp = postFormHTML(t, newClient(t), srv.URL+"/auth/newuser", - url.Values{"user": {"ab"}, "pass1": {"kurz"}, "pass2": {"anders"}}) - pageHTML = body(t, resp) - if resp.StatusCode != http.StatusBadRequest { - t.Fatalf("ungültige registrierung: erwartet 400, bekam %d", resp.StatusCode) - } - for _, want := range []string{ - "Der Nutzername ist zu kurz.", - "Das Passwort ist kürzer als 10 Zeichen.", - "Passwort und Passwortbestätigung sind ungleich.", - } { - if !strings.Contains(pageHTML, want) { - t.Fatalf("feldfehler %q fehlt in der HTML-Antwort:\n%s", want, pageHTML) - } - } - if !strings.Contains(pageHTML, `value="ab"`) { - t.Fatalf("nutzername nicht ins formular zurückgeschrieben:\n%s", pageHTML) - } - for _, leak := range []string{"kurz", "anders"} { - if strings.Contains(pageHTML, `value="`+leak+`"`) { - t.Fatalf("passwort %q landet im HTML:\n%s", leak, pageHTML) - } - } - - // Erfolg: 303 auf die Startseite, Session-Cookie direkt gesetzt. - anon := newClient(t) - resp = postFormHTML(t, anon, srv.URL+"/auth/newuser", - url.Values{"user": {"carol"}, "pass1": {"supersecret1"}, "pass2": {"supersecret1"}}) - resp.Body.Close() - if resp.StatusCode != http.StatusSeeOther { - t.Fatalf("registrierung ok: erwartet 303, bekam %d", resp.StatusCode) - } - if loc := resp.Header.Get("Location"); loc != "/" { - t.Fatalf("registrierung ok: erwartet Location /, bekam %q", loc) - } - var sessionSet bool - for _, ck := range resp.Cookies() { - if ck.Name == "session" && ck.Value != "" { - sessionSet = true - } - } - if !sessionSet { - t.Fatal("registrierung ok: kein session-cookie -- ohne JS wäre ein zweites Formular nötig") - } - - // Der Cookie muss auch wirklich tragen: der Client ist jetzt eingeloggt. - resp, err = anon.Get(srv.URL + "/auth/headerbar") - if err != nil { - t.Fatal(err) - } - var h struct { - LoggedIn bool `json:"loggedin"` - } - json.NewDecoder(resp.Body).Decode(&h) - resp.Body.Close() - if !h.LoggedIn { - t.Fatal("nach registrierung ohne JS nicht eingeloggt") - } - - // Bereits vergebener Name -> Formular neu mit Meldung, kein 500. - resp = postFormHTML(t, newClient(t), srv.URL+"/auth/newuser", - url.Values{"user": {"carol"}, "pass1": {"supersecret1"}, "pass2": {"supersecret1"}}) - pageHTML = body(t, resp) - if resp.StatusCode != http.StatusBadRequest { - t.Fatalf("doppelter name: erwartet 400, bekam %d", resp.StatusCode) - } - if !strings.Contains(pageHTML, "Der Nutzername wird bereits verwendet.") { - t.Fatalf("meldung zum vergebenen namen fehlt:\n%s", pageHTML) - } -} - -// getHTML holt eine Seite als Browser ohne JavaScript. -func getHTML(t *testing.T, c *http.Client, urlStr string) (int, string) { - t.Helper() - resp, err := c.Get(urlStr) - if err != nil { - t.Fatalf("GET %s: %v", urlStr, err) - } - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { - resp.Body.Close() - t.Fatalf("GET %s: erwartet HTML, bekam %q", urlStr, ct) - } - return resp.StatusCode, body(t, resp) -} - -// TestFeedOhneJavaScript deckt den serverseitig gerenderten Feed ab: Beiträge -// müssen ohne JS sichtbar sein, geblättert werden können und Nutzerinhalt darf -// kein Markup einschleusen. -func TestFeedOhneJavaScript(t *testing.T) { - srv := newTestServer(t) - alice := registerAndLogin(t, srv, "alice") - - // Mehr als eine Seite füllen. - for i := 0; i < feedPageSize+5; i++ { - createEntry(t, alice, srv, fmt.Sprintf("beitrag %d", i)) - } - anon := newClient(t) - - status, page1 := getHTML(t, anon, srv.URL+"/feed") - if status != http.StatusOK { - t.Fatalf("/feed: erwartet 200, bekam %d", status) - } - if !strings.Contains(page1, "Seite 1 von 2") { - t.Errorf("seitenanzeige fehlt oder falsch:\n%s", excerpt(page1, "pager")) - } - // Ohne Weiter-Link ist der Rest des Feeds ohne JS unerreichbar. - if !strings.Contains(page1, `href="/feed?page=1"`) { - t.Errorf("kein Link auf die nächste Seite:\n%s", excerpt(page1, "pager")) - } - if strings.Contains(page1, "← Neuere") { - t.Error("Zurück-Link auf der ersten Seite") - } - // Jeder Beitrag braucht einen Anker, damit Aktionen später dorthin - // zurückspringen können. - if !strings.Contains(page1, `class="card" id="e`) { - t.Errorf("beiträge ohne id-anker:\n%s", excerpt(page1, "card")) - } - - status, page2 := getHTML(t, anon, srv.URL+"/feed?page=1") - if status != http.StatusOK { - t.Fatalf("/feed?page=1: erwartet 200, bekam %d", status) - } - if !strings.Contains(page2, "Seite 2 von 2") { - t.Errorf("zweite seite falsch gezählt:\n%s", excerpt(page2, "pager")) - } - if !strings.Contains(page2, `href="/feed"`) { - t.Error("kein Link zurück auf die erste Seite") - } - if strings.Contains(page2, "Ältere →") { - t.Error("Weiter-Link auf der letzten Seite") - } - - // Seite jenseits des Endes fällt auf die letzte zurück, statt leer zu sein. - if _, beyond := getHTML(t, anon, srv.URL+"/feed?page=99"); !strings.Contains(beyond, "Seite 2 von 2") { - t.Errorf("seite jenseits des endes:\n%s", excerpt(beyond, "pager")) - } - - // Nutzerfeed mit demselben Aufbau. - status, userPage := getHTML(t, anon, srv.URL+"/u/alice/beitraege") - if status != http.StatusOK { - t.Fatalf("/u/alice/beitraege: erwartet 200, bekam %d", status) - } - if !strings.Contains(userPage, "Beiträge von alice") { - t.Error("nutzerfeed ohne Überschrift") - } - if !strings.Contains(userPage, `href="/u/alice/beitraege?page=1"`) { - t.Error("nutzerfeed: Blätter-Link verliert den Pfad") - } - - if status, _ := getHTML(t, anon, srv.URL+"/u/gibtsnicht/beitraege"); status != http.StatusNotFound { - t.Errorf("unbekannter nutzer: erwartet 404, bekam %d", status) - } -} - -// TestFeedEscapesUserContent stellt sicher, dass Beitragstext kein Markup -// einschleusen kann. Der JS-Pfad hat dafür escapeHtml (feed.js); der -// SSR-Pfad braucht denselben Schutz eigenständig. -func TestFeedEscapesUserContent(t *testing.T) { - srv := newTestServer(t) - alice := registerAndLogin(t, srv, "alice") - createEntry(t, alice, srv, ` & "zitat" 'apos' https://example.com/x?a=1&b=2`) - - _, page := getHTML(t, newClient(t), srv.URL+"/feed") - - for _, bad := range []string{"", `alert("xss")`} { - if strings.Contains(page, bad) { - t.Errorf("unescaptes %q im HTML:\n%s", bad, excerpt(page, "content")) - } - } - if !strings.Contains(page, "<script>") { - t.Errorf("inhalt fehlt oder anders escaped:\n%s", excerpt(page, "content")) - } - // Links werden erzeugt -- aber nur mit http/https-Schema. - if !strings.Contains(page, ``) { - t.Errorf("link nicht oder falsch erzeugt:\n%s", excerpt(page, "content")) - } -} - -// excerpt schneidet einen Ausschnitt um das erste Vorkommen von marker heraus, -// damit Fehlermeldungen nicht die ganze Seite ausgeben. -func excerpt(s, marker string) string { - i := strings.Index(s, marker) - if i < 0 { - return s - } - start := max(0, i-120) - end := min(len(s), i+400) - return "..." + s[start:end] + "..." -} - -// TestVoteOhneJavaScript prüft den Abstimmungs-Weg ohne JS: das Formular muss -// im Feed stehen, der Klick per 303 an genau den Beitrag zurückführen, und der -// neue Stand danach in der Seite stehen. -func TestVoteOhneJavaScript(t *testing.T) { - srv := newTestServer(t) - alice := registerAndLogin(t, srv, "alice") - pid := createEntry(t, alice, srv, "stimm ab") - - // Angemeldet: Formular mit beiden Knöpfen und Rücksprungziel. - _, page := getHTML(t, alice, srv.URL+"/feed") - if !strings.Contains(page, fmt.Sprintf(`action="/entry/%d/vote"`, pid)) { - t.Fatalf("kein vote-formular im feed:\n%s", excerpt(page, "interactions")) - } - if !strings.Contains(page, fmt.Sprintf(`name="return_to" value="/feed#e%d"`, pid)) { - t.Errorf("return_to fehlt oder zeigt woanders hin:\n%s", excerpt(page, "vote")) - } - if !strings.Contains(page, `name="mode" value="left"`) || !strings.Contains(page, `name="mode" value="right"`) { - t.Errorf("vote-knöpfe unvollständig:\n%s", excerpt(page, "vote")) - } - - // Abstimmen -> 303 zurück an den Beitrag. - resp := postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/vote", srv.URL, pid), - url.Values{"mode": {"left"}, "return_to": {fmt.Sprintf("/feed#e%d", pid)}}) - resp.Body.Close() - if resp.StatusCode != http.StatusSeeOther { - t.Fatalf("vote: erwartet 303, bekam %d", resp.StatusCode) - } - if got, want := resp.Header.Get("Location"), fmt.Sprintf("/feed#e%d", pid); got != want { - t.Fatalf("vote: Location = %q, erwartet %q", got, want) - } - - // Der Stand steht jetzt in der Seite, die Stimme ist markiert. - _, page = getHTML(t, alice, srv.URL+"/feed") - if !strings.Contains(page, "1 / 0") { - t.Errorf("zähler nicht aktualisiert:\n%s", excerpt(page, "interactions")) - } - if !strings.Contains(page, `class="ghost selected"`) { - t.Errorf("eigene stimme nicht markiert:\n%s", excerpt(page, "vote")) - } - - // Ein manipuliertes Ziel darf nicht im Location landen. - resp = postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/vote", srv.URL, pid), - url.Values{"mode": {"right"}, "return_to": {"//evil.example"}}) - resp.Body.Close() - if got := resp.Header.Get("Location"); got != "/feed" { - t.Errorf("manipuliertes return_to: Location = %q, erwartet /feed", got) - } - - // Ohne Anmeldung: kein Formular, aber der Stand ist sichtbar. - _, anonPage := getHTML(t, newClient(t), srv.URL+"/feed") - if strings.Contains(anonPage, fmt.Sprintf(`action="/entry/%d/vote"`, pid)) { - t.Error("vote-formular für nicht angemeldete besucher") - } - if !strings.Contains(anonPage, "anmelden zum Abstimmen") { - t.Errorf("kein hinweis auf die anmeldung:\n%s", excerpt(anonPage, "interactions")) - } -} - -// TestReportOhneJavaScript prüft den Meldeweg ohne JS. Der Grund bleibt -// optional (nur die Möglichkeit muss bestehen), und die Bestätigung ist eine -// eigene Seite -- ein stiller Rücksprung wäre von "nichts passiert" nicht zu -// unterscheiden. -func TestReportOhneJavaScript(t *testing.T) { - srv := newTestServer(t) - alice := registerAndLogin(t, srv, "alice") - pid := createEntry(t, alice, srv, "melde mich") - - anon := newClient(t) - _, page := getHTML(t, anon, srv.URL+"/feed") - if !strings.Contains(page, fmt.Sprintf(`action="/entry/%d/report"`, pid)) { - t.Fatalf("kein melde-formular im feed:\n%s", excerpt(page, "report")) - } - //
klappt ohne JavaScript auf -- ohne das wäre das Feld unerreichbar. - if !strings.Contains(page, `
`) { - t.Errorf("melde-formular nicht aufklappbar:\n%s", excerpt(page, "report")) - } - // Die Grund-Eingabe muss angeboten werden, darf aber nicht erzwungen sein. - if !strings.Contains(page, `name="reason"`) { - t.Errorf("kein freitextfeld für den grund:\n%s", excerpt(page, "report")) - } - if strings.Contains(excerpt(page, `name="reason"`), "required") { - t.Errorf("grund ist pflicht, soll aber optional sein:\n%s", excerpt(page, "report")) - } - - // Melden mit Grund -> 303 auf die Bestätigungsseite. - back := fmt.Sprintf("/feed#e%d", pid) - resp := postFormHTML(t, anon, fmt.Sprintf("%s/entry/%d/report", srv.URL, pid), - url.Values{"reason": {"Spam & \"Werbung\""}, "return_to": {back}}) - resp.Body.Close() - if resp.StatusCode != http.StatusSeeOther { - t.Fatalf("report: erwartet 303, bekam %d", resp.StatusCode) - } - loc := resp.Header.Get("Location") - if !strings.HasPrefix(loc, fmt.Sprintf("/entry/%d/gemeldet?", pid)) { - t.Fatalf("report: Location = %q, erwartet Bestätigungsseite", loc) - } - - status, done := getHTML(t, anon, srv.URL+loc) - if status != http.StatusOK { - t.Fatalf("bestätigungsseite: erwartet 200, bekam %d", status) - } - if !strings.Contains(done, "Meldung eingegangen") { - t.Errorf("keine bestätigung:\n%s", done) - } - // Der Rückweg ist ein Link, kein automatischer Sprung. - if !strings.Contains(done, fmt.Sprintf(`href="/feed#e%d"`, pid)) { - t.Errorf("kein rückweg zum beitrag:\n%s", excerpt(done, "report-done")) - } - // Der Grund wird angezeigt und dabei escaped. - if !strings.Contains(done, "Spam & "Werbung"") { - t.Errorf("grund fehlt oder ist nicht escaped:\n%s", excerpt(done, "report-done")) - } - - // Melden ohne Grund bleibt möglich. - resp = postFormHTML(t, anon, fmt.Sprintf("%s/entry/%d/report", srv.URL, pid), - url.Values{"return_to": {back}}) - resp.Body.Close() - if resp.StatusCode != http.StatusSeeOther { - t.Fatalf("report ohne grund: erwartet 303, bekam %d", resp.StatusCode) - } - _, done = getHTML(t, anon, srv.URL+resp.Header.Get("Location")) - if !strings.Contains(done, "keinen Grund angegeben") { - t.Errorf("fehlender grund nicht benannt:\n%s", excerpt(done, "report-done")) - } -} - -// postMultipartHTML schickt ein Formular mit Datei wie ein Browser ohne -// JavaScript. Ohne enctype=multipart/form-data käme beim Server nur der -// Dateiname an -- deshalb prüft der Test den echten Weg. -func postMultipartHTML(t *testing.T, c *http.Client, urlStr string, - fields map[string]string, fileField, fileName string, fileData []byte) *http.Response { - t.Helper() - - var buf bytes.Buffer - mw := multipart.NewWriter(&buf) - for k, v := range fields { - if err := mw.WriteField(k, v); err != nil { - t.Fatal(err) - } - } - if fileField != "" { - part, err := mw.CreateFormFile(fileField, fileName) - if err != nil { - t.Fatal(err) - } - if _, err := part.Write(fileData); err != nil { - t.Fatal(err) - } - } - if err := mw.Close(); err != nil { - t.Fatal(err) - } - - req, err := http.NewRequest(http.MethodPost, urlStr, &buf) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Content-Type", mw.FormDataContentType()) - - prev := c.CheckRedirect - c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } - defer func() { c.CheckRedirect = prev }() - - resp, err := c.Do(req) - if err != nil { - t.Fatalf("POST %s: %v", urlStr, err) - } - return resp -} - -// pngBytes baut ein winziges gültiges PNG (1x1), damit storeImage etwas zu -// dekodieren hat. -func pngBytes(t *testing.T) []byte { - t.Helper() - var buf bytes.Buffer - img := image.NewRGBA(image.Rect(0, 0, 1, 1)) - if err := png.Encode(&buf, img); err != nil { - t.Fatal(err) - } - return buf.Bytes() -} - -// TestBeitragOhneJavaScript deckt das Schreiben ohne JS ab: Formular im Feed, -// Text und Bild per multipart, Rücksprung zum neuen Beitrag, und im Fehlerfall -// der Feed mit erhaltenem Text. -func TestBeitragOhneJavaScript(t *testing.T) { - srv := newTestServer(t) - alice := registerAndLogin(t, srv, "alice") - - // Angemeldet: Formular ist da und kann Dateien schicken. - _, page := getHTML(t, alice, srv.URL+"/feed") - if !strings.Contains(page, `action="/entry/create"`) { - t.Fatalf("kein beitragsformular im feed:\n%s", excerpt(page, "compose")) - } - if !strings.Contains(page, `enctype="multipart/form-data"`) { - t.Errorf("formular ohne enctype -- upload käme nie an:\n%s", excerpt(page, "compose")) - } - if !strings.Contains(page, `type="file"`) { - t.Errorf("kein dateifeld:\n%s", excerpt(page, "compose")) - } - // Das Formular trägt den Rücksprungort mit, damit ein abgelehnter Post ihn - // nicht verliert und die neu gerenderten Knöpfe auf "/feed" zeigen statt "/". - if !strings.Contains(page, `name="return_to" value="/feed"`) { - t.Errorf("beitragsformular ohne return_to:\n%s", excerpt(page, "compose")) - } - - // Nicht angemeldet: kein Formular. - _, anonPage := getHTML(t, newClient(t), srv.URL+"/feed") - if strings.Contains(anonPage, `action="/entry/create"`) { - t.Error("beitragsformular für nicht angemeldete besucher") - } - - // Beitrag mit Text und Bild -> 303 auf den neuen Beitrag. - resp := postMultipartHTML(t, alice, srv.URL+"/entry/create", - map[string]string{"content": "mein erster ssr-beitrag"}, "file", "bild.png", pngBytes(t)) - resp.Body.Close() - if resp.StatusCode != http.StatusSeeOther { - t.Fatalf("beitrag anlegen: erwartet 303, bekam %d", resp.StatusCode) - } - loc := resp.Header.Get("Location") - if !strings.HasPrefix(loc, "/feed#e") { - t.Fatalf("Location = %q, erwartet /feed#e", loc) - } - - // Der Beitrag steht im Feed, mit Bild und unter dem Anker aus dem Redirect. - _, page = getHTML(t, alice, srv.URL+"/feed") - if !strings.Contains(page, "mein erster ssr-beitrag") { - t.Errorf("neuer beitrag fehlt im feed:\n%s", excerpt(page, "content")) - } - anchor := strings.TrimPrefix(loc, "/feed#") - if !strings.Contains(page, `id="`+anchor+`"`) { - t.Errorf("anker %q aus dem redirect existiert nicht in der seite", anchor) - } - if !strings.Contains(page, ` Feed mit Meldung und erhaltenem Text, kein Redirect. - // return_to wie vom Formular mitgeschickt. - resp = postFormHTML(t, alice, srv.URL+"/entry/create", - url.Values{"content": {" "}, "return_to": {"/feed"}, "page": {"0"}}) - failPage := body(t, resp) - if resp.StatusCode != http.StatusBadRequest { - t.Fatalf("leerer beitrag: erwartet 400, bekam %d", resp.StatusCode) - } - if !strings.Contains(failPage, "Leerer Beitrag!") { - t.Errorf("fehlermeldung fehlt:\n%s", excerpt(failPage, "compose")) - } - // Der Feed muss weiterhin dastehen -- nicht nur eine nackte Fehlerseite. - if !strings.Contains(failPage, "mein erster ssr-beitrag") { - t.Errorf("feed nach fehler nicht mitgerendert:\n%s", excerpt(failPage, "feed")) - } - // Die neu gerenderten Interaktions-Knöpfe zeigen zurück in den Feed, nicht - // auf "/" (die noch per JS bediente Startseite). - if strings.Contains(failPage, `name="return_to" value="/#e`) { - t.Errorf("interaktions-knöpfe zeigen nach dem fehler auf \"/\":\n%s", excerpt(failPage, "vote")) - } - if !strings.Contains(failPage, `name="return_to" value="/feed#e`) { - t.Errorf("interaktions-knöpfe ohne feed-rücksprung:\n%s", excerpt(failPage, "vote")) - } - - // Getippter Text überlebt einen abgelehnten Upload. - resp = postMultipartHTML(t, alice, srv.URL+"/entry/create", - map[string]string{"content": "text der nicht verloren gehen darf"}, - "file", "kaputt.png", []byte("das ist kein bild")) - failPage = body(t, resp) - if resp.StatusCode != http.StatusBadRequest { - t.Fatalf("kaputtes bild: erwartet 400, bekam %d", resp.StatusCode) - } - if !strings.Contains(failPage, "Bild konnte nicht verarbeitet werden.") { - t.Errorf("meldung zum bild fehlt:\n%s", excerpt(failPage, "compose")) - } - if !strings.Contains(failPage, "text der nicht verloren gehen darf") { - t.Errorf("getippter text ging verloren:\n%s", excerpt(failPage, "compose")) - } - // Dateifelder lassen sich nicht vorbelegen -- darauf muss hingewiesen werden. - if !strings.Contains(failPage, "erneut auswählen") { - t.Errorf("kein hinweis zum erneuten auswählen des bildes:\n%s", excerpt(failPage, "compose")) - } -} - -// mediaPathFrom liest den Pfad des ersten eingebetteten Uploads aus einer -// gerenderten Seite, damit der Test seine Datei wieder entfernen kann. -func mediaPathFrom(page string) string { - const marker = ``) { - t.Error("feed über TOR nicht sichtbar") - } -} - -// TestThreadOhneJavaScript deckt die Thread-Ansicht ab: Ahnenkette, der -// Beitrag selbst, Antworten und das Antwortformular müssen ohne JS erreichbar -// sein -- ohne diese Seite ist Antworten ohne JavaScript unmöglich. -func TestThreadOhneJavaScript(t *testing.T) { - srv := newTestServer(t) - alice := registerAndLogin(t, srv, "alice") - - root := createEntry(t, alice, srv, "wurzelbeitrag") - mitte := replyTo(t, alice, srv, root, "mittlere antwort") - - anon := newClient(t) - status, page := getHTML(t, anon, fmt.Sprintf("%s/beitrag/%d", srv.URL, mitte)) - if status != http.StatusOK { - t.Fatalf("/beitrag/%d: erwartet 200, bekam %d", mitte, status) - } - - // Ahnenkette: der Wurzelbeitrag steht als Kontext über dem fokussierten. - if !strings.Contains(page, "wurzelbeitrag") { - t.Errorf("ahnenkette fehlt:\n%s", excerpt(page, "ancestors")) - } - if !strings.Contains(page, "mittlere antwort") { - t.Errorf("fokussierter beitrag fehlt:\n%s", excerpt(page, "focus")) - } - // Der Weg nach oben muss ein echter Link sein. - if !strings.Contains(page, fmt.Sprintf(`href="/beitrag/%d"`, root)) { - t.Errorf("kein link zum elternbeitrag:\n%s", excerpt(page, "ancestors")) - } - - // Antwortformular: nur angemeldet, mit reply_to auf den fokussierten Beitrag. - if strings.Contains(page, `action="/entry/create"`) { - t.Error("antwortformular für nicht angemeldete besucher") - } - _, page = getHTML(t, alice, fmt.Sprintf("%s/beitrag/%d", srv.URL, mitte)) - if !strings.Contains(page, fmt.Sprintf(`name="reply_to" value="%d"`, mitte)) { - t.Fatalf("antwortformular ohne reply_to:\n%s", excerpt(page, "compose")) - } - // Das Antwortformular trägt den Rücksprung in den Thread mit. - if !strings.Contains(page, fmt.Sprintf(`name="return_to" value="/beitrag/%d"`, mitte)) { - t.Errorf("antwortformular ohne return_to:\n%s", excerpt(page, "compose")) - } - - // Antworten ohne JS -> 303 in den Thread des Elternbeitrags. - resp := postFormHTML(t, alice, srv.URL+"/entry/create", - url.Values{"content": {"meine antwort"}, "reply_to": {fmt.Sprint(mitte)}}) - resp.Body.Close() - if resp.StatusCode != http.StatusSeeOther { - t.Fatalf("antwort: erwartet 303, bekam %d", resp.StatusCode) - } - loc := resp.Header.Get("Location") - if !strings.HasPrefix(loc, fmt.Sprintf("/beitrag/%d#e", mitte)) { - t.Fatalf("antwort: Location = %q, erwartet /beitrag/%d#e", loc, mitte) - } - - // Die Antwort steht im Thread, unter dem Anker aus dem Redirect. - _, page = getHTML(t, alice, fmt.Sprintf("%s/beitrag/%d", srv.URL, mitte)) - if !strings.Contains(page, "meine antwort") { - t.Errorf("antwort fehlt im thread:\n%s", excerpt(page, "replies")) - } - anchor := loc[strings.Index(loc, "#")+1:] - if !strings.Contains(page, `id="`+anchor+`"`) { - t.Errorf("anker %q aus dem redirect existiert nicht in der seite", anchor) - } - - // Fehlerfall: leere Antwort -> Thread neu, nicht der Feed. return_to wie - // vom Formular mitgeschickt. - resp = postFormHTML(t, alice, srv.URL+"/entry/create", - url.Values{"content": {" "}, "reply_to": {fmt.Sprint(mitte)}, - "return_to": {fmt.Sprintf("/beitrag/%d", mitte)}}) - failPage := body(t, resp) - if resp.StatusCode != http.StatusBadRequest { - t.Fatalf("leere antwort: erwartet 400, bekam %d", resp.StatusCode) - } - if !strings.Contains(failPage, "Leerer Beitrag!") { - t.Errorf("fehlermeldung fehlt:\n%s", excerpt(failPage, "compose")) - } - if !strings.Contains(failPage, "mittlere antwort") { - t.Errorf("thread nach fehler nicht mitgerendert:\n%s", excerpt(failPage, "focus")) - } - // Die neu gerenderten Interaktions-Knöpfe zeigen zurück in den Thread, - // nicht auf "/". - if strings.Contains(failPage, `name="return_to" value="/#e`) { - t.Errorf("interaktions-knöpfe zeigen nach dem fehler auf \"/\":\n%s", excerpt(failPage, "vote")) - } - if !strings.Contains(failPage, fmt.Sprintf(`name="return_to" value="/beitrag/%d#e`, mitte)) { - t.Errorf("interaktions-knöpfe ohne thread-rücksprung:\n%s", excerpt(failPage, "vote")) - } - - // Unbekannter Beitrag -> 404, keine leere Seite. - if status, _ := getHTML(t, anon, srv.URL+"/beitrag/999999"); status != http.StatusNotFound { - t.Errorf("unbekannter beitrag: erwartet 404, bekam %d", status) - } -} - -// TestThreadGeloeschterBeitrag prüft, dass ein soft-gelöschter Beitrag den -// Thread zusammenhält, aber kein Antwortformular mehr anbietet. -func TestThreadGeloeschterBeitrag(t *testing.T) { - srv := newTestServer(t) - alice := registerAndLogin(t, srv, "alice") - - root := createEntry(t, alice, srv, "wird geloescht") - replyTo(t, alice, srv, root, "haengt daran") - - resp := postForm(t, alice, fmt.Sprintf("%s/entry/%d/delete", srv.URL, root), url.Values{}) - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("löschen: status %d", resp.StatusCode) - } - - status, page := getHTML(t, alice, fmt.Sprintf("%s/beitrag/%d", srv.URL, root)) - if status != http.StatusOK { - t.Fatalf("gelöschter beitrag: erwartet 200, bekam %d", status) - } - if !strings.Contains(page, "[deleted]") { - t.Errorf("kein platzhalter für den gelöschten beitrag:\n%s", excerpt(page, "focus")) - } - if !strings.Contains(page, "haengt daran") { - t.Errorf("antworten am gelöschten beitrag nicht mehr erreichbar:\n%s", excerpt(page, "replies")) - } - if strings.Contains(page, `action="/entry/create"`) { - t.Error("antwortformular an einem gelöschten beitrag") - } -} - -// replyTo erstellt eine Antwort und liefert deren pid. -func replyTo(t *testing.T, c *http.Client, srv *httptest.Server, parent int64, content string) int64 { - t.Helper() - resp := postForm(t, c, srv.URL+"/entry/create", - url.Values{"content": {content}, "reply_to": {fmt.Sprint(parent)}}) - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - t.Fatalf("reply: status %d", resp.StatusCode) - } - var out struct { - PID int64 `json:"pid"` - } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - t.Fatalf("decode reply: %v", err) - } - return out.PID -} - -// TestBearbeitenOhneJavaScript deckt das Bearbeiten ohne JS ab. Anders als beim -// Erstellen ist der Ausgangszustand nicht leer: das Formular muss den -// bisherigen Text tragen, und der Rückweg muss dorthin führen, wo der -// Bearbeiten-Link stand -- nicht auf die Bearbeitungsseite selbst. -func TestBearbeitenOhneJavaScript(t *testing.T) { - srv := newTestServer(t) - alice := registerAndLogin(t, srv, "alice") - bob := registerAndLogin(t, srv, "bob") - pid := createEntry(t, alice, srv, "urspruenglicher text") - - // Der Bearbeiten-Link steht nur am eigenen Beitrag. - _, page := getHTML(t, alice, srv.URL+"/feed") - if !strings.Contains(page, fmt.Sprintf("/beitrag/%d/bearbeiten", pid)) { - t.Fatalf("kein bearbeiten-link am eigenen beitrag:\n%s", excerpt(page, "interactions")) - } - _, bobPage := getHTML(t, bob, srv.URL+"/feed") - if strings.Contains(bobPage, "/bearbeiten") { - t.Error("bearbeiten-link an fremdem beitrag") - } - _, anonPage := getHTML(t, newClient(t), srv.URL+"/feed") - if strings.Contains(anonPage, "/bearbeiten") { - t.Error("bearbeiten-link für nicht angemeldete besucher") - } - - // Das Formular trägt den bisherigen Text und einen Rückweg in den Feed. - editPage := fmt.Sprintf("%s/beitrag/%d/bearbeiten?return_to=%s", - srv.URL, pid, url.QueryEscape(fmt.Sprintf("/feed#e%d", pid))) - status, form := getHTML(t, alice, editPage) - if status != http.StatusOK { - t.Fatalf("bearbeitungsseite: erwartet 200, bekam %d", status) - } - if !strings.Contains(form, "urspruenglicher text") { - t.Errorf("bisheriger text fehlt im formular:\n%s", excerpt(form, "textarea")) - } - if !strings.Contains(form, fmt.Sprintf(`value="/feed#e%d"`, pid)) { - t.Errorf("rückweg nicht übernommen:\n%s", excerpt(form, "return_to")) - } - // Ohne Abbrechen-Weg wäre die Seite ohne JS eine Sackgasse. - if !strings.Contains(form, "Abbrechen") { - t.Errorf("kein abbrechen-link:\n%s", excerpt(form, "compose")) - } - - // Fremder Beitrag -> 403 als HTML-Seite, nicht als JSON. - status, denied := getHTML(t, bob, fmt.Sprintf("%s/beitrag/%d/bearbeiten", srv.URL, pid)) - if status != http.StatusForbidden { - t.Fatalf("fremde bearbeitungsseite: erwartet 403, bekam %d", status) - } - if !strings.Contains(denied, "Das ist nicht dein Beitrag.") { - t.Errorf("keine verständliche meldung:\n%s", denied) - } - - // Speichern -> 303 auf den mitgegebenen Rückweg. - resp := postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/edit", srv.URL, pid), - url.Values{"content": {"geaenderter text"}, "return_to": {fmt.Sprintf("/feed#e%d", pid)}}) - resp.Body.Close() - if resp.StatusCode != http.StatusSeeOther { - t.Fatalf("speichern: erwartet 303, bekam %d", resp.StatusCode) - } - if got, want := resp.Header.Get("Location"), fmt.Sprintf("/feed#e%d", pid); got != want { - t.Fatalf("speichern: Location = %q, erwartet %q", got, want) - } - - _, page = getHTML(t, alice, srv.URL+"/feed") - if !strings.Contains(page, "geaenderter text") || strings.Contains(page, "urspruenglicher text") { - t.Errorf("änderung nicht übernommen:\n%s", excerpt(page, "content")) - } - - // Leerer Text -> Formular neu, mit der abgelehnten Fassung statt des - // gespeicherten Stands. - resp = postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/edit", srv.URL, pid), - url.Values{"content": {" "}, "return_to": {fmt.Sprintf("/feed#e%d", pid)}}) - failPage := body(t, resp) - if resp.StatusCode != http.StatusBadRequest { - t.Fatalf("leerer text: erwartet 400, bekam %d", resp.StatusCode) - } - if !strings.Contains(failPage, "Inhalt darf nicht leer sein.") { - t.Errorf("fehlermeldung fehlt:\n%s", excerpt(failPage, "compose")) - } - if !strings.Contains(failPage, fmt.Sprintf(`action="/entry/%d/edit"`, pid)) { - t.Errorf("kein bearbeitungsformular nach dem fehler:\n%s", excerpt(failPage, "compose")) - } - if !strings.Contains(failPage, fmt.Sprintf(`value="/feed#e%d"`, pid)) { - t.Errorf("rückweg nach dem fehler verloren:\n%s", excerpt(failPage, "return_to")) - } -} - -// TestAbgelaufeneSessionZeigtHTML deckt den Fehler ab, den der Bearbeiten-Weg -// ans Licht gebracht hat: requireAuth antwortete immer mit JSON. Ohne -// JavaScript bekäme ein Nutzer mit abgelaufener Session eine rohe JSON-Zeile -// im Browser zu sehen. -func TestAbgelaufeneSessionZeigtHTML(t *testing.T) { - srv := newTestServer(t) - anon := newClient(t) - - resp := postFormHTML(t, anon, srv.URL+"/entry/create", url.Values{"content": {"x"}}) - page := body(t, resp) - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("erwartet 401, bekam %d", resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { - t.Fatalf("erwartet HTML, bekam %q -- rohes JSON im Browser", ct) - } - if !strings.Contains(page, "Nicht angemeldet") { - t.Errorf("keine verständliche meldung:\n%s", page) - } - if !strings.Contains(page, `href="/feed"`) { - t.Errorf("kein weg zurück von der fehlerseite:\n%s", page) - } - - // Der JSON-Client bekommt weiterhin JSON. - resp = postForm(t, anon, srv.URL+"/entry/create", url.Values{"content": {"x"}}) - defer resp.Body.Close() - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { - t.Fatalf("API-Client: erwartet JSON, bekam %q", ct) - } -} - -// TestLoeschenOhneJavaScript deckt das Löschen ohne JS ab. Kernpunkt: der Link -// im Feed darf nichts löschen, sondern nur fragen -- ohne JavaScript gibt es -// kein confirm(), und einem GET-Link folgen auch Linkvorschauen und Crawler. -func TestLoeschenOhneJavaScript(t *testing.T) { - srv := newTestServer(t) - alice := registerAndLogin(t, srv, "alice") - bob := registerAndLogin(t, srv, "bob") - pid := createEntry(t, alice, srv, "wird gleich geloescht") - replyTo(t, alice, srv, pid, "antwort bleibt") - - // Der Löschen-Link steht nur am eigenen Beitrag. - _, page := getHTML(t, alice, srv.URL+"/feed") - if !strings.Contains(page, fmt.Sprintf("/beitrag/%d/loeschen", pid)) { - t.Fatalf("kein löschen-link am eigenen beitrag:\n%s", excerpt(page, "interactions")) - } - _, bobPage := getHTML(t, bob, srv.URL+"/feed") - if strings.Contains(bobPage, "/loeschen") { - t.Error("löschen-link an fremdem beitrag") - } - - // Der Link führt auf eine Frage, nicht auf die Tat. - confirmURL := fmt.Sprintf("%s/beitrag/%d/loeschen?return_to=%s", - srv.URL, pid, url.QueryEscape(fmt.Sprintf("/feed#e%d", pid))) - status, confirm := getHTML(t, alice, confirmURL) - if status != http.StatusOK { - t.Fatalf("bestätigungsseite: erwartet 200, bekam %d", status) - } - if !strings.Contains(confirm, "wird gleich geloescht") { - t.Errorf("beitrag wird nicht gezeigt:\n%s", excerpt(confirm, "confirm-delete")) - } - if !strings.Contains(confirm, "nicht rückgängig") { - t.Errorf("kein hinweis auf die endgültigkeit:\n%s", excerpt(confirm, "confirm-delete")) - } - if !strings.Contains(confirm, `method="post"`) { - t.Errorf("löschen nicht als POST:\n%s", excerpt(confirm, "confirm-delete")) - } - if !strings.Contains(confirm, "Abbrechen") { - t.Errorf("kein abbrechen-weg:\n%s", excerpt(confirm, "confirm-delete")) - } - - // Entscheidend: das Aufrufen der Bestätigungsseite hat nichts gelöscht. - _, page = getHTML(t, alice, srv.URL+"/feed") - if !strings.Contains(page, "wird gleich geloescht") { - t.Fatal("der bestätigungs-GET hat den beitrag bereits gelöscht") - } - - // Fremder Beitrag -> 403 als HTML. - if status, _ := getHTML(t, bob, fmt.Sprintf("%s/beitrag/%d/loeschen", srv.URL, pid)); status != http.StatusForbidden { - t.Errorf("fremde bestätigungsseite: erwartet 403, bekam %d", status) - } - - // Bestätigen -> 303 auf den Rückweg. - resp := postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/delete", srv.URL, pid), - url.Values{"return_to": {fmt.Sprintf("/feed#e%d", pid)}}) - resp.Body.Close() - if resp.StatusCode != http.StatusSeeOther { - t.Fatalf("löschen: erwartet 303, bekam %d", resp.StatusCode) - } - if got, want := resp.Header.Get("Location"), fmt.Sprintf("/feed#e%d", pid); got != want { - t.Fatalf("löschen: Location = %q, erwartet %q", got, want) - } - - // Platzhalter statt Inhalt, Antwort bleibt erreichbar, keine Aktionen mehr. - _, page = getHTML(t, alice, srv.URL+"/feed") - if strings.Contains(page, "wird gleich geloescht") { - t.Errorf("inhalt nach dem löschen noch sichtbar:\n%s", excerpt(page, "content")) - } - if !strings.Contains(page, "[deleted]") { - t.Errorf("kein platzhalter:\n%s", excerpt(page, "card")) - } - if strings.Contains(page, fmt.Sprintf("/beitrag/%d/loeschen", pid)) { - t.Error("löschen-link an einem bereits gelöschten beitrag") - } - _, thread := getHTML(t, alice, fmt.Sprintf("%s/beitrag/%d", srv.URL, pid)) - if !strings.Contains(thread, "antwort bleibt") { - t.Errorf("antworten nach dem löschen nicht mehr erreichbar:\n%s", excerpt(thread, "replies")) - } -} diff --git a/entry.go b/entry.go index e44be30..3eed574 100644 --- a/entry.go +++ b/entry.go @@ -13,7 +13,6 @@ import ( "math/rand/v2" "mime/multipart" "net/http" - "net/url" "os" "strconv" "strings" @@ -81,9 +80,7 @@ const ( maxFeedPage = 100 ) -// feedWhere baut die Filterbedingung für Feed-Abfragen. Von feedPage und -// feedTotal gemeinsam benutzt -- zwei getrennte Kopien würden auseinander -// laufen und die Seitenzahl gegenüber dem Inhalt verfälschen. +// feedWhere baut die Filterbedingung für Feed-Abfragen. func feedWhere(uid int64, rootsOnly bool) (string, []any) { args := []any{} where := []string{} @@ -100,17 +97,6 @@ func feedWhere(uid int64, rootsOnly bool) (string, []any) { return " WHERE " + strings.Join(where, " AND "), args } -// feedTotal zählt die Beiträge, die feedPage mit denselben Filtern liefern -// würde -- Grundlage für "Seite n von m" im serverseitig gerenderten Feed. -func feedTotal(uid int64, rootsOnly bool) (int, error) { - where, args := feedWhere(uid, rootsOnly) - var n int - err := db.QueryRow( - `SELECT COUNT(*) FROM entry e LEFT JOIN user u ON u.uid = e.uid`+where, args..., - ).Scan(&n) - return n, err -} - // feedPage liefert eine Feed-Seite (feedPageSize Beiträge). Ist uid != 0, nur die Beiträge // dieses Nutzers. rootsOnly blendet Antworten aus und sortiert nach Aktivität; // sonst werden alle Beiträge chronologisch geliefert. @@ -215,9 +201,7 @@ type thread struct { Replies []Entry } -// threadData holt einen Thread aus der Datenbank. Von beiden Adaptern benutzt -// (JSON über handleThread, HTML über showThread) -- die rekursive CTE gibt es -// deshalb nur einmal. +// threadData holt einen Thread aus der Datenbank. func threadData(pid int64) (thread, error) { entry, err := entryByPID(pid) if err != nil { @@ -260,7 +244,6 @@ func threadData(pid int64) (thread, error) { func handleCreateEntry(w http.ResponseWriter, r *http.Request) error { uid := uidFromContext(r.Context()) - json := wantsJSON(r) // MaxBytesReader kappt den gesamten Request hart: ParseMultipartForm // begrenzt nur den Speicher, alles darüber liefe sonst unbegrenzt in @@ -268,11 +251,6 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error { r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) parseErr := r.ParseMultipartForm(16 << 20) if parseErr != nil && parseErr != http.ErrNotMultipart { - // Hier ist der Formularinhalt nicht mehr lesbar (der Body ist weg oder - // kaputt) -- der Text lässt sich also nicht retten. - if !json { - return failCompose(w, r, 0, composeForm{}, errUploadTooLargeOrBroken(parseErr)) - } return errUploadTooLargeOrBroken(parseErr) } @@ -280,18 +258,9 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error { hadFile := r.MultipartForm != nil && len(r.MultipartForm.File["file"]) > 0 replyTo, _ := strconv.ParseInt(r.FormValue("reply_to"), 10, 64) - // fail zeichnet für Formular-Clients die Seite neu, auf der geschrieben - // wurde -- Thread bei einer Antwort, sonst den Feed. - fail := func(err error) error { - if json { - return err - } - return failCompose(w, r, replyTo, composeForm{Content: content, HadFile: hadFile}, err) - } - if replyTo != 0 { if _, err := entryByPID(replyTo); err != nil { - return fail(Invalid("entry.parent_missing", "Elternbeitrag nicht gefunden").At("reply_to")) + return Invalid("entry.parent_missing", "Elternbeitrag nicht gefunden").At("reply_to") } } @@ -301,13 +270,13 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error { if err != nil { // Nicht stillschweigend ohne Bild posten -- der Nutzer soll // erfahren, dass sein Bild abgelehnt wurde. - return fail(Invalid("image.rejected", "Bild konnte nicht verarbeitet werden.").At("file").WithCause(err)) + return Invalid("image.rejected", "Bild konnte nicht verarbeitet werden.").At("file").WithCause(err) } filepath = stored } if filepath == "" && content == "" { - return fail(Invalid("entry.empty", "Leerer Beitrag!").At("content")) + return Invalid("entry.empty", "Leerer Beitrag!").At("content") } now := time.Now().Unix() @@ -360,18 +329,6 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error { return Internal(err) } - // Ohne JavaScript zum frisch erstellten Beitrag. Nicht über return_to: - // ein neuer Wurzelbeitrag steht oben auf Seite 1, egal von welcher Seite - // aus er geschrieben wurde. Bei einer Antwort führt der Weg in den Thread. - if !json { - target := fmt.Sprintf("/feed#e%d", pid) - if replyTo != 0 { - target = fmt.Sprintf("/beitrag/%d#e%d", replyTo, pid) - } - http.Redirect(w, r, target, http.StatusSeeOther) - return nil - } - writeJSON(w, http.StatusCreated, map[string]any{ "pid": pid, "content": content, @@ -385,41 +342,43 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error { // bleibt unverändert; last_activity wird nicht angefasst (eine Korrektur soll den // Thread nicht nach oben spülen). func handleEditEntry(w http.ResponseWriter, r *http.Request) error { - json := wantsJSON(r) - pid, err := pidParam(r) if err != nil { return err } - // Dieselbe Prüfung wie beim Anzeigen des Formulars -- Besitz und Zustand - // können sich zwischen Aufruf und Absenden geändert haben. if _, err := ownEntry(r, pid); err != nil { return err } content := truncateRunes(strings.TrimSpace(r.FormValue("content")), 1000) if content == "" { - e := Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content") - if json { - return e - } - return failEdit(w, r, pid, content, e) + return Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content") } if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil { return Internal(err) } - // Zurück dorthin, wo der Bearbeiten-Link angeklickt wurde -- der Beitrag - // kann im Feed, im Nutzerfeed oder in einem Thread gestanden haben. - if !json { - redirectBack(w, r, threadAnchor(pid)) - return nil - } writeJSON(w, http.StatusOK, map[string]any{"pid": pid, "content": content}) return nil } +// ownEntry lädt einen Beitrag und prüft, ob er dem Anfragenden gehört -- +// Voraussetzung fürs Bearbeiten wie fürs Löschen. +func ownEntry(r *http.Request, pid int64) (Entry, error) { + e, err := entryByPID(pid) + if err != nil { + return Entry{}, errEntryNotFound + } + // Soft-gelöschte Beiträge stehen auf uid 0 und scheitern damit an derselben + // Besitzprüfung -- sie gehören niemandem mehr. Kein eigener Zweig dafür: + // so bleibt es bei den 403, die der Endpunkt schon immer geliefert hat. + if e.UID != uidFromContext(r.Context()) { + return Entry{}, errEntryNotOwned + } + return e, nil +} + // handleDeleteEntry löscht einen eigenen Beitrag "weich": die Zeile bleibt als // [deleted]-Platzhalter erhalten (damit Antworten nicht verwaisen), Inhalt, Bild // und Autor werden entfernt. @@ -428,8 +387,8 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } - // Dieselbe Prüfung wie auf der Bestätigungsseite; entryByPID liefert dabei - // auch den Bildpfad, der nach dem UPDATE nicht mehr in der Zeile steht. + // ownEntry liefert auch den Bildpfad, der nach dem UPDATE nicht mehr in + // der Zeile steht. e, err := ownEntry(r, pid) if err != nil { return err @@ -444,12 +403,6 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error { os.Remove(e.Filepath) } - // Zurück dorthin, wo gelöscht wurde -- der Platzhalter steht dort weiterhin, - // der Anker bleibt also gültig. - if !wantsJSON(r) { - redirectBack(w, r, "/feed") - return nil - } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) return nil } @@ -496,18 +449,7 @@ func handleBump(w http.ResponseWriter, r *http.Request) error { if remaining < 0 { remaining = 0 } - // Ohne JavaScript: der Cooldown ist eine eigene Fehlerseite mit der - // Restzeit -- ein stiller Rücksprung wäre von "hat geklappt" nicht zu - // unterscheiden. - if !wantsJSON(r) { - hours := (remaining + 3599) / 3600 - if hours < 1 { - hours = 1 - } - return RateLimited("entry.bump_cooldown", fmt.Sprintf( - "Dieser Beitrag wurde erst kürzlich repostet. Nächster Repost in etwa %d Stunde(n).", hours)) - } - // JSON-Zweig: bewusst kein Taxonomie-Fehler -- die Antwort trägt + // Bewusst kein Taxonomie-Fehler -- die Antwort trägt // Nutzdaten (retry_after, bump_count), die das Frontend zum Sperren des // Buttons braucht, und der Cooldown ist ein regulärer Ausgang. w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10)) @@ -522,12 +464,6 @@ func handleBump(w http.ResponseWriter, r *http.Request) error { return nil } - // Ohne JavaScript zurück an den Beitrag, auf dem repostet wurde -- der - // Anker in return_to hält die Position. - if !wantsJSON(r) { - redirectBack(w, r, threadAnchor(pid)) - return nil - } writeJSON(w, http.StatusOK, map[string]any{ "pid": pid, "bump_count": bumpCount, @@ -695,48 +631,10 @@ func handleReport(w http.ResponseWriter, r *http.Request) error { return Internal(err) } - // Ohne JavaScript auf eine eigene Bestätigungsseite statt zurück in den - // Feed: bei einer Meldung soll erkennbar sein, dass sie angekommen ist. - // Ein stiller Rücksprung wäre von "nichts passiert" nicht zu unterscheiden. - // Der Weg zurück ist ein Link, kein automatischer Redirect -- der Nutzer - // entscheidet, wann er weiterliest. - if !wantsJSON(r) { - q := url.Values{"return_to": {safeReturn(r.FormValue("return_to"), "/feed")}} - if reason != "" { - q.Set("reason", reason) - } - http.Redirect(w, r, fmt.Sprintf("/entry/%d/gemeldet?%s", pid, q.Encode()), http.StatusSeeOther) - return nil - } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) return nil } -// reportedView füttert die Bestätigungsseite. -type reportedView struct { - PID int64 - Reason string - BackURL string -} - -// showReported bestätigt eine eingegangene Meldung. Eigene Seite mit eigener -// URL, damit ein Reload nichts erneut meldet (Post/Redirect/Get). -func showReported(w http.ResponseWriter, r *http.Request) error { - pid, err := pidParam(r) - if err != nil { - return err - } - render(w, r, "reported", pageData{ - Title: "Meldung eingegangen", - Form: reportedView{ - PID: pid, - Reason: truncateRunes(r.URL.Query().Get("reason"), 500), - BackURL: safeReturn(r.URL.Query().Get("return_to"), "/feed"), - }, - }) - return nil -} - // handleVote gibt die Stimme des angemeldeten Nutzers ab oder schaltet sie um: // - keine bisherige Stimme -> neue Stimme // - gleiche Stimme erneut -> Stimme zurückziehen (Toggle) @@ -776,12 +674,6 @@ func handleVote(w http.ResponseWriter, r *http.Request) error { } } - // Ohne JavaScript zurück an den Beitrag, auf dem abgestimmt wurde -- der - // Anker in return_to hält die Position im Feed. - if !wantsJSON(r) { - redirectBack(w, r, "/feed") - return nil - } writeTally(w, pid, uid) return nil } diff --git a/feedview.go b/feedview.go deleted file mode 100644 index 2d8272a..0000000 --- a/feedview.go +++ /dev/null @@ -1,539 +0,0 @@ -package main - -import ( - "fmt" - "html/template" - "net/http" - "net/url" - "regexp" - "strconv" - "strings" - "time" - - "github.com/go-chi/chi/v5" -) - -// Serverseitig gerenderter Feed: das Gegenstück zu createFeed/renderEntry in -// web/js/feed.js. Beide holen dieselben Daten über feedPage; nur das Zeichnen -// passiert einmal in Go und einmal in JavaScript. -// -// Der Unterschied im Blättern ist Absicht: mit JavaScript hängt der Feed die -// nächste Seite unten an (Infinite Scroll), ohne JavaScript wechselt man auf -// eine eigene Seiten-URL. Nur so bleibt ein Anker (#e123) nach einer Aktion -// gültig -- bei akkumulierendem Nachladen wäre der Beitrag nach der Rückkehr -// gar nicht geladen. - -// reContentURL entspricht dem Muster in feed.js (linkify): http/https bis zum -// nächsten Leerzeichen oder '<'. Das Schema ist Teil des Musters, deshalb kann -// hier kein javascript:-Link entstehen. -var reContentURL = regexp.MustCompile(`https?://[^\s<]+`) - -// renderContent macht aus dem rohen Beitragstext sicheres HTML: alles wird -// escaped, danach werden nur die selbst erzeugten Links und
eingefügt. -// Reihenfolge ist wesentlich -- erst escapen, dann Markup ergänzen. Umgekehrt -// würde das Escapen die eigenen Tags wieder zerlegen. -func renderContent(s string) template.HTML { - var b strings.Builder - esc := func(part string) { - b.WriteString(strings.ReplaceAll(template.HTMLEscapeString(part), "\n", "
")) - } - - last := 0 - for _, m := range reContentURL.FindAllStringIndex(s, -1) { - esc(s[last:m[0]]) - link := template.HTMLEscapeString(s[m[0]:m[1]]) - b.WriteString(`
` + link + ``) - last = m[1] - } - esc(s[last:]) - return template.HTML(b.String()) -} - -// avatarURL spiegelt avatarUrl aus feed.js. -func avatarURL(path string) string { - if path == "" { - return "/static/assets/no_profile_pic.jpg" - } - return "/" + path -} - -// entryView ist ein Beitrag mit allem, was die Vorlage zum Zeichnen braucht. -// Der Abstimmungsstand hängt am Betrachter, ReturnTo am Seitenaufruf -- -// beides kann die Entry-Struktur aus der Datenbank nicht liefern. -type entryView struct { - Entry - tallyState - // ReturnTo ist auf einer Seite überall gleich; am Beitrag statt an der - // Seite, damit der entry-Block ohne Zugriff auf den Seitenkontext - // auskommt und auch einzeln (Thread-Ansicht) verwendbar bleibt. - ReturnTo string - LoggedIn bool - - // EditURL ist gesetzt, wenn der Betrachter den Beitrag bearbeiten darf. - // Die Adresse trägt den Rückweg schon mit, damit das Speichern dorthin - // zurückführt, wo der Bearbeiten-Link angeklickt wurde -- und nicht auf - // die Bearbeitungsseite selbst. - EditURL string - // DeleteURL führt auf die Bestätigungsseite, nicht direkt aufs Löschen: - // ein Link darf nichts Unwiderrufliches auslösen (Vorschauen und Crawler - // folgen GET-Links ungefragt). - DeleteURL string -} - -// bumpRemaining ist die Restsperre des Reposts in Sekunden (0 = wieder -// möglich). Spiegelt die Bedingung im UPDATE von handleBump; die Vorlage -// braucht den Wert, um den Repost-Knopf zu sperren und die Wartezeit -// anzuzeigen -- das Gegenstück zu setupBump in web/js/feed.js. -func (v entryView) bumpRemaining() int64 { - rem := v.LastBump + v.BumpCount*bumpStep - time.Now().Unix() - if rem < 0 { - return 0 - } - return rem -} - -// BumpLocked meldet, ob der Repost dieses Beitrags gerade im Cooldown steckt. -func (v entryView) BumpLocked() bool { return v.bumpRemaining() > 0 } - -// BumpWaitHours ist die Restsperre auf volle Stunden aufgerundet (mindestens -// 1, solange überhaupt Zeit übrig ist) -- wie die Anzeige im JS-Frontend. -func (v entryView) BumpWaitHours() int64 { - h := (v.bumpRemaining() + 3599) / 3600 - if h < 1 { - return 1 - } - return h -} - -// composeForm hält die Eingaben des Beitragsformulars über einen -// fehlgeschlagenen Versuch hinweg. Die angehängte Datei fehlt dabei -// zwangsläufig: ein lässt sich aus Sicherheitsgründen -// nicht vorbelegen, deshalb HadFile als Hinweis für den Nutzer. -type composeForm struct { - Content string - HadFile bool -} - -// feedView ist das Datenmodell der Feed-Seite. -type feedView struct { - Entries []entryView - Page int // 0-basiert wie feedPage - Pages int // Gesamtzahl, mindestens 1 - PrevURL string - NextURL string - - // ReturnTo ist der Rücksprungort dieser Seite (beim GET die Seite selbst). - // Das Beitragsformular trägt ihn als verstecktes Feld, damit er einen - // abgelehnten Post übersteht -- sonst fiele das neu gerenderte Formular - // samt seiner Interaktions-Knöpfe auf den Default "/" zurück (die noch per - // JS bediente Startseite) statt auf "/feed". - ReturnTo string - - // CanPost steuert, ob das Beitragsformular erscheint. Ohne Anmeldung gibt - // es nichts zu schreiben; über TOR wird das Schreiben abgelehnt (blockTOR), - // und ein Formular, das beim Absenden immer scheitert, wäre schlechter als - // ein ehrlicher Hinweis. - CanPost bool - IsTOR bool - Compose composeForm -} - -// HumanPage ist die für Menschen gezählte Seitenzahl (ab 1). -func (v feedView) HumanPage() int { return v.Page + 1 } - -// pageURL baut die URL einer Feed-Seite aus der aktuellen Anfrage. Über -// r.URL.Query() statt aus Einzelteilen, damit spätere Filter (Suche, -// Nutzer-Feed) automatisch erhalten bleiben. -func pageURL(r *http.Request, page int) string { - q := r.URL.Query() - if page <= 0 { - q.Del("page") - } else { - q.Set("page", strconv.Itoa(page)) - } - if len(q) == 0 { - return r.URL.Path - } - return r.URL.Path + "?" + q.Encode() -} - -// buildFeedView holt Beiträge und Gesamtzahl und baut die Blätter-Links. -func buildFeedView(r *http.Request, uid int64, rootsOnly bool) (feedView, error) { - // Bei GET steht die Seite in der Query. Wird der Feed nach einem - // fehlgeschlagenen POST neu gerendert, kommt sie aus dem Formular -- - // sonst landet der Nutzer mit seinem Text unvermittelt auf Seite 1. - pageParam := r.URL.Query().Get("page") - if pageParam == "" { - pageParam = r.FormValue("page") - } - page, _ := strconv.Atoi(pageParam) - if page < 0 { - page = 0 - } - if page > maxFeedPage { - page = maxFeedPage - } - - total, err := feedTotal(uid, rootsOnly) - if err != nil { - return feedView{}, Internal(err) - } - pages := (total + feedPageSize - 1) / feedPageSize - if pages < 1 { - pages = 1 - } - if pages > maxFeedPage+1 { - pages = maxFeedPage + 1 - } - // Hinter der letzten Seite gäbe es nur eine leere Liste ohne Weg zurück. - if page > pages-1 { - page = pages - 1 - } - - entries, err := feedPage(page, uid, rootsOnly) - if err != nil { - return feedView{}, Internal(err) - } - - // Der Betrachter ist nicht der gefilterte Nutzer: uid oben schränkt ein, - // wessen Beiträge gezeigt werden -- viewer ist, wer zusieht. - var viewer int64 - if s, ok := getSession(r); ok { - viewer = s.UID - } - pids := make([]int64, 0, len(entries)) - for _, e := range entries { - pids = append(pids, e.PID) - } - tallies, err := voteTallies(pids, viewer) - if err != nil { - return feedView{}, Internal(err) - } - - back := returnTo(r) - v := feedView{ - Entries: toEntryViews(entries, tallies, back, viewer), - Page: page, - Pages: pages, - IsTOR: isTOR(r), - ReturnTo: back, - } - v.CanPost = viewer != 0 && !v.IsTOR - if page > 0 { - v.PrevURL = pageURL(r, page-1) - } - if page < pages-1 { - v.NextURL = pageURL(r, page+1) - } - return v, nil -} - -// threadView ist das Datenmodell der Thread-Seite. -type threadView struct { - Ancestors []entryView - Entry entryView - Replies []entryView - - CanPost bool - IsTOR bool - Compose composeForm - - // ReturnTo wie bei feedView: das Antwortformular trägt ihn versteckt mit, - // damit eine abgelehnte Antwort die Interaktions-Knöpfe nicht auf "/" - // zurückwirft statt auf den Thread. - ReturnTo string -} - -// toEntryViews reichert Beiträge mit Abstimmungsstand, Rücksprungziel und -// Anmeldestatus an -- alles, was die Vorlage braucht und die Datenbank nicht -// liefert. Feed und Thread teilen sich den Schritt. -func toEntryViews(entries []Entry, tallies map[int64]tallyState, back string, viewer int64) []entryView { - views := make([]entryView, 0, len(entries)) - for _, e := range entries { - v := entryView{ - Entry: e, - tallyState: tallies[e.PID], - ReturnTo: back, - LoggedIn: viewer != 0, - } - // Gelöschte Beiträge haben uid 0 -- ohne die Deleted-Prüfung wäre der - // Platzhalter für einen nicht angemeldeten Betrachter "eigener" - // Beitrag. viewer != 0 fängt das zwar schon ab, die Prüfung bleibt - // trotzdem stehen: an einem [deleted] gibt es nichts zu bearbeiten. - if viewer != 0 && e.UID == viewer && e.Deleted == 0 { - v.EditURL = ownActionURL("bearbeiten", e.PID, back) - v.DeleteURL = ownActionURL("loeschen", e.PID, back) - } - views = append(views, v) - } - return views -} - -// ownActionURL verlinkt eine Seite für den eigenen Beitrag (bearbeiten, -// loeschen) und hängt den Rückweg als Query an. url.Values kodiert dabei auch -// das '#' des Ankers korrekt -- von Hand zusammengesetzt würde der Anker die -// Query zerschneiden. -func ownActionURL(action string, pid int64, back string) string { - q := url.Values{"return_to": {fmt.Sprintf("%s#e%d", back, pid)}} - return fmt.Sprintf("/beitrag/%d/%s?%s", pid, action, q.Encode()) -} - -// buildThreadView holt den Thread und bereitet ihn zum Zeichnen auf. -func buildThreadView(r *http.Request, pid int64) (threadView, error) { - t, err := threadData(pid) - if err != nil { - return threadView{}, err - } - - var viewer int64 - if s, ok := getSession(r); ok { - viewer = s.UID - } - - // Alle Beiträge der Seite in einem Rutsch -- Ahnen, der Beitrag selbst und - // die Antworten. - pids := make([]int64, 0, len(t.Ancestors)+1+len(t.Replies)) - pids = append(pids, t.Entry.PID) - for _, e := range t.Ancestors { - pids = append(pids, e.PID) - } - for _, e := range t.Replies { - pids = append(pids, e.PID) - } - tallies, err := voteTallies(pids, viewer) - if err != nil { - return threadView{}, Internal(err) - } - - back := returnTo(r) - v := threadView{ - Ancestors: toEntryViews(t.Ancestors, tallies, back, viewer), - Entry: toEntryViews([]Entry{t.Entry}, tallies, back, viewer)[0], - Replies: toEntryViews(t.Replies, tallies, back, viewer), - IsTOR: isTOR(r), - ReturnTo: back, - } - // Auf einen gelöschten Beitrag lässt sich nicht antworten -- er hat keinen - // Autor mehr und der Platzhalter soll den Thread nur zusammenhalten. - v.CanPost = viewer != 0 && !v.IsTOR && t.Entry.Deleted == 0 - return v, nil -} - -// showThread rendert einen Beitrag mit Ahnenkette, Antworten und Antwortformular. -func showThread(w http.ResponseWriter, r *http.Request) error { - pid, err := pidParam(r) - if err != nil { - return err - } - v, err := buildThreadView(r, pid) - if err != nil { - return err - } - render(w, r, "thread", pageData{Title: "Beitrag", Form: v}) - return nil -} - -// editView füttert die Bearbeitungsseite. Anders als beim Erstellen ist der -// Ausgangszustand nicht leer, sondern der bisherige Text. -type editView struct { - PID int64 - Content string - BackURL string -} - -// ownEntry lädt einen Beitrag und prüft, ob er dem Anfragenden gehört -- -// Voraussetzung fürs Bearbeiten wie fürs Löschen. Bewusst dieselbe Prüfkette -// wie in den schreibenden Handlern: eine Seite anzuzeigen, die beim Absenden -// abgelehnt wird, wäre irreführend. -func ownEntry(r *http.Request, pid int64) (Entry, error) { - e, err := entryByPID(pid) - if err != nil { - return Entry{}, errEntryNotFound - } - // Soft-gelöschte Beiträge stehen auf uid 0 und scheitern damit an derselben - // Besitzprüfung -- sie gehören niemandem mehr. Kein eigener Zweig dafür: - // so bleibt es bei den 403, die der Endpunkt schon immer geliefert hat. - if e.UID != uidFromContext(r.Context()) { - return Entry{}, errEntryNotOwned - } - return e, nil -} - -// showEditEntry zeigt das Bearbeitungsformular mit dem bisherigen Text. -func showEditEntry(w http.ResponseWriter, r *http.Request) error { - pid, err := pidParam(r) - if err != nil { - return err - } - e, err := ownEntry(r, pid) - if err != nil { - return err - } - - render(w, r, "edit", pageData{ - Title: "Beitrag bearbeiten", - Form: editView{ - PID: pid, - Content: e.Content, - BackURL: safeReturn(r.URL.Query().Get("return_to"), threadAnchor(pid)), - }, - }) - return nil -} - -// failEdit zeichnet das Bearbeitungsformular nach einem Fehler neu -- mit der -// abgelehnten Fassung, nicht mit dem gespeicherten Stand. Sonst verlöre der -// Nutzer genau die Änderung, die er gerade vorgenommen hat. -func failEdit(w http.ResponseWriter, r *http.Request, pid int64, content string, err error) error { - e := asError(err) - if e.Kind == KindInternal { - renderError(w, e) - return nil - } - failForm(w, r, "edit", pageData{ - Title: "Beitrag bearbeiten", - Form: editView{ - PID: pid, - Content: content, - BackURL: safeReturn(r.FormValue("return_to"), threadAnchor(pid)), - }, - }, e) - return nil -} - -// threadAnchor ist der Standard-Rückweg zu einem Beitrag. -func threadAnchor(pid int64) string { - return fmt.Sprintf("/beitrag/%d#e%d", pid, pid) -} - -// deleteView füttert die Löschbestätigung. Sie zeigt den Beitrag noch einmal, -// damit erkennbar ist, was verschwindet. -type deleteView struct { - PID int64 - Content string - HasImage bool - IsRoot bool - Replies int64 - BackURL string -} - -// showDeleteEntry fragt vor dem Löschen nach. Eigene Seite statt eines Links, -// der direkt löscht: ein GET darf nichts Unwiderrufliches tun, und ohne -// JavaScript gibt es kein confirm(). -func showDeleteEntry(w http.ResponseWriter, r *http.Request) error { - pid, err := pidParam(r) - if err != nil { - return err - } - e, err := ownEntry(r, pid) - if err != nil { - return err - } - - render(w, r, "delete", pageData{ - Title: "Beitrag löschen", - Form: deleteView{ - PID: pid, - Content: e.Content, - HasImage: e.Filepath != "", - IsRoot: e.ReplyTo == 0, - Replies: e.ReplyCount, - BackURL: safeReturn(r.URL.Query().Get("return_to"), threadAnchor(pid)), - }, - }) - return nil -} - -// failCompose zeichnet den Feed nach einem abgelehnten Beitrag neu -- mit -// Fehlermeldung und dem bereits getippten Text. Ohne JavaScript gibt es keinen -// Weg, nur einen Teil der Seite auszutauschen; der Text darf dabei trotzdem -// nicht verloren gehen. -// Antworten werden im Thread neu gezeichnet, neue Beiträge im Feed -- der -// Nutzer soll dort landen, wo er geschrieben hat. -func failCompose(w http.ResponseWriter, r *http.Request, replyTo int64, form composeForm, err error) error { - e := asError(err) - if e.Kind == KindInternal { - renderError(w, e) - return nil - } - - if replyTo != 0 { - v, verr := buildThreadView(r, replyTo) - if verr != nil { - // Gibt es den Elternbeitrag nicht (mehr), lässt sich der Thread - // nicht zeichnen -- dann bleibt nur die Fehlerseite. - return verr - } - v.Compose = form - failForm(w, r, "thread", pageData{Title: "Beitrag", Form: v}, e) - return nil - } - - v, verr := buildFeedView(r, 0, true) - if verr != nil { - // Wenn schon der Feed nicht mehr geht, ist der Beitragsfehler das - // kleinere Problem -- dann die Fehlerseite zeigen. - return verr - } - v.Compose = form - - failForm(w, r, "feed", pageData{Title: "Feed", Form: v}, e) - return nil -} - -// showFeed rendert den Hauptfeed (nur Wurzelbeiträge, nach Aktivität sortiert). -func showFeed(w http.ResponseWriter, r *http.Request) error { - v, err := buildFeedView(r, 0, true) - if err != nil { - return err - } - render(w, r, "feed", pageData{Title: "Feed", Form: v}) - return nil -} - -// showUserFeed rendert den Feed eines einzelnen Nutzers (inkl. Antworten). -func showUserFeed(w http.ResponseWriter, r *http.Request) error { - username := chi.URLParam(r, "username") - - var uid int64 - if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, username).Scan(&uid); err != nil { - return errUserNotFound - } - - v, err := buildFeedView(r, uid, false) - if err != nil { - return err - } - render(w, r, "userfeed", pageData{ - Title: username, - Form: userFeedView{feedView: v, Username: username}, - }) - return nil -} - -// userFeedView ergänzt den Feed um den Nutzer, dessen Beiträge gezeigt werden. -type userFeedView struct { - feedView - Username string -} - -// profileURL escaped den Namen für den Pfad. chi liefert ihn roh; ohne -// Kodierung würden Namen mit '.' oder '-' zwar funktionieren, ein '/' oder '?' -// im Namen aber die URL zerlegen. -func profileURL(username string) string { - return "/u/" + url.PathEscape(username) -} - -// fmtTime formatiert einen Unix-Zeitstempel für die Anzeige. Ohne JavaScript -// gibt es keine Client-Zeitzone, deshalb UTC mit ausgeschriebenem Kürzel -- -// lieber eindeutig als scheinbar lokal. -func fmtTime(unix int64) string { - return time.Unix(unix, 0).UTC().Format("02.01.2006, 15:04 UTC") -} - -// fmtTimeAttr liefert dieselbe Zeit maschinenlesbar für