Compare commits
2
Commits
2bae608d76
...
3d2447fe35
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d2447fe35 | ||
|
|
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,54 @@ 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(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. Anonyme Meldungen (uid 0) sind nicht unterscheidbar und zählen alle.
|
||||
report(bob, pid, "")
|
||||
report(alice, pid, "")
|
||||
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 {
|
||||
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,37 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
e, err := entryByPID(pid)
|
||||
if err != nil || e.Deleted != 0 {
|
||||
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 $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)
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) {
|
||||
<Link to={profile}>{e.username}</Link> · {formatTime(e.created_at)}
|
||||
<span className="cooldown">{bump.cooldown}</span>
|
||||
</span>
|
||||
{!own && report.button}
|
||||
</div>
|
||||
<div className="threadline-wrapper">
|
||||
<div className="threadline"></div>
|
||||
<div className="body">
|
||||
{report.form}
|
||||
<div className="content">{linkify(e.content)}</div>
|
||||
{e.filepath && <img src={`/${e.filepath}`} alt="" loading="lazy" />}
|
||||
<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>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -86,6 +86,7 @@ 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) |
|
||||
|
||||
Reference in New Issue
Block a user