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