diff --git a/endpoints_test.go b/endpoints_test.go
index 2b895d5..60e4152 100644
--- a/endpoints_test.go
+++ b/endpoints_test.go
@@ -568,9 +568,6 @@ func TestReport(t *testing.T) {
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)
}
@@ -580,11 +577,16 @@ func TestReport(t *testing.T) {
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(alice, pid, "")
- if n := openReports(); n != 2 {
- t.Fatalf("offene meldungen: erwartet 2, bekam %d", n)
+ report(newClient(t), pid, "")
+ 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 {
diff --git a/entry.go b/entry.go
index da3e38c..123a2b8 100644
--- a/entry.go
+++ b/entry.go
@@ -441,8 +441,9 @@ 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.
+// handleReport meldet einen Beitrag, optional mit Grund. Melden darf jeder;
+// 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 {
pid, err := pidParam(r)
if err != nil {
@@ -453,12 +454,16 @@ func handleReport(w http.ResponseWriter, r *http.Request) error {
return errEntryNotFound
}
reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
+ var uid int64
+ if s, ok := getSession(r); ok {
+ uid = s.UID
+ }
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(),
+ WHERE $2::bigint = 0 OR NOT EXISTS (SELECT 1 FROM report WHERE pid = $1 AND uid = $2 AND handled = 0)`,
+ pid, uid, reason, time.Now().Unix(),
); err != nil {
return Internal(err)
}
diff --git a/frontend/src/app.css b/frontend/src/app.css
index ef11d7a..895802a 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -235,6 +235,9 @@ img.avatar-lg { width: 72px; height: 72px; }
auf 1rem liegt). Hier explizit angleichen -> ganze Meta-Zeile gleich groß. */
.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". */
.profile-head {
display: flex;
diff --git a/frontend/src/components/EntryCard.tsx b/frontend/src/components/EntryCard.tsx
index 29acf20..51451ac 100644
--- a/frontend/src/components/EntryCard.tsx
+++ b/frontend/src/components/EntryCard.tsx
@@ -4,13 +4,15 @@ import type { Entry } from "../types";
import { avatarUrl, formatTime, linkify } from "../format";
import { Votes } from "./Votes";
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
-// oder Knopf darin.
+// Klick auf die Karte öffnet den Beitrag -- außer der Klick galt einem Link,
+// Knopf oder Formular darin.
function useOpenOnClick(pid: number) {
const navigate = useNavigate();
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 }) {
const open = useOpenOnClick(e.pid);
const bump = useBump(e);
+ const report = useReport(e.pid);
+ const own = useMe().data?.uid === e.uid;
const profile = `/u/${encodeURIComponent(e.username)}`;
return (
@@ -49,10 +53,12 @@ function LiveCard({ entry: e }: { entry: Entry }) {
{e.username} · {formatTime(e.created_at)}
{bump.cooldown}
+ {!own && report.button}
+ {report.form}
{linkify(e.content)}
{e.filepath &&

}
diff --git a/frontend/src/components/Report.tsx b/frontend/src/components/Report.tsx
new file mode 100644
index 0000000..c67dc0b
--- /dev/null
+++ b/frontend/src/components/Report.tsx
@@ -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
) => {
+ e.preventDefault();
+ report.mutate(new FormData(e.currentTarget));
+ };
+ const cancel = () => {
+ report.reset();
+ setOpen(false);
+ };
+
+ return {
+ button: report.isSuccess
+ ? gemeldet
+ : ,
+ form: open && (
+
+ ),
+ };
+}
diff --git a/frontend/src/pages/EntryPage.tsx b/frontend/src/pages/EntryPage.tsx
index 9b6e876..93b026b 100644
--- a/frontend/src/pages/EntryPage.tsx
+++ b/frontend/src/pages/EntryPage.tsx
@@ -38,8 +38,7 @@ function ThreadView({ pid }: { pid: string }) {
);
}
const data = thread.data;
- const live = !!me && !!data && !data.entry.deleted;
- const isOwner = live && me.uid === data.entry.uid;
+ const isOwner = !!me && !!data && me.uid === data.entry.uid && !data.entry.deleted;
return (
<>
@@ -48,9 +47,7 @@ function ThreadView({ pid }: { pid: string }) {
{data && }
- {live && (isOwner
- ?
- : )}
+ {data && isOwner && }
@@ -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) => {
- e.preventDefault();
- report.mutate(new FormData(e.currentTarget));
- };
- const cancel = () => {
- report.reset();
- setOpen(false);
- };
-
- if (report.isSuccess) return Danke, der Beitrag wurde gemeldet.
;
- return (
- <>
-
- {open && (
-
- )}
- >
- );
-}
diff --git a/main.go b/main.go
index c91e6dd..88d07a2 100644
--- a/main.go
+++ b/main.go
@@ -137,6 +137,9 @@ func routes() http.Handler {
r.Get("/entry/feed/{page}", api(handleFeed))
r.Get("/entry/{pid}/votes", api(handleVotes))
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) ---
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}/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))
diff --git a/notes/api.md b/notes/api.md
index a96c244..dff11ff 100644
--- a/notes/api.md
+++ b/notes/api.md
@@ -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/{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`) |
+| 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/{pid}/edit` | ✓ Autor | `content` | `200 {"pid","content"}` |
| 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