Melden rechts in der Byline jeder Karte, auch anonym
- Melden-Knopf in der Byline aller Beiträge (außer eigenen), Grund-Formular
darunter; ersetzt den Knopf unter dem fokussierten Beitrag
- /api/entry/{pid}/report öffentlich, 10/min pro IP; ohne Session anonym
(uid 0), Doppelmeldungen nur für Angemeldete unterdrückt
- Klicks in Formularen öffnen die Karte nicht mehr
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
80c333cd58
commit
3d2447fe35
+8
-6
@@ -568,9 +568,6 @@ func TestReport(t *testing.T) {
|
|||||||
return n
|
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 {
|
if got := report(bob, pid, " Spam "); got != http.StatusOK {
|
||||||
t.Fatalf("report: erwartet 200, bekam %d", got)
|
t.Fatalf("report: erwartet 200, bekam %d", got)
|
||||||
}
|
}
|
||||||
@@ -580,11 +577,16 @@ func TestReport(t *testing.T) {
|
|||||||
t.Fatalf("grund: %q", reason)
|
t.Fatalf("grund: %q", reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erneut melden legt keine zweite offene Meldung an, ein anderer Nutzer schon.
|
// Erneut melden legt keine zweite offene Meldung an, ein anderer Nutzer
|
||||||
|
// schon. Anonyme Meldungen (uid 0) sind nicht unterscheidbar und zählen alle.
|
||||||
report(bob, pid, "")
|
report(bob, pid, "")
|
||||||
report(alice, pid, "")
|
report(alice, pid, "")
|
||||||
if n := openReports(); n != 2 {
|
report(newClient(t), pid, "")
|
||||||
t.Fatalf("offene meldungen: erwartet 2, bekam %d", n)
|
if got := report(newClient(t), pid, ""); got != http.StatusOK {
|
||||||
|
t.Fatalf("anon report: erwartet 200, bekam %d", got)
|
||||||
|
}
|
||||||
|
if n := openReports(); n != 4 {
|
||||||
|
t.Fatalf("offene meldungen: erwartet 4, bekam %d", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := report(bob, 999999, ""); got != http.StatusNotFound {
|
if got := report(bob, 999999, ""); got != http.StatusNotFound {
|
||||||
|
|||||||
@@ -441,8 +441,9 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleReport meldet einen Beitrag, optional mit Grund. Wer denselben Beitrag
|
// handleReport meldet einen Beitrag, optional mit Grund. Melden darf jeder;
|
||||||
// erneut meldet, solange die Meldung offen ist, legt keine zweite an.
|
// ohne Session ist der Melder anonym (uid 0). Angemeldete legen für denselben
|
||||||
|
// Beitrag keine zweite Meldung an, solange ihre erste offen ist.
|
||||||
func handleReport(w http.ResponseWriter, r *http.Request) error {
|
func handleReport(w http.ResponseWriter, r *http.Request) error {
|
||||||
pid, err := pidParam(r)
|
pid, err := pidParam(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -453,12 +454,16 @@ func handleReport(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return errEntryNotFound
|
return errEntryNotFound
|
||||||
}
|
}
|
||||||
reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
|
reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
|
||||||
|
var uid int64
|
||||||
|
if s, ok := getSession(r); ok {
|
||||||
|
uid = s.UID
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := db.Exec(
|
if _, err := db.Exec(
|
||||||
`INSERT INTO report (pid, uid, reason, created_at)
|
`INSERT INTO report (pid, uid, reason, created_at)
|
||||||
SELECT $1, $2, $3, $4
|
SELECT $1, $2, $3, $4
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM report WHERE pid = $1 AND uid = $2 AND handled = 0)`,
|
WHERE $2::bigint = 0 OR NOT EXISTS (SELECT 1 FROM report WHERE pid = $1 AND uid = $2 AND handled = 0)`,
|
||||||
pid, uidFromContext(r.Context()), reason, time.Now().Unix(),
|
pid, uid, reason, time.Now().Unix(),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return Internal(err)
|
return Internal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,9 @@ img.avatar-lg { width: 72px; height: 72px; }
|
|||||||
auf 1rem liegt). Hier explizit angleichen -> ganze Meta-Zeile gleich groß. */
|
auf 1rem liegt). Hier explizit angleichen -> ganze Meta-Zeile gleich groß. */
|
||||||
.byline span { font-size: 1rem; }
|
.byline span { font-size: 1rem; }
|
||||||
|
|
||||||
|
/* Melden steht rechts in der Byline. */
|
||||||
|
.byline .report { margin-left: auto; }
|
||||||
|
|
||||||
/* Profilkopf: großer Avatar links neben Name und "Mitglied seit". */
|
/* Profilkopf: großer Avatar links neben Name und "Mitglied seit". */
|
||||||
.profile-head {
|
.profile-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import type { Entry } from "../types";
|
|||||||
import { avatarUrl, formatTime, linkify } from "../format";
|
import { avatarUrl, formatTime, linkify } from "../format";
|
||||||
import { Votes } from "./Votes";
|
import { Votes } from "./Votes";
|
||||||
import { useBump } from "./Bump";
|
import { useBump } from "./Bump";
|
||||||
|
import { useReport } from "./Report";
|
||||||
|
import { useMe } from "../auth";
|
||||||
|
|
||||||
// Klick auf die Karte öffnet den Beitrag -- außer der Klick galt einem Link
|
// Klick auf die Karte öffnet den Beitrag -- außer der Klick galt einem Link,
|
||||||
// oder Knopf darin.
|
// Knopf oder Formular darin.
|
||||||
function useOpenOnClick(pid: number) {
|
function useOpenOnClick(pid: number) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (ev: MouseEvent) => {
|
return (ev: MouseEvent) => {
|
||||||
if (!(ev.target as Element).closest("a, button")) navigate(`/e/${pid}`);
|
if (!(ev.target as Element).closest("a, button, form")) navigate(`/e/${pid}`);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +39,8 @@ function DeletedCard({ entry: e }: { entry: Entry }) {
|
|||||||
function LiveCard({ entry: e }: { entry: Entry }) {
|
function LiveCard({ entry: e }: { entry: Entry }) {
|
||||||
const open = useOpenOnClick(e.pid);
|
const open = useOpenOnClick(e.pid);
|
||||||
const bump = useBump(e);
|
const bump = useBump(e);
|
||||||
|
const report = useReport(e.pid);
|
||||||
|
const own = useMe().data?.uid === e.uid;
|
||||||
const profile = `/u/${encodeURIComponent(e.username)}`;
|
const profile = `/u/${encodeURIComponent(e.username)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,10 +53,12 @@ function LiveCard({ entry: e }: { entry: Entry }) {
|
|||||||
<Link to={profile}>{e.username}</Link> · {formatTime(e.created_at)}
|
<Link to={profile}>{e.username}</Link> · {formatTime(e.created_at)}
|
||||||
<span className="cooldown">{bump.cooldown}</span>
|
<span className="cooldown">{bump.cooldown}</span>
|
||||||
</span>
|
</span>
|
||||||
|
{!own && report.button}
|
||||||
</div>
|
</div>
|
||||||
<div className="threadline-wrapper">
|
<div className="threadline-wrapper">
|
||||||
<div className="threadline"></div>
|
<div className="threadline"></div>
|
||||||
<div className="body">
|
<div className="body">
|
||||||
|
{report.form}
|
||||||
<div className="content">{linkify(e.content)}</div>
|
<div className="content">{linkify(e.content)}</div>
|
||||||
{e.filepath && <img src={`/${e.filepath}`} alt="" loading="lazy" />}
|
{e.filepath && <img src={`/${e.filepath}`} alt="" loading="lazy" />}
|
||||||
<div className="interactions">
|
<div className="interactions">
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { api, errMsg } from "../api";
|
||||||
|
|
||||||
|
// useReport hält das Melden eines Beitrags: der Knopf steht rechts in der
|
||||||
|
// Byline, das Formular für den optionalen Grund darunter im Beitrag --
|
||||||
|
// deshalb ein Hook statt einer Komponente (wie useBump). Melden geht auch
|
||||||
|
// ohne Anmeldung, der Melder ist dann anonym.
|
||||||
|
export function useReport(pid: number) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const report = useMutation({
|
||||||
|
mutationFn: (fd: FormData) => api.post(`/entry/${pid}/report`, fd),
|
||||||
|
onSuccess: () => setOpen(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
report.mutate(new FormData(e.currentTarget));
|
||||||
|
};
|
||||||
|
const cancel = () => {
|
||||||
|
report.reset();
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
button: report.isSuccess
|
||||||
|
? <span className="report">gemeldet</span>
|
||||||
|
: <button className="report" type="button" disabled={open} onClick={() => setOpen(true)}>Melden</button>,
|
||||||
|
form: 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>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,8 +38,7 @@ function ThreadView({ pid }: { pid: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = thread.data;
|
const data = thread.data;
|
||||||
const live = !!me && !!data && !data.entry.deleted;
|
const isOwner = !!me && !!data && me.uid === data.entry.uid && !data.entry.deleted;
|
||||||
const isOwner = live && me.uid === data.entry.uid;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -48,9 +47,7 @@ function ThreadView({ pid }: { pid: string }) {
|
|||||||
</section>
|
</section>
|
||||||
<div id="focus">
|
<div id="focus">
|
||||||
{data && <EntryCard entry={data.entry} />}
|
{data && <EntryCard entry={data.entry} />}
|
||||||
{live && (isOwner
|
{data && isOwner && <OwnerActions entry={data.entry} onChange={refresh} />}
|
||||||
? <OwnerActions entry={data.entry} onChange={refresh} />
|
|
||||||
: <ReportAction pid={data.entry.pid} />)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section id="reply-box" hidden={!me}>
|
<section id="reply-box" hidden={!me}>
|
||||||
@@ -119,35 +116,3 @@ 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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ func routes() http.Handler {
|
|||||||
r.Get("/entry/feed/{page}", api(handleFeed))
|
r.Get("/entry/feed/{page}", api(handleFeed))
|
||||||
r.Get("/entry/{pid}/votes", api(handleVotes))
|
r.Get("/entry/{pid}/votes", api(handleVotes))
|
||||||
r.Get("/entry/{pid}/thread", api(handleThread))
|
r.Get("/entry/{pid}/thread", api(handleThread))
|
||||||
|
// Melden darf jeder (anonym ohne Session), gedrosselt pro IP, damit der
|
||||||
|
// offene Endpunkt kein Spam-Kanal wird.
|
||||||
|
r.With(limitByIP(10, time.Minute)).Post("/entry/{pid}/report", api(handleReport))
|
||||||
|
|
||||||
// --- User (öffentlich) ---
|
// --- User (öffentlich) ---
|
||||||
r.Get("/u/{username}/info", api(handleUserPage))
|
r.Get("/u/{username}/info", api(handleUserPage))
|
||||||
@@ -150,7 +153,6 @@ func routes() http.Handler {
|
|||||||
r.Post("/entry/{pid}/bump", api(handleBump))
|
r.Post("/entry/{pid}/bump", api(handleBump))
|
||||||
r.Post("/entry/{pid}/edit", api(handleEditEntry))
|
r.Post("/entry/{pid}/edit", api(handleEditEntry))
|
||||||
r.Post("/entry/{pid}/delete", api(handleDeleteEntry))
|
r.Post("/entry/{pid}/delete", api(handleDeleteEntry))
|
||||||
r.Post("/entry/{pid}/report", api(handleReport))
|
|
||||||
r.Get("/user/info", api(handleUserInfo))
|
r.Get("/user/info", api(handleUserInfo))
|
||||||
r.Post("/user/rename", api(handleUserRename))
|
r.Post("/user/rename", api(handleUserRename))
|
||||||
r.Post("/user/avatar", api(handleSetAvatar))
|
r.Post("/user/avatar", api(handleSetAvatar))
|
||||||
|
|||||||
+1
-1
@@ -86,12 +86,12 @@ Antwort). Ohne passenden Nachfahren fehlt das Feld.
|
|||||||
| GET | `/api/entry/feed/{page}` | – | – | `200 [Entry]` mit `latest`: Threads nach letzter Aktivität, 20 pro Seite, Seite 0–100, `[]` am Ende |
|
| GET | `/api/entry/feed/{page}` | – | – | `200 [Entry]` mit `latest`: Threads nach letzter Aktivität, 20 pro Seite, Seite 0–100, `[]` am Ende |
|
||||||
| GET | `/api/entry/{pid}/thread` | – | – | `200 {"entry", "ancestors": [Entry], "replies": [Entry]}`; `ancestors` Root zuerst, `replies` (max. 100) nach letzter Aktivität, mit `latest` |
|
| GET | `/api/entry/{pid}/thread` | – | – | `200 {"entry", "ancestors": [Entry], "replies": [Entry]}`; `ancestors` Root zuerst, `replies` (max. 100) nach letzter Aktivität, mit `latest` |
|
||||||
| GET | `/api/entry/{pid}/votes` | – | – | `200 {"pid","left","right","selected"}` (`selected`: `left`/`right`/`none`) |
|
| GET | `/api/entry/{pid}/votes` | – | – | `200 {"pid","left","right","selected"}` (`selected`: `left`/`right`/`none`) |
|
||||||
|
| POST | `/api/entry/{pid}/report` | – | `reason` (optional, max. 500) | `200 {"status":"reported"}`. Ohne Session anonym (uid 0); Angemeldete legen keine zweite offene Meldung an; gelöschte Beiträge `404`; 10/min pro IP |
|
||||||
| POST | `/api/entry/create` | ✓ | multipart: `content` (max. 1000), `file` (optional), `reply_to` (optional) | `201 {"pid","content","filepath","reply_to"}` |
|
| POST | `/api/entry/create` | ✓ | multipart: `content` (max. 1000), `file` (optional), `reply_to` (optional) | `201 {"pid","content","filepath","reply_to"}` |
|
||||||
| POST | `/api/entry/{pid}/edit` | ✓ Autor | `content` | `200 {"pid","content"}` |
|
| POST | `/api/entry/{pid}/edit` | ✓ Autor | `content` | `200 {"pid","content"}` |
|
||||||
| POST | `/api/entry/{pid}/delete` | ✓ Autor | – | `200 {"status":"deleted"}` (Soft-Delete, Thread bleibt) |
|
| 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}/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}/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
|
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
|
antwortet der Endpunkt mit `429 entry.bump_cooldown`. Dazu kommen der Header
|
||||||
|
|||||||
Reference in New Issue
Block a user