Melden-Button: Beiträge mit optionalem Grund melden
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2bae608d76
commit
80c333cd58
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }) {
|
||||
</section>
|
||||
<div id="focus">
|
||||
{data && <EntryCard entry={data.entry} />}
|
||||
{data && isOwner && <OwnerActions entry={data.entry} onChange={refresh} />}
|
||||
{live && (isOwner
|
||||
? <OwnerActions entry={data.entry} onChange={refresh} />
|
||||
: <ReportAction pid={data.entry.pid} />)}
|
||||
</div>
|
||||
|
||||
<section id="reply-box" hidden={!me}>
|
||||
@@ -116,3 +119,35 @@ function OwnerActions({ entry, onChange }: { entry: Entry; onChange: () => void
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ReportAction: fremden Beitrag melden, der Grund ist optional.
|
||||
function ReportAction({ pid }: { pid: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const report = useMutation({
|
||||
mutationFn: (fd: FormData) => api.post(`/entry/${pid}/report`, fd),
|
||||
});
|
||||
|
||||
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
report.mutate(new FormData(e.currentTarget));
|
||||
};
|
||||
const cancel = () => {
|
||||
report.reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (report.isSuccess) return <p className="muted">Danke, der Beitrag wurde gemeldet.</p>;
|
||||
return (
|
||||
<>
|
||||
<button disabled={open} onClick={() => setOpen(true)}>Melden</button>
|
||||
{open && (
|
||||
<form onSubmit={onSubmit}>
|
||||
<textarea name="reason" rows={2} maxLength={500} placeholder="Grund (optional)"></textarea>{" "}
|
||||
<button type="submit" disabled={report.isPending}>Melden</button>{" "}
|
||||
<button type="button" onClick={cancel}>Abbrechen</button>{" "}
|
||||
<p className="msg">{report.isError ? errMsg(report.error, "Melden fehlgeschlagen.") : ""}</p>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ func routes() http.Handler {
|
||||
r.Post("/entry/{pid}/bump", api(handleBump))
|
||||
r.Post("/entry/{pid}/edit", api(handleEditEntry))
|
||||
r.Post("/entry/{pid}/delete", api(handleDeleteEntry))
|
||||
r.Post("/entry/{pid}/report", api(handleReport))
|
||||
r.Get("/user/info", api(handleUserInfo))
|
||||
r.Post("/user/rename", api(handleUserRename))
|
||||
r.Post("/user/avatar", api(handleSetAvatar))
|
||||
|
||||
@@ -91,6 +91,7 @@ Antwort). Ohne passenden Nachfahren fehlt das Feld.
|
||||
| POST | `/api/entry/{pid}/delete` | ✓ Autor | – | `200 {"status":"deleted"}` (Soft-Delete, Thread bleibt) |
|
||||
| POST | `/api/entry/{pid}/vote` | ✓ | `mode` = `left`/`right` | `200` wie `votes`. Gleiche Stimme erneut zieht sie zurück |
|
||||
| POST | `/api/entry/{pid}/bump` | ✓ | – | `200 {"pid","bump_count","last_bump","last_activity","retry_after"}` |
|
||||
| POST | `/api/entry/{pid}/report` | ✓ | `reason` (optional, max. 500) | `200 {"status":"reported"}`. Erneutes Melden legt keine zweite offene Meldung an; gelöschte Beiträge `404` |
|
||||
|
||||
Beim Bump wächst der Cooldown mit jedem Repost um einen Tag. Im Cooldown
|
||||
antwortet der Endpunkt mit `429 entry.bump_cooldown`. Dazu kommen der Header
|
||||
|
||||
Reference in New Issue
Block a user