From 80c333cd587387aaf402a72bfa425ab6bd312245 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Tue, 29 Sep 2026 17:53:10 +0200 Subject: [PATCH] =?UTF-8?q?Melden-Button:=20Beitr=C3=A4ge=20mit=20optional?= =?UTF-8?q?em=20Grund=20melden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - report-Tabelle wieder im Schema (kompatibel zur bestehenden auf PROD) - POST /api/entry/{pid}/report, nur angemeldet, speichert Melder-uid; erneutes Melden legt keine zweite offene Meldung an - "Melden" auf der Beitragsseite für fremde, nicht gelöschte Beiträge - Test und API-Doku Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q --- db.go | 12 +++++++++ endpoints_test.go | 46 ++++++++++++++++++++++++++++++++ entry.go | 26 ++++++++++++++++++ frontend/src/pages/EntryPage.tsx | 39 +++++++++++++++++++++++++-- main.go | 1 + notes/api.md | 1 + 6 files changed, 123 insertions(+), 2 deletions(-) diff --git a/db.go b/db.go index f6097d6..8a7259a 100644 --- a/db.go +++ b/db.go @@ -71,6 +71,17 @@ CREATE TABLE IF NOT EXISTS impression ( PRIMARY KEY (day, anon_ip, path) ); +-- Meldungen von Beiträgen. handled bleibt vorerst 0; die Abarbeitung folgt +-- mit der Moderation (notes/todo.md). +CREATE TABLE IF NOT EXISTS report ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + pid BIGINT NOT NULL, + uid BIGINT NOT NULL DEFAULT 0, + reason TEXT NOT NULL DEFAULT '', + created_at BIGINT NOT NULL, + handled BIGINT 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) @@ -80,6 +91,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 ec42032..2b895d5 100644 --- a/endpoints_test.go +++ b/endpoints_test.go @@ -550,6 +550,52 @@ func TestSoftDelete(t *testing.T) { } } +func TestReport(t *testing.T) { + srv := newTestServer(t) + alice := registerAndLogin(t, srv, "alice") + bob := registerAndLogin(t, srv, "bob") + pid := createEntry(t, alice, srv, "strittig") + + report := func(c *http.Client, pid int64, reason string) int { + resp := postForm(t, c, fmt.Sprintf("%s/api/entry/%d/report", srv.URL, pid), url.Values{"reason": {reason}}) + resp.Body.Close() + return resp.StatusCode + } + openReports := func() (n int) { + if err := db.QueryRow(`SELECT count(*) FROM report WHERE pid = $1 AND handled = 0`, pid).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + + if got := report(newClient(t), pid, ""); got != http.StatusUnauthorized { + t.Fatalf("anon report: erwartet 401, bekam %d", got) + } + if got := report(bob, pid, " Spam "); got != http.StatusOK { + t.Fatalf("report: erwartet 200, bekam %d", got) + } + var reason string + db.QueryRow(`SELECT reason FROM report WHERE pid = $1`, pid).Scan(&reason) + if reason != "Spam" { + t.Fatalf("grund: %q", reason) + } + + // Erneut melden legt keine zweite offene Meldung an, ein anderer Nutzer schon. + report(bob, pid, "") + report(alice, pid, "") + if n := openReports(); n != 2 { + t.Fatalf("offene meldungen: erwartet 2, bekam %d", n) + } + + if got := report(bob, 999999, ""); got != http.StatusNotFound { + t.Fatalf("report unbekannt: erwartet 404, bekam %d", got) + } + postForm(t, alice, fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, pid), url.Values{}).Body.Close() + if got := report(bob, pid, ""); got != http.StatusNotFound { + t.Fatalf("report gelöscht: erwartet 404, bekam %d", got) + } +} + func TestRenameUser(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") diff --git a/entry.go b/entry.go index 5a2a16f..da3e38c 100644 --- a/entry.go +++ b/entry.go @@ -441,6 +441,32 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error { return nil } +// handleReport meldet einen Beitrag, optional mit Grund. Wer denselben Beitrag +// erneut meldet, solange die Meldung offen ist, legt keine zweite an. +func handleReport(w http.ResponseWriter, r *http.Request) error { + pid, err := pidParam(r) + if err != nil { + return err + } + e, err := entryByPID(pid) + if err != nil || e.Deleted != 0 { + return errEntryNotFound + } + reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500) + + if _, err := db.Exec( + `INSERT INTO report (pid, uid, reason, created_at) + SELECT $1, $2, $3, $4 + WHERE NOT EXISTS (SELECT 1 FROM report WHERE pid = $1 AND uid = $2 AND handled = 0)`, + pid, uidFromContext(r.Context()), reason, time.Now().Unix(), + ); err != nil { + return Internal(err) + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "reported"}) + return nil +} + // bumpStep ist der Cooldown-Zuwachs je bereits erfolgtem Bump: nach dem 1. Bump // ein Tag Sperre, nach dem 10. Bump zehn Tage. So wird Hochspülen mit jedem Mal // teurer. diff --git a/frontend/src/pages/EntryPage.tsx b/frontend/src/pages/EntryPage.tsx index 93b026b..9b6e876 100644 --- a/frontend/src/pages/EntryPage.tsx +++ b/frontend/src/pages/EntryPage.tsx @@ -38,7 +38,8 @@ function ThreadView({ pid }: { pid: string }) { ); } const data = thread.data; - const isOwner = !!me && !!data && me.uid === data.entry.uid && !data.entry.deleted; + const live = !!me && !!data && !data.entry.deleted; + const isOwner = live && me.uid === data.entry.uid; return ( <> @@ -47,7 +48,9 @@ function ThreadView({ pid }: { pid: string }) {
{data && } - {data && isOwner && } + {live && (isOwner + ? + : )}