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