diff --git a/.containerignore b/.containerignore index e196b6a..d6fb17a 100644 --- a/.containerignore +++ b/.containerignore @@ -10,6 +10,7 @@ kver *.db-shm *.db-wal *.db.bak +*.env static/media/ migrate.sh Containerfile diff --git a/Containerfile b/Containerfile index 77411ab..1168fb2 100644 --- a/Containerfile +++ b/Containerfile @@ -4,6 +4,10 @@ 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 0198883..1a3c6e2 100644 --- a/auth.go +++ b/auth.go @@ -4,6 +4,7 @@ import ( crand "crypto/rand" "database/sql" "encoding/hex" + "errors" "net/http" "regexp" "strconv" @@ -53,34 +54,92 @@ func getSession(r *http.Request) (*Session, bool) { return &s, true } -func handleLogin(w http.ResponseWriter, r *http.Request) { +// errBadCredentials ist für "Nutzer existiert nicht" und "Passwort falsch" +// 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 + return nil } username := sanitizeUsername(r.FormValue("user")) if len(username) < 3 { - writeError(w, http.StatusBadRequest, "Der Nutzername ist zu kurz.") - return + return fail(username, Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user")) } + // Gleiche Meldung für "Nutzer existiert nicht" und "Passwort falsch": sonst + // wird der Endpunkt zum Nutzernamen-Orakel. var uid int64 var hash []byte err := db.QueryRow(`SELECT uid, password FROM user WHERE username = ?`, username).Scan(&uid, &hash) if err == sql.ErrNoRows { - writeError(w, http.StatusUnauthorized, "Nutzername oder Passwort ist falsch.") - return + return fail(username, errBadCredentials) } else if err != nil { - writeError(w, http.StatusInternalServerError, "Datenbankfehler") - return + return Internal(err) } if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass"))) != nil { - writeError(w, http.StatusUnauthorized, "Nutzername oder Passwort ist falsch.") - return + return fail(username, 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. +func startSession(w http.ResponseWriter, r *http.Request, uid int64) error { now := time.Now().Unix() // timeout ist clientgesteuert -> serverseitig deckeln, damit niemand eine // quasi-unbegrenzte Session anlegen kann. Default 1 Tag, Maximum 30 Tage. @@ -96,8 +155,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { token, err := newToken() if err != nil { - writeError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden") - return + return Internal(err) } db.Exec(`DELETE FROM session WHERE expires < ?`, now) @@ -105,8 +163,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { `INSERT INTO session (uid, created_at, expires, description, value) VALUES (?, ?, ?, ?, ?)`, uid, now, expires, "", token, ); err != nil { - writeError(w, http.StatusInternalServerError, "Session konnte nicht erstellt werden") - return + return Internal(err) } db.Exec(`UPDATE user SET last_login = ? WHERE uid = ?`, now, uid) @@ -119,7 +176,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode, }) - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + return nil } func handleLogout(w http.ResponseWriter, r *http.Request) { @@ -139,43 +196,94 @@ func handleLogout(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } -func handleNewUser(w http.ResponseWriter, r *http.Request) { - var errs []string +// handleNewUser sammelt alle Validierungsfehler ein, statt beim ersten +// 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")) if len(username) < 3 { - errs = append(errs, "Der Nutzername ist zu kurz.") - } - if idFromUsername(username) != 0 { - errs = append(errs, "Der Nutzername wird bereits verwendet.") + fields["user"] = "Der Nutzername ist zu kurz." + } else if idFromUsername(username) != 0 { + fields["user"] = "Der Nutzername wird bereits verwendet." } pass1 := r.FormValue("pass1") pass2 := r.FormValue("pass2") + switch { + case len(pass1) < 10: + fields["pass1"] = "Das Passwort ist kürzer als 10 Zeichen." + // bcrypt verarbeitet maximal 72 Bytes; GenerateFromPassword gäbe darüber + // einen Fehler -> hier abfangen statt als generischer 500 zu enden. + case len(pass1) > 72: + fields["pass1"] = "Das Passwort ist länger als 72 Bytes." + } if pass1 != pass2 { - errs = append(errs, "Passwort und Passwortbestätigung sind ungleich.") - } - if len(pass1) < 10 { - errs = append(errs, "Das Passwort ist kürzer als 10 Zeichen.") + fields["pass2"] = "Passwort und Passwortbestätigung sind ungleich." } - if len(errs) > 0 { - writeJSON(w, http.StatusBadRequest, map[string]any{"errors": errs}) - return + // 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 err := createUser(username, pass1); err != nil { - writeError(w, http.StatusInternalServerError, "Account konnte nicht erstellt werden") - return + if len(fields) > 0 { + e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.") + e.Fields = fields + return fail(e) + } + + uid, 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) + } 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 } -func handleSessionInfo(w http.ResponseWriter, r *http.Request) { +// 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 { - writeError(w, http.StatusUnauthorized, "Invalid session") - return + return Unauth("auth.no_session", "Keine gültige Sitzung.") } writeJSON(w, http.StatusOK, map[string]any{ "uid": s.UID, @@ -183,6 +291,7 @@ func handleSessionInfo(w http.ResponseWriter, r *http.Request) { "expires": s.Expires, "description": s.Description, }) + return nil } func handleHeaderbar(w http.ResponseWriter, r *http.Request) { diff --git a/db.go b/db.go index c5d753a..9a4d61e 100644 --- a/db.go +++ b/db.go @@ -67,6 +67,20 @@ CREATE TABLE IF NOT EXISTS impression ( PRIMARY KEY (day, anon_ip, path) ); +-- Meldungen zu Beiträgen (DSA-Melde-/Abhilfeverfahren). Bewusst ohne Auth: +-- jeder darf melden, weil es eine Moderationshilfe ist. uid ist der Melder, +-- vorerst immer 0 (anonym) -- die Spalte existiert nur, um bei Spam später den +-- Absender nachziehen zu können, ohne das Schema zu ändern. handled: 0 offen, +-- 1 von der Moderation erledigt. reason ist ein optionaler Freitext. +CREATE TABLE IF NOT EXISTS report ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pid INTEGER NOT NULL, + uid INTEGER NOT NULL DEFAULT 0, + reason TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + handled INTEGER NOT NULL DEFAULT 0 +); + -- Indizes für die häufigen Zugriffspfade; ohne sie werden Feed (ORDER BY -- last_activity), Thread (WHERE reply_to) und Vote-Zähler (WHERE pid) mit -- wachsender Tabelle zu Full-Table-Scans. username/session.value/vote(uid,pid) @@ -76,6 +90,7 @@ CREATE INDEX IF NOT EXISTS idx_entry_reply_to ON entry(reply_to); CREATE INDEX IF NOT EXISTS idx_entry_uid ON entry(uid); CREATE INDEX IF NOT EXISTS idx_vote_pid ON vote(pid); CREATE INDEX IF NOT EXISTS idx_session_uid ON session(uid); +CREATE INDEX IF NOT EXISTS idx_report_open ON report(handled, created_at); ` func initDB(dsn string) error { diff --git a/endpoints_test.go b/endpoints_test.go index fe8b6be..95bb8d0 100644 --- a/endpoints_test.go +++ b/endpoints_test.go @@ -1,15 +1,22 @@ 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" + "unicode/utf8" ) // newTestServer richtet eine frische, isolierte DB (eigene Datei je Test) und @@ -38,9 +45,20 @@ 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. func postForm(t *testing.T, c *http.Client, urlStr string, form url.Values) *http.Response { t.Helper() - resp, err := c.PostForm(urlStr, form) + req, err := http.NewRequest(http.MethodPost, urlStr, strings.NewReader(form.Encode())) + if err != nil { + t.Fatalf("request %s: %v", urlStr, err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := c.Do(req) if err != nil { t.Fatalf("POST %s: %v", urlStr, err) } @@ -295,13 +313,15 @@ type threadEntry struct { Username string `json:"username"` } -type threadView struct { +// threadJSON ist die Antwortform von /entry/{pid}/thread. Nicht zu verwechseln +// mit threadView in feedview.go -- das ist das Modell der gerenderten Seite. +type threadJSON struct { Entry threadEntry `json:"entry"` Ancestors []threadEntry `json:"ancestors"` Replies []threadEntry `json:"replies"` } -func getThread(t *testing.T, srv *httptest.Server, pid int64) threadView { +func getThread(t *testing.T, srv *httptest.Server, pid int64) threadJSON { t.Helper() resp, err := http.Get(fmt.Sprintf("%s/entry/%d/thread", srv.URL, pid)) if err != nil { @@ -311,7 +331,7 @@ func getThread(t *testing.T, srv *httptest.Server, pid int64) threadView { if resp.StatusCode != http.StatusOK { t.Fatalf("thread %d: status %d", pid, resp.StatusCode) } - var tv threadView + var tv threadJSON json.NewDecoder(resp.Body).Decode(&tv) return tv } @@ -554,6 +574,8 @@ func TestDeleteUser(t *testing.T) { } } +// postFormFrom schickt wie postForm als API-Client (Accept: application/json), +// zusätzlich mit gesetzter Client-IP. // postFormFrom verhält sich wie postForm, setzt aber X-Real-IP -> middleware.RealIP // überschreibt damit r.RemoteAddr, sodass blockTOR die simulierte Quell-IP sieht. func postFormFrom(t *testing.T, c *http.Client, urlStr, realIP string, form url.Values) *http.Response { @@ -563,6 +585,7 @@ func postFormFrom(t *testing.T, c *http.Client, urlStr, realIP string, form url. t.Fatalf("request %s: %v", urlStr, err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") req.Header.Set("X-Real-IP", realIP) resp, err := c.Do(req) if err != nil { @@ -571,6 +594,99 @@ func postFormFrom(t *testing.T, c *http.Client, urlStr, realIP string, form url. return resp } +// TestLogoutPost prüft, dass Logout als POST funktioniert (und als GET nicht +// mehr existiert -- als GET wäre er per fremdem Link auslösbar, CSRF). +func TestLogoutPost(t *testing.T) { + srv := newTestServer(t) + c := registerAndLogin(t, srv, "alice") + + resp, err := c.Get(srv.URL + "/auth/logout") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + // GET fällt in den FileServer-Catch-All -> 404. Hauptsache: kein Logout. + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("GET logout: erwartet 404, bekam %d", resp.StatusCode) + } + + resp = postForm(t, c, srv.URL+"/auth/logout", url.Values{}) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("POST logout: erwartet 200, bekam %d", resp.StatusCode) + } + + resp, err = c.Get(srv.URL + "/auth/headerbar") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var h struct { + LoggedIn bool `json:"loggedin"` + } + json.NewDecoder(resp.Body).Decode(&h) + if h.LoggedIn { + t.Fatal("nach Logout sollte loggedin=false sein") + } +} + +// TestCrossOriginPostRejected prüft den CSRF-Schutz: POSTs mit fremdem +// Origin-Header werden abgewiesen, same-origin und ohne Origin gehen durch. +func TestCrossOriginPostRejected(t *testing.T) { + srv := newTestServer(t) + c := registerAndLogin(t, srv, "alice") + + post := func(origin string) int { + req, err := http.NewRequest(http.MethodPost, srv.URL+"/entry/create", + strings.NewReader(url.Values{"content": {"x"}}.Encode())) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + if origin != "" { + req.Header.Set("Origin", origin) + } + resp, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + return resp.StatusCode + } + + if got := post("https://boese-seite.example"); got != http.StatusForbidden { + t.Fatalf("cross-origin POST: erwartet 403, bekam %d", got) + } + if got := post("null"); got != http.StatusForbidden { + t.Fatalf("Origin null: erwartet 403, bekam %d", got) + } + if got := post(srv.URL); got != http.StatusCreated { + t.Fatalf("same-origin POST: erwartet 201, bekam %d", got) + } + if got := post(""); got != http.StatusCreated { + t.Fatalf("POST ohne Origin: erwartet 201, bekam %d", got) + } +} + +// TestContentTruncationRuneSafe prüft, dass überlanger Content runenweise (nicht +// byteweise) gekürzt wird und gültiges UTF-8 bleibt. +func TestContentTruncationRuneSafe(t *testing.T) { + srv := newTestServer(t) + alice := registerAndLogin(t, srv, "alice") + + long := strings.Repeat("ä", 1005) // 2 Bytes je Rune: byteweises s[:1000] schnitte mittendrin + pid := createEntry(t, alice, srv, long) + + tv := getThread(t, srv, pid) + if !utf8.ValidString(tv.Entry.Content) { + t.Fatal("gekürzter Content ist kein gültiges UTF-8") + } + if n := len([]rune(tv.Entry.Content)); n != 1000 { + t.Fatalf("erwartete 1000 Runen, bekam %d", n) + } +} + // TestBlockTOREntry prüft, dass Beiträge aus dem TOR-Netz (Default 10.89.0.0/16) // mit 403 abgewiesen werden, aus einem anderen Netz aber durchgehen. func TestBlockTOREntry(t *testing.T) { @@ -589,3 +705,951 @@ 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")) + } + + // 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. + resp = postFormHTML(t, alice, srv.URL+"/entry/create", url.Values{"content": {" "}}) + 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")) + } + + // 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")) + } + + // 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. + resp = postFormHTML(t, alice, srv.URL+"/entry/create", + url.Values{"content": {" "}, "reply_to": {fmt.Sprint(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")) + } + + // 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 2267d6b..28fde69 100644 --- a/entry.go +++ b/entry.go @@ -2,15 +2,18 @@ package main import ( "database/sql" + "errors" "fmt" "image" "image/gif" "image/jpeg" _ "image/png" "io" + "log" "math/rand/v2" "mime/multipart" "net/http" + "net/url" "os" "strconv" "strings" @@ -71,19 +74,17 @@ func entryByPID(pid int64) (Entry, error) { return e, err } -// feedPage liefert eine Feed-Seite (20 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. -func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) { - if page < 0 { - page = 0 - } - if page > 100 { - page = 100 - } - const count = 20 +const ( + feedPageSize = 20 + // maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen SQLite + // sonst dazu, entsprechend viele Zeilen zu überspringen. + maxFeedPage = 100 +) - query := entrySelect +// 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. +func feedWhere(uid int64, rootsOnly bool) (string, []any) { args := []any{} where := []string{} if uid != 0 { @@ -93,16 +94,43 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) { if rootsOnly { where = append(where, "e.reply_to = 0") } - if len(where) > 0 { - query += " WHERE " + strings.Join(where, " AND ") + if len(where) == 0 { + return "", args } + 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. +func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) { + if page < 0 { + page = 0 + } + if page > maxFeedPage { + page = maxFeedPage + } + + where, args := feedWhere(uid, rootsOnly) + query := entrySelect + where if rootsOnly { query += ` ORDER BY e.last_activity DESC` } else { query += ` ORDER BY e.created_at DESC` } query += ` LIMIT ? OFFSET ?` - args = append(args, count, page*count) + args = append(args, feedPageSize, page*feedPageSize) rows, err := db.Query(query, args...) if err != nil { @@ -111,31 +139,30 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) { return scanEntries(rows) } -func handleFeed(w http.ResponseWriter, r *http.Request) { +func handleFeed(w http.ResponseWriter, r *http.Request) error { page, _ := strconv.Atoi(chi.URLParam(r, "page")) entries, err := feedPage(page, 0, true) if err != nil { - writeError(w, http.StatusInternalServerError, "Datenbankfehler") - return + return Internal(err) } writeJSON(w, http.StatusOK, entries) + return nil } // handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten). -func handleUserFeed(w http.ResponseWriter, r *http.Request) { +func handleUserFeed(w http.ResponseWriter, r *http.Request) error { var uid int64 if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, chi.URLParam(r, "username")).Scan(&uid); err != nil { - writeError(w, http.StatusNotFound, "Nutzer nicht gefunden") - return + return errUserNotFound } page, _ := strconv.Atoi(chi.URLParam(r, "page")) entries, err := feedPage(page, uid, false) if err != nil { - writeError(w, http.StatusInternalServerError, "Datenbankfehler") - return + return Internal(err) } writeJSON(w, http.StatusOK, entries) + return nil } // serveEntryPage liefert die statische Focus-Seite; das Frontend lädt die Daten @@ -144,127 +171,205 @@ func serveEntryPage(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "web/entry.html") } -// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den -// direkten Antworten (öffentlich). -func handleThread(w http.ResponseWriter, r *http.Request) { +var ( + errEntryNotFound = NotFound("entry.not_found", "Beitrag nicht gefunden") + errEntryNotOwned = Forbidden("entry.not_owner", "Das ist nicht dein Beitrag.") +) + +// pidParam liest den {pid}-Routenparameter. Eigene Funktion, weil praktisch +// jeder Entry-Handler damit anfängt und die Fehlermeldung überall dieselbe ist. +func pidParam(r *http.Request) (int64, error) { pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Ungültige pid") - return + return 0, Invalid("entry.bad_pid", "Ungültige pid").WithCause(err) + } + return pid, nil +} + +// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den +// direkten Antworten (öffentlich). +func handleThread(w http.ResponseWriter, r *http.Request) error { + pid, err := pidParam(r) + if err != nil { + return err } + t, err := threadData(pid) + if err != nil { + return err + } + + writeJSON(w, http.StatusOK, map[string]any{ + "entry": t.Entry, + "ancestors": t.Ancestors, + "replies": t.Replies, + }) + return nil +} + +// thread ist ein Beitrag mit seinem Kontext: Ahnenkette (Root zuerst) und +// direkte Antworten. +type thread struct { + Entry Entry + Ancestors []Entry + 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. +func threadData(pid int64) (thread, error) { entry, err := entryByPID(pid) if err != nil { - writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") - return + return thread{}, errEntryNotFound } + t := thread{Entry: entry, Ancestors: []Entry{}, Replies: []Entry{}} - // Ahnenkette von Eltern bis Root hochlaufen, dann umdrehen (Root zuerst). - ancestors := []Entry{} - for cur := entry.ReplyTo; cur != 0 && len(ancestors) < 50; { - a, err := entryByPID(cur) + // Ahnenkette (Root zuerst) per rekursiver CTE in einem Query statt bis zu + // 50 Einzelqueries. depth zählt vom Beitrag aufwärts; ORDER BY depth DESC + // dreht die Kette auf Root-zuerst. Das Limit deckelt die Kettenlänge und + // bricht auch einen (theoretischen) Zyklus ab. + if entry.ReplyTo != 0 { + rows, err := db.Query( + `WITH RECURSIVE chain(pid, depth) AS ( + SELECT ?, 1 + UNION ALL + SELECT e.reply_to, c.depth + 1 + FROM entry e JOIN chain c ON e.pid = c.pid + WHERE e.reply_to != 0 AND c.depth < 50 + )`+entrySelect+` + JOIN chain c ON c.pid = e.pid + ORDER BY c.depth DESC`, entry.ReplyTo) if err != nil { - break + return thread{}, Internal(err) + } + if t.Ancestors, err = scanEntries(rows); err != nil { + return thread{}, Internal(err) } - ancestors = append(ancestors, a) - cur = a.ReplyTo - } - for i, j := 0, len(ancestors)-1; i < j; i, j = i+1, j-1 { - ancestors[i], ancestors[j] = ancestors[j], ancestors[i] } rows, err := db.Query(entrySelect+` WHERE e.reply_to = ? ORDER BY e.last_activity DESC LIMIT 100`, pid) if err != nil { - writeError(w, http.StatusInternalServerError, "Datenbankfehler") - return + return thread{}, Internal(err) } - replies, err := scanEntries(rows) - if err != nil { - writeError(w, http.StatusInternalServerError, "Datenbankfehler") - return + if t.Replies, err = scanEntries(rows); err != nil { + return thread{}, Internal(err) } - - writeJSON(w, http.StatusOK, map[string]any{ - "entry": entry, - "ancestors": ancestors, - "replies": replies, - }) + return t, nil } -func handleCreateEntry(w http.ResponseWriter, r *http.Request) { +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 // Temp-Dateien auf die Platte. r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) - if err := r.ParseMultipartForm(16 << 20); err != nil && err != http.ErrNotMultipart { - writeError(w, http.StatusBadRequest, "Ungültige Anfrage") - return - } - - content := strings.TrimSpace(r.FormValue("content")) - if len(content) > 1000 { - content = content[:1000] + 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) } + content := truncateRunes(strings.TrimSpace(r.FormValue("content")), 1000) + 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 { - writeError(w, http.StatusBadRequest, "Elternbeitrag nicht gefunden") - return + return fail(Invalid("entry.parent_missing", "Elternbeitrag nicht gefunden").At("reply_to")) } } filepath := "" - if r.MultipartForm != nil { - if files := r.MultipartForm.File["file"]; len(files) > 0 { - if stored, err := storeImage(files[0]); err == nil { - filepath = stored - } + if hadFile { + stored, err := storeImage(r.MultipartForm.File["file"][0]) + 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)) } + filepath = stored } if filepath == "" && content == "" { - writeError(w, http.StatusBadRequest, "Leerer Beitrag!") - return + return fail(Invalid("entry.empty", "Leerer Beitrag!").At("content")) } - pid := int64(rand.IntN(999999999999)) now := time.Now().Unix() tx, err := db.Begin() if err != nil { - writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden") - return + return Internal(err) } - if _, err := tx.Exec( - `INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity) - VALUES (?, ?, ?, ?, ?, ?, 0, ?)`, - pid, uid, now, content, filepath, replyTo, now, - ); err != nil { - tx.Rollback() - writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden") - return + // pid würfeln; +1, weil 0 der Sentinel für "kein Elternbeitrag"/"gelöschter + // Autor" ist. Bei der seltenen UNIQUE-Kollision neu würfeln statt 500. + var pid int64 + for try := 0; ; try++ { + pid = int64(rand.IntN(999999999999)) + 1 + _, err := tx.Exec( + `INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity) + VALUES (?, ?, ?, ?, ?, ?, 0, ?)`, + pid, uid, now, content, filepath, replyTo, now, + ) + if err == nil { + break + } + if try >= 4 || !strings.Contains(err.Error(), "UNIQUE") { + tx.Rollback() + return Internal(err) + } } - // Antwort: reply_count und last_activity aller Vorfahren bis zum Root anheben. - for cur, i := replyTo, 0; cur != 0 && i < 50; i++ { + // Antwort: reply_count und last_activity aller Vorfahren bis zum Root + // anheben -- per rekursiver CTE in einem Statement statt bis zu 50 + // Einzelqueries (hält die Transaktion kurz). depth deckelt die Kette und + // bricht auch einen (theoretischen) Zyklus ab. + if replyTo != 0 { if _, err := tx.Exec( - `UPDATE entry SET reply_count = reply_count + 1, - last_activity = MAX(COALESCE(last_activity, 0), ?) WHERE pid = ?`, - now, cur, + `WITH RECURSIVE chain(pid, depth) AS ( + SELECT ?, 0 + UNION ALL + SELECT e.reply_to, c.depth + 1 + FROM entry e JOIN chain c ON e.pid = c.pid + WHERE e.reply_to != 0 AND c.depth < 50 + ) + UPDATE entry SET reply_count = reply_count + 1, + last_activity = MAX(last_activity, ?) + WHERE pid IN (SELECT pid FROM chain)`, + replyTo, now, ); err != nil { tx.Rollback() - writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden") - return - } - if err := tx.QueryRow(`SELECT reply_to FROM entry WHERE pid = ?`, cur).Scan(&cur); err != nil { - break + return Internal(err) } } if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden") - return + 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{ @@ -273,77 +378,80 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) { "filepath": filepath, "reply_to": replyTo, }) + return nil } // handleEditEntry ändert den Inhalt eines eigenen Beitrags. Das angehängte Bild // 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) { - pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) +func handleEditEntry(w http.ResponseWriter, r *http.Request) error { + json := wantsJSON(r) + + pid, err := pidParam(r) if err != nil { - writeError(w, http.StatusBadRequest, "Ungültige pid") - return + return err } - uid := uidFromContext(r.Context()) - - var owner int64 - if err := db.QueryRow(`SELECT uid FROM entry WHERE pid = ?`, pid).Scan(&owner); err != nil { - writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") - return - } - if owner != uid { - writeError(w, http.StatusForbidden, "Das ist nicht dein Beitrag.") - return + // 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 := strings.TrimSpace(r.FormValue("content")) + content := truncateRunes(strings.TrimSpace(r.FormValue("content")), 1000) if content == "" { - writeError(w, http.StatusBadRequest, "Inhalt darf nicht leer sein.") - return - } - if len(content) > 1000 { - content = content[:1000] + e := Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content") + if json { + return e + } + return failEdit(w, r, pid, content, e) } if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil { - writeError(w, http.StatusInternalServerError, "Bearbeiten fehlgeschlagen") - return + 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 } // handleDeleteEntry löscht einen eigenen Beitrag "weich": die Zeile bleibt als // [deleted]-Platzhalter erhalten (damit Antworten nicht verwaisen), Inhalt, Bild // und Autor werden entfernt. -func handleDeleteEntry(w http.ResponseWriter, r *http.Request) { - pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) +func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error { + pid, err := pidParam(r) if err != nil { - writeError(w, http.StatusBadRequest, "Ungültige pid") - return + return err } - uid := uidFromContext(r.Context()) - - var owner int64 - var filepath string - if err := db.QueryRow(`SELECT uid, filepath FROM entry WHERE pid = ?`, pid).Scan(&owner, &filepath); err != nil { - writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") - return - } - if owner != uid { - writeError(w, http.StatusForbidden, "Das ist nicht dein Beitrag.") - return + // Dieselbe Prüfung wie auf der Bestätigungsseite; entryByPID liefert dabei + // auch den Bildpfad, der nach dem UPDATE nicht mehr in der Zeile steht. + e, err := ownEntry(r, pid) + if err != nil { + return err } if _, err := db.Exec( `UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = ?`, pid, ); err != nil { - writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen") - return + return Internal(err) } - if filepath != "" { - os.Remove(filepath) + if e.Filepath != "" { + 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 } // bumpStep ist der Cooldown-Zuwachs je bereits erfolgtem Bump: nach dem 1. Bump @@ -357,46 +465,53 @@ const bumpStep = 24 * 60 * 60 // Spam wächst der Cooldown linear mit der Zahl bisheriger Bumps (siehe bumpStep); // er sitzt am Beitrag selbst (global), nicht am bumpenden Nutzer. Jeder // Eingeloggte darf bumpen (Route in der requireAuth-Gruppe). -func handleBump(w http.ResponseWriter, r *http.Request) { - pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) +func handleBump(w http.ResponseWriter, r *http.Request) error { + pid, err := pidParam(r) if err != nil { - writeError(w, http.StatusBadRequest, "Ungültige pid") - return + return err + } + + // Cooldown = bisherige Bumps in Tagen. Beim ersten Bump (bump_count 0) ist + // die Sperre 0 -> sofort erlaubt; danach wächst sie mit jedem Bump. Die + // Bedingung sitzt direkt im UPDATE (statt Read-then-Write), damit zwei + // gleichzeitige Bumps den Cooldown nicht umgehen können. + now := time.Now().Unix() + res, err := db.Exec( + `UPDATE entry SET last_activity = ?, bump_count = bump_count + 1, last_bump = ? + WHERE pid = ? AND deleted = 0 AND last_bump + bump_count * ? <= ?`, + now, now, pid, bumpStep, now, + ) + if err != nil { + return Internal(err) } var bumpCount, lastBump, deleted int64 if err := db.QueryRow(`SELECT bump_count, last_bump, deleted FROM entry WHERE pid = ?`, pid). - Scan(&bumpCount, &lastBump, &deleted); err != nil { - writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") - return - } - if deleted != 0 { - writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") - return + Scan(&bumpCount, &lastBump, &deleted); err != nil || deleted != 0 { + return errEntryNotFound } - now := time.Now().Unix() - // Cooldown = bisherige Bumps in Tagen. Beim ersten Bump (bumpCount 0) ist die - // Sperre 0 -> sofort erlaubt; danach wächst sie mit jedem Bump. - if remaining := lastBump + bumpCount*bumpStep - now; remaining > 0 { + if n, _ := res.RowsAffected(); n == 0 { + remaining := lastBump + bumpCount*bumpStep - now + if remaining < 0 { + remaining = 0 + } + // Bewusst kein Taxonomie-Fehler: die Antwort trägt Nutzdaten + // (retry_after, bump_count), die das Frontend zum Sperren des Buttons + // braucht -- ein reiner Fehlerkörper kann die nicht transportieren. + // Der Cooldown ist auch kein Fehlerfall, sondern ein regulärer + // Ausgang des Bumpens. w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10)) writeJSON(w, http.StatusTooManyRequests, map[string]any{ - "error": "Dieser Beitrag wurde erst kürzlich repostet.", + "error": apiErrorBody{ + Code: "entry.bump_cooldown", + Message: "Dieser Beitrag wurde erst kürzlich repostet.", + }, "retry_after": remaining, "bump_count": bumpCount, }) - return + return nil } - - if _, err := db.Exec( - `UPDATE entry SET last_activity = ?, bump_count = bump_count + 1, last_bump = ? WHERE pid = ?`, - now, now, pid, - ); err != nil { - writeError(w, http.StatusInternalServerError, "Bumpen fehlgeschlagen") - return - } - - bumpCount++ writeJSON(w, http.StatusOK, map[string]any{ "pid": pid, "bump_count": bumpCount, @@ -404,6 +519,7 @@ func handleBump(w http.ResponseWriter, r *http.Request) { "last_activity": now, "retry_after": bumpCount * bumpStep, // bis zum nächsten erlaubten Bump }) + return nil } // voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag. @@ -416,11 +532,106 @@ func voteTally(pid, uid int64) (left, right int, selected string) { selected = cur } } - db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'left'`, pid).Scan(&left) - db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'right'`, pid).Scan(&right) + // Beide Zähler in einem Query statt zwei COUNTs. + rows, err := db.Query(`SELECT mode, COUNT(*) FROM vote WHERE pid = ? GROUP BY mode`, pid) + if err != nil { + log.Printf("voteTally pid=%d: %v", pid, err) + return + } + defer rows.Close() + for rows.Next() { + var mode string + var n int + if err := rows.Scan(&mode, &n); err != nil { + log.Printf("voteTally pid=%d: %v", pid, err) + return + } + switch mode { + case "left": + left = n + case "right": + right = n + } + } return } +// tally ist der Abstimmungsstand eines Beitrags aus Sicht eines Betrachters. +type tallyState struct { + Left int + Right int + Selected string // "left", "right" oder "none" +} + +// voteTallies holt die Stände für eine ganze Feed-Seite in zwei Abfragen statt +// in zwei pro Beitrag. Bei 20 Beiträgen wären das sonst 40 Roundtrips für eine +// einzige Seitenansicht. +func voteTallies(pids []int64, uid int64) (map[int64]tallyState, error) { + out := make(map[int64]tallyState, len(pids)) + for _, pid := range pids { + out[pid] = tallyState{Selected: "none"} + } + if len(pids) == 0 { + return out, nil + } + + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(pids)), ",") + args := make([]any, 0, len(pids)+1) + for _, pid := range pids { + args = append(args, pid) + } + + rows, err := db.Query( + `SELECT pid, mode, COUNT(*) FROM vote WHERE pid IN (`+placeholders+`) GROUP BY pid, mode`, args...) + if err != nil { + return nil, err + } + for rows.Next() { + var pid int64 + var mode string + var n int + if err := rows.Scan(&pid, &mode, &n); err != nil { + rows.Close() + return nil, err + } + t := out[pid] + switch mode { + case "left": + t.Left = n + case "right": + t.Right = n + } + out[pid] = t + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + // Eigene Stimmen nur, wenn überhaupt jemand angemeldet ist. + if uid == 0 { + return out, nil + } + rows, err = db.Query( + `SELECT pid, mode FROM vote WHERE uid = ? AND pid IN (`+placeholders+`)`, + append([]any{uid}, args...)...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var pid int64 + var mode string + if err := rows.Scan(&pid, &mode); err != nil { + return nil, err + } + t := out[pid] + t.Selected = mode + out[pid] = t + } + return out, rows.Err() +} + func writeTally(w http.ResponseWriter, pid, uid int64) { left, right, selected := voteTally(pid, uid) writeJSON(w, http.StatusOK, map[string]any{ @@ -432,11 +643,10 @@ func writeTally(w http.ResponseWriter, pid, uid int64) { } // handleVotes liest den Abstimmungsstand eines Beitrags (read-only, öffentlich). -func handleVotes(w http.ResponseWriter, r *http.Request) { - pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) +func handleVotes(w http.ResponseWriter, r *http.Request) error { + pid, err := pidParam(r) if err != nil { - writeError(w, http.StatusBadRequest, "Ungültige pid") - return + return err } var uid int64 @@ -444,54 +654,120 @@ func handleVotes(w http.ResponseWriter, r *http.Request) { uid = s.UID } writeTally(w, pid, uid) + return nil +} + +// handleReport nimmt eine Meldung zu einem Beitrag entgegen (DSA-Melde- +// verfahren). Bewusst ohne Auth -- jeder darf melden, weil es eine +// Moderationshilfe ist. Die Melder-uid wird vorerst nicht gespeichert (immer +// 0); nur der optionale Grund landet in der DB. Die Route ist per IP +// rate-limited (siehe main.go), damit der offene Endpunkt kein Spam-Kanal wird. +func handleReport(w http.ResponseWriter, r *http.Request) error { + pid, err := pidParam(r) + if err != nil { + return err + } + // Nur existierende Beiträge melden, sonst entstehen Waisen-Meldungen. + if _, err := entryByPID(pid); err != nil { + return errEntryNotFound + } + reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500) + if _, err := db.Exec( + `INSERT INTO report (pid, uid, reason, created_at, handled) VALUES (?, 0, ?, ?, 0)`, + pid, reason, time.Now().Unix(), + ); err != nil { + 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) -// - andere Stimme -> auf den neuen Modus wechseln -func handleVote(w http.ResponseWriter, r *http.Request) { - pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) +// - gleiche Stimme erneut -> Stimme zurückziehen (Toggle) +// - andere Stimme -> auf den neuen Modus wechseln +func handleVote(w http.ResponseWriter, r *http.Request) error { + pid, err := pidParam(r) if err != nil { - writeError(w, http.StatusBadRequest, "Ungültige pid") - return + return err } uid := uidFromContext(r.Context()) mode := r.FormValue("mode") if mode != "left" && mode != "right" { - writeError(w, http.StatusBadRequest, "Ungültiger Modus (left oder right)") - return + return Invalid("vote.bad_mode", "Ungültiger Modus (left oder right)").At("mode") } // Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes. if _, err := entryByPID(pid); err != nil { - writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") - return + return errEntryNotFound } - var prev string - readErr := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&prev) - if readErr != nil && readErr != sql.ErrNoRows { - writeError(w, http.StatusInternalServerError, "Datenbankfehler") - return + // Toggle-Logik in zwei jeweils atomaren Statements statt Read-then-Write + // (parallele Requests konnten den Toggle sonst doppelt anwenden): + // 1. Gleiche Stimme löschen -> hat das getroffen, war es ein Toggle-off. + // 2. Sonst Upsert: neue Stimme anlegen bzw. bestehende ummodeln. + res, err := db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ? AND mode = ?`, uid, pid, mode) + if err != nil { + return Internal(err) + } + if n, _ := res.RowsAffected(); n == 0 { + if _, err := db.Exec( + `INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?) + ON CONFLICT(uid, pid) DO UPDATE SET mode = excluded.mode`, + uid, pid, mode, + ); err != nil { + return Internal(err) + } } - var execErr error - switch { - case readErr == sql.ErrNoRows: - _, execErr = db.Exec(`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)`, uid, pid, mode) - case prev == mode: - _, execErr = db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ?`, uid, pid) - default: - _, execErr = db.Exec(`UPDATE vote SET mode = ? WHERE uid = ? AND pid = ?`, mode, uid, pid) + // 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 } - if execErr != nil { - writeError(w, http.StatusInternalServerError, "Datenbankfehler") - return - } - writeTally(w, pid, uid) + return nil } const ( @@ -506,6 +782,18 @@ const ( maxUploadBytes = 17 << 20 ) +// errUploadTooLargeOrBroken unterscheidet die beiden Gründe, an denen +// ParseMultipartForm scheitern kann: das Limit von MaxBytesReader (der Upload +// war zu groß -> 413) oder kaputtes Multipart (-> 400). Vorher endete beides +// als 400, was dem Nutzer nicht sagte, dass die Datei schlicht zu groß war. +func errUploadTooLargeOrBroken(err error) error { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + return TooLarge("upload.too_large", "Die Datei ist zu groß.").WithCause(err) + } + return Invalid("request.malformed", "Ungültige Anfrage").WithCause(err) +} + // storeImage speichert ein hochgeladenes Bild unter static/media. // Standbilder (JPEG/PNG/Einzelbild-GIF) werden auf max. 1024px skaliert und // dabei durch Decode+Re-Encode sanitisiert. Animierte GIFs würden durch das diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..7941bfb --- /dev/null +++ b/errors.go @@ -0,0 +1,173 @@ +package main + +import ( + "errors" + "log" + "net/http" +) + +// Fehler-Taxonomie: trennt die drei Informationen, die früher an jeder +// Fehler-Aufrufstelle verklebt waren -- wie schwer der Fehler ist (Kind), +// was genau passiert ist (Code) und was der Nutzer lesen soll (Msg). +// +// Der Sinn der Trennung: derselbe Fehler muss künftig von zwei Adaptern +// unterschiedlich dargestellt werden. Der HTML-Pfad braucht Msg und Field, um +// ein Formular mit Fehlermarkierung neu zu rendern; ein API-Client braucht +// Code, um ohne Parsen deutscher Strings reagieren zu können. + +type Kind uint8 + +const ( + KindInternal Kind = iota // Bug oder Infrastruktur -- Ursache nie nach außen + KindInvalid // Eingabe passt nicht + KindUnauth // nicht angemeldet + KindForbidden // angemeldet, aber nicht erlaubt + KindNotFound // Objekt existiert nicht (oder soll nicht sichtbar sein) + KindConflict // Name vergeben, Doppel-Post + KindTooLarge // Upload über dem Limit + KindRateLimited // zu viele Anfragen + KindUnavailable // Feature nicht konfiguriert +) + +// Error ist der Fehlertyp, den die Service-Schicht nach oben gibt. Kind ist +// bewusst kein HTTP-Status: die Zuordnung passiert erst im Adapter, damit ein +// künftiger CLI- oder App-Client dieselben Fehler auf eigene Weise abbilden +// kann (Exit-Code, Dialog, Retry). +type Error struct { + Kind Kind + Code string // stabil und maschinenlesbar, Schema "." + Msg string // deutsche Nutzermeldung + Field string // optional: betroffenes Formularfeld + Fields map[string]string // optional: mehrere Feldfehler auf einmal + cause error // nur fürs Log, nie für den Nutzer +} + +func (e *Error) Error() string { return e.Code + ": " + e.Msg } + +// Unwrap gibt die eingepackte Ursache frei, damit errors.Is/As über den +// Taxonomie-Fehler hinweg bis zum ursprünglichen Fehler (etwa sql.ErrNoRows) +// durchsuchen kann. +func (e *Error) Unwrap() error { return e.cause } + +func Invalid(code, msg string) *Error { return &Error{Kind: KindInvalid, Code: code, Msg: msg} } +func Unauth(code, msg string) *Error { return &Error{Kind: KindUnauth, Code: code, Msg: msg} } +func Forbidden(code, msg string) *Error { return &Error{Kind: KindForbidden, Code: code, Msg: msg} } +func NotFound(code, msg string) *Error { return &Error{Kind: KindNotFound, Code: code, Msg: msg} } +func Conflict(code, msg string) *Error { return &Error{Kind: KindConflict, Code: code, Msg: msg} } +func TooLarge(code, msg string) *Error { return &Error{Kind: KindTooLarge, Code: code, Msg: msg} } +func RateLimited(code, msg string) *Error { return &Error{Kind: KindRateLimited, Code: code, Msg: msg} } +func Unavailable(code, msg string) *Error { return &Error{Kind: KindUnavailable, Code: code, Msg: msg} } + +// Internal verschluckt die Ursache absichtlich: der Nutzer sieht nie einen +// DB- oder Dateisystemfehler (der Interna und Pfade verraten würde), das Log +// sieht ihn immer. Deshalb hat Internal auch keine freie Msg -- eine +// nutzerseitige Unterscheidung zwischen "Datenbankfehler" und "Speichern +// fehlgeschlagen" ist bedeutungslos. +func Internal(cause error) *Error { + return &Error{ + Kind: KindInternal, + Code: "internal", + Msg: "Es ist ein Fehler aufgetreten.", + cause: cause, + } +} + +// At markiert das Formularfeld, an dem der Fehler hängt. Verkettbar, damit die +// Aufrufstelle einzeilig bleibt: return Invalid(...).At("content") +// +// Achtung: At und WithCause verändern den Empfänger. Auf einen frisch +// konstruierten Fehler anwenden, NICHT auf ein geteiltes Sentinel wie +// errEntryNotFound -- das würde den globalen Wert für alle Requests umschreiben. +func (e *Error) At(field string) *Error { + e.Field = field + return e +} + +// WithCause hängt eine technische Ursache an einen fachlichen Fehler. Nützlich, +// wenn ein DB-Fehler fachlich eindeutig ist (UNIQUE-Verletzung -> Conflict), +// die Ursache aber trotzdem ins Log soll. +func (e *Error) WithCause(cause error) *Error { + e.cause = cause + return e +} + +// asError normalisiert alles, was kein *Error ist, zu einem Internal. Damit +// müssen die Adapter keinen Sonderfall für "nackte" Fehler kennen, die aus +// Bibliotheken oder noch nicht umgestellten Stellen kommen. +func asError(err error) *Error { + var e *Error + if errors.As(err, &e) { + return e + } + return Internal(err) +} + +// statusOf bildet Kind auf HTTP ab. Beide HTTP-Adapter (HTML und JSON) teilen +// sich diese Zuordnung -- unterschiedliche Statuscodes für denselben Fehler +// wären genau die Divergenz, die die Taxonomie verhindern soll. +func statusOf(k Kind) int { + switch k { + case KindInvalid: + return http.StatusBadRequest + case KindUnauth: + return http.StatusUnauthorized + case KindForbidden: + return http.StatusForbidden + case KindNotFound: + return http.StatusNotFound + case KindConflict: + return http.StatusConflict + case KindTooLarge: + return http.StatusRequestEntityTooLarge + case KindRateLimited: + return http.StatusTooManyRequests + case KindUnavailable: + return http.StatusServiceUnavailable + default: + return http.StatusInternalServerError + } +} + +// logInternal protokolliert nur echte Serverfehler -- Validierungs- und +// Rechtefehler sind normaler Betrieb und würden das Log zumüllen. Beide +// Adapter rufen das auf, damit ein Fehler unabhängig vom Ausgabeformat +// genau einmal im Log landet. +func logInternal(e *Error) { + if e.Kind != KindInternal { + return + } + // Die Ursache ist das Einzige, was hier wirklich interessiert; fehlt sie, + // bleibt wenigstens die Meldung. + if e.cause != nil { + log.Printf("internal: %v", e.cause) + } else { + log.Printf("internal: %s", e.Msg) + } +} + +// apiErrorBody ist das Drahtformat des JSON-Adapters. Eigener Typ statt einer +// Map, damit die Feldnamen an genau einer Stelle stehen -- sie sind Teil des +// API-Vertrags, sobald ein externer Client existiert. +type apiErrorBody struct { + Code string `json:"code"` + Message string `json:"message"` + Field string `json:"field,omitempty"` + Fields map[string]string `json:"fields,omitempty"` +} + +// writeAPIError ist der einzige Ausgang für Fehler im JSON-Adapter. Hier -- an +// der Adaptergrenze -- wird auch geloggt, und zwar nur einmal: die +// Service-Schicht loggt nicht, sonst steht derselbe Fehler mehrfach im Log. +func writeAPIError(w http.ResponseWriter, err error) { + e := asError(err) + logInternal(e) + + writeJSON(w, statusOf(e.Kind), map[string]any{ + "error": apiErrorBody{ + Code: e.Code, + Message: e.Msg, + Field: e.Field, + Fields: e.Fields, + }, + }) +} diff --git a/feedview.go b/feedview.go new file mode 100644 index 0000000..3838e66 --- /dev/null +++ b/feedview.go @@ -0,0 +1,499 @@ +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 +} + +// 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 + + // 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) + } + + v := feedView{ + Entries: toEntryViews(entries, tallies, returnTo(r), viewer), + Page: page, + Pages: pages, + IsTOR: isTOR(r), + } + 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 +} + +// 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), + } + // 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