Add community content reports, map bans, and appeals

This commit is contained in:
2026-10-06 13:00:10 +05:00
parent 5f43e97d32
commit dd67b5c0de
18 changed files with 1167 additions and 50 deletions

View File

@@ -1005,6 +1005,32 @@ pre {
.game-header > * { .game-header > * {
pointer-events: auto; pointer-events: auto;
} }
.report-dialog {
width: min(540px, calc(100vw - 32px));
max-height: calc(100dvh - 40px);
overflow-y: auto;
margin: auto;
padding: 28px;
border: 2px solid var(--line);
border-radius: 0;
background: var(--panel);
color: var(--text);
}
.report-dialog .field select {
display: block;
width: 100%;
padding: 12px;
margin-top: 6px;
background: var(--bg);
border: 1px solid var(--line);
color: var(--text);
}
.report-dialog::backdrop {
background: #0009;
}
.game-header .report-dialog {
pointer-events: auto;
}
.game-header .brand { .game-header .brand {
font-size: 24px; font-size: 24px;
filter: drop-shadow(0 2px 2px #000); filter: drop-shadow(0 2px 2px #000);

View File

@@ -4,6 +4,7 @@ import { Header, Footer } from "@/components/Shell";
import { StartGame } from "@/components/StartGame"; import { StartGame } from "@/components/StartGame";
import { getMap } from "@/lib/catalog"; import { getMap } from "@/lib/catalog";
import { ContributionInstructions } from "@/components/ContributionInstructions"; import { ContributionInstructions } from "@/components/ContributionInstructions";
import { ReportContent } from "@/components/ReportContent";
export default async function ServerPage({ export default async function ServerPage({
params, params,
}: { }: {
@@ -23,6 +24,7 @@ export default async function ServerPage({
<h1>{map.name}</h1> <h1>{map.name}</h1>
<p className="lead">{map.description}</p> <p className="lead">{map.description}</p>
<p className="map-credit">Created by {map.credit}</p> <p className="map-credit">Created by {map.credit}</p>
<ReportContent datasetId={id} />
<p className="muted"> <p className="muted">
Map ID: <code>{map.id}</code> Map ID: <code>{map.id}</code>
</p> </p>

View File

@@ -78,6 +78,8 @@ export function Dashboard() {
<div className="dashboard-toolbar"> <div className="dashboard-toolbar">
<p className="muted"> <p className="muted">
Your maps start privately. You decide when they’re ready for review. Your maps start privately. You decide when they’re ready for review.
Contributions are hidden here after five days. Their panoramas and
direct links stay available.
</p> </p>
<div className="inline-actions"> <div className="inline-actions">
<Link className="button" href="/settings"> <Link className="button" href="/settings">
@@ -120,6 +122,14 @@ export function Dashboard() {
: m.state} : m.state}
</span>{" "} </span>{" "}
<span className="muted">{m.panorama_count} locations</span> <span className="muted">{m.panorama_count} locations</span>
{m.banned_at ? (
<p className="error-message">
Map banned after three confirmed reports. Open your map to
appeal.
</p>
) : m.report_strikes > 0 ? (
<p className="muted">{m.report_strikes}/3 confirmed reports</p>
) : null}
<ProcessingProgress job={m.upload_jobs} /> <ProcessingProgress job={m.upload_jobs} />
{m.rejection_reason && ( {m.rejection_reason && (
<p className="error-message">{m.rejection_reason}</p> <p className="error-message">{m.rejection_reason}</p>

View File

@@ -9,6 +9,7 @@ import { PanoramaPreview } from "./PanoramaPreview";
import { OwnerContributions } from "./OwnerContributions"; import { OwnerContributions } from "./OwnerContributions";
import Link from "next/link"; import Link from "next/link";
import { MapRefresh } from "./MapRefresh"; import { MapRefresh } from "./MapRefresh";
import { MapAppeal } from "./MapAppeal";
type Draft = { type Draft = {
id: string; id: string;
name: string; name: string;
@@ -25,6 +26,9 @@ type Draft = {
allow_contributions: boolean; allow_contributions: boolean;
contribution_server_address: string | null; contribution_server_address: string | null;
update_state: string | null; update_state: string | null;
report_strikes: number;
banned_at: string | null;
ban_reason: string | null;
}; };
export function DraftEditor({ id }: { id: string }) { export function DraftEditor({ id }: { id: string }) {
const dirty = useRef(false); const dirty = useRef(false);
@@ -52,6 +56,7 @@ export function DraftEditor({ id }: { id: string }) {
setSelected(d.manifest.viewpoints.map((p) => p.id)); setSelected(d.manifest.viewpoints.map((p) => p.id));
} }
if ( if (
d.banned_at ||
["uploading", "processing"].includes(d.state) || ["uploading", "processing"].includes(d.state) ||
["queued", "running"].includes(d.upload_jobs?.state ?? "") ["queued", "running"].includes(d.upload_jobs?.state ?? "")
) )
@@ -139,6 +144,16 @@ export function DraftEditor({ id }: { id: string }) {
{!draft.parent_dataset_id && <a href="#map-tools">Map tools</a>} {!draft.parent_dataset_id && <a href="#map-tools">Map tools</a>}
</div> </div>
<span className="badge">{draft.state}</span> <span className="badge">{draft.state}</span>
{!draft.parent_dataset_id &&
(draft.report_strikes > 0 || draft.banned_at) && (
<MapAppeal
id={id}
strikes={draft.report_strikes}
bannedAt={draft.banned_at}
banReason={draft.ban_reason}
onChange={() => setVersion((v) => v + 1)}
/>
)}
{draft.update_state && ( {draft.update_state && (
<span className="badge">Update: {draft.update_state}</span> <span className="badge">Update: {draft.update_state}</span>
)} )}

View File

@@ -14,6 +14,7 @@ import { GuessMap, type Pin } from "./GuessMap";
import { api } from "./StartGame"; import { api } from "./StartGame";
import { difficulties, type Difficulty, type Bounds } from "@/shared/protocol"; import { difficulties, type Difficulty, type Bounds } from "@/shared/protocol";
import { roundClock } from "@/shared/round-clock"; import { roundClock } from "@/shared/round-clock";
import { ReportContent } from "./ReportContent";
export type RoundResult = { export type RoundResult = {
score: number; score: number;
distance: number | null; distance: number | null;
@@ -197,6 +198,13 @@ export function Game({ id }: { id: string }) {
)} )}
<div className="game-header"> <div className="game-header">
<Brand /> <Brand />
{session && (
<ReportContent
key={session.round}
sessionId={id}
round={session.round}
/>
)}
<div className="game-info"> <div className="game-info">
<div> <div>
<small>ROUND</small> <small>ROUND</small>

111
components/MapAppeal.tsx Normal file
View File

@@ -0,0 +1,111 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { api } from "./StartGame";
export type Appeal = {
id: string;
dataset_id: string;
reason: string;
state: string;
review_reason: string | null;
created_at: string;
reviewed_at: string | null;
};
export function MapAppeal({
id,
strikes,
bannedAt,
banReason,
onChange,
}: {
id: string;
strikes: number;
bannedAt: string | null;
banReason: string | null;
onChange: () => void;
}) {
const [appeals, setAppeals] = useState<Appeal[]>([]);
const [reason, setReason] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
setAppeals(
(await api<{ appeals: Appeal[] }>(`datasets/${id}/appeals`)).appeals,
);
} catch (e) {
setError((e as Error).message);
}
}, [id]);
useEffect(() => {
load();
const timer = setInterval(load, 10000);
return () => clearInterval(timer);
}, [load]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
await api(`datasets/${id}/appeals`, { reason });
setReason("");
await load();
onChange();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section className="tool-card">
<h2>
{bannedAt
? "Map unpublished · appeal required"
: "Community report strikes"}
</h2>
<p>
{strikes}/3 confirmed reports.{" "}
{bannedAt
? "This map cannot be published until a moderator approves your appeal."
: "Three confirmed reports automatically unpublish this map."}
</p>
{banReason && <p>{banReason}</p>}
{appeals.map((a) => (
<div key={a.id}>
<span className="badge">Appeal: {a.state}</span>
<p>{a.reason}</p>
{a.review_reason && <p>Moderator feedback: {a.review_reason}</p>}
</div>
))}
{bannedAt &&
(appeals.some((a) => a.state === "open") ? (
<p role="status">Your appeal is waiting for moderator review.</p>
) : (
<form onSubmit={submit}>
<label className="field">
Explain why the map ban should be lifted
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
required
minLength={10}
maxLength={2000}
/>
</label>
<button
className="button primary"
disabled={busy || reason.trim().length < 10}
>
{busy ? "Sending…" : "Appeal map ban"}
</button>
</form>
))}
{error && (
<p role="alert" className="error-message">
{error}
</p>
)}
</section>
);
}

View File

@@ -3,6 +3,8 @@ import { useEffect, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { api } from "./StartGame"; import { api } from "./StartGame";
import { PanoramaPreview } from "./PanoramaPreview"; import { PanoramaPreview } from "./PanoramaPreview";
import { reportCategories } from "@/shared/moderation";
import type { Appeal } from "./MapAppeal";
type ReviewMap = { type ReviewMap = {
id: string; id: string;
name: string; name: string;
@@ -13,24 +15,44 @@ type ReviewMap = {
panorama_count: number; panorama_count: number;
map: string | null; map: string | null;
image: string | null; image: string | null;
report_strikes: number;
banned_at: string | null;
}; };
export function Moderation() { export function Moderation() {
const [maps, setMaps] = useState<ReviewMap[]>([]), const [maps, setMaps] = useState<ReviewMap[]>([]),
[reports, setReports] = useState< [reports, setReports] = useState<
{ id: string; dataset_id: string; reason: string }[] {
id: string;
dataset_id: string;
reason: string;
category: keyof typeof reportCategories;
panorama_id: string | null;
evidence_url: string | null;
created_at: string;
mapName: string;
map: string | null;
}[]
>([]), >([]),
[appeals, setAppeals] = useState<Appeal[]>([]),
[error, setError] = useState(""), [error, setError] = useState(""),
[busy, setBusy] = useState(false), [busy, setBusy] = useState(false),
[reasons, setReasons] = useState<Record<string, string>>({}); [reasons, setReasons] = useState<Record<string, string>>({});
const [preview, setPreview] = useState<string | null>(null), const [preview, setPreview] = useState<string | null>(null),
[loading, setLoading] = useState(true); [loading, setLoading] = useState(true);
const [reportPreview, setReportPreview] = useState<{
id: string;
panorama: string | null;
} | null>(null);
async function load() { async function load() {
try { try {
const d = await api<{ maps: ReviewMap[]; reports: typeof reports }>( const d = await api<{
"moderation", maps: ReviewMap[];
); reports: typeof reports;
appeals: Appeal[];
}>("moderation");
setMaps(d.maps); setMaps(d.maps);
setReports(d.reports); setReports(d.reports);
setAppeals(d.appeals);
setError(""); setError("");
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
@@ -38,11 +60,23 @@ export function Moderation() {
setLoading(false); setLoading(false);
} }
} }
async function resolveReport(id: string) { async function resolveReport(id: string, decision: "confirm" | "dismiss") {
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
await api(`reports/${id}`, { state: "resolved" }, "PATCH"); await api(`reports/${id}`, { decision, reason: reasons[id] }, "PATCH");
await load();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function reviewAppeal(id: string, decision: "approve" | "reject") {
setBusy(true);
setError("");
try {
await api(`moderation/appeals/${id}`, { decision, reason: reasons[id] });
await load(); await load();
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
@@ -87,6 +121,12 @@ export function Moderation() {
<span className="badge">Map update awaiting review</span> <span className="badge">Map update awaiting review</span>
)} )}
<h2>{m.name}</h2> <h2>{m.name}</h2>
{m.report_strikes > 0 && (
<p className={m.banned_at ? "error-message" : "muted"}>
{m.report_strikes}/3 confirmed reports
{m.banned_at ? " · Banned; owner appeal required" : ""}
</p>
)}
<p className="muted"> <p className="muted">
{m.source} · {m.panorama_count} locations · {m.description} {m.source} · {m.panorama_count} locations · {m.description}
</p> </p>
@@ -148,23 +188,137 @@ export function Moderation() {
{reports.length > 0 && ( {reports.length > 0 && (
<section className="token-box"> <section className="token-box">
<h2>Community reports</h2> <h2>Community reports</h2>
<p className="muted">
Confirming a valid report adds one strike to the map. Three strikes
automatically unpublish it. Dismissed reports add no strikes.
</p>
{reports.map((r) => ( {reports.map((r) => (
<article className="draft-row" key={r.id}> <article className="draft-row" key={r.id}>
<div> <div>
<h3>{r.mapName}</h3>
<code>{r.dataset_id}</code> <code>{r.dataset_id}</code>
<p>
{r.panorama_id ? `Panorama: ${r.panorama_id}` : "Entire map"}{" "}
· {reportCategories[r.category]} ·{" "}
{new Date(r.created_at).toLocaleString()}
</p>
<p>{r.reason}</p> <p>{r.reason}</p>
</div> {r.evidence_url && (
<p>
<a
href={r.evidence_url}
target="_blank"
rel="noopener noreferrer"
>
Open evidence
</a>
</p>
)}
{r.map && (
<Image
src={r.map}
width={320}
height={320}
alt="Reported map version"
unoptimized
style={{ maxWidth: "100%", height: "auto" }}
/>
)}
<button <button
className="button" className="button"
disabled={busy} onClick={() =>
onClick={() => resolveReport(r.id)} setReportPreview({ id: r.id, panorama: r.panorama_id })
}
> >
Resolve report Inspect reported content
</button> </button>
<label className="field">
Decision reason
<textarea
maxLength={2000}
value={reasons[r.id] ?? ""}
onChange={(e) =>
setReasons((s) => ({ ...s, [r.id]: e.target.value }))
}
/>
</label>
<div className="inline-actions">
<button
className="button primary"
disabled={
busy ||
(reasons[r.id]?.trim().length ?? 0) < 10 ||
!!maps.find((m) => m.id === r.dataset_id)?.banned_at
}
onClick={() => resolveReport(r.id, "confirm")}
>
Confirm report · add strike
</button>
<button
className="button"
disabled={busy || (reasons[r.id]?.trim().length ?? 0) < 10}
onClick={() => resolveReport(r.id, "dismiss")}
>
Dismiss report
</button>
</div>
</div>
</article> </article>
))} ))}
</section> </section>
)} )}
{appeals.length > 0 && (
<section className="token-box">
<h2>Map ban appeals</h2>
{appeals.map((a) => (
<article className="tool-card" key={a.id}>
<h3>
{maps.find((m) => m.id === a.dataset_id)?.name ?? a.dataset_id}
</h3>
<p>{a.reason}</p>
<button
className="button"
onClick={() => setPreview(a.dataset_id)}
>
Inspect map panoramas
</button>
<label className="field">
Appeal decision reason
<textarea
maxLength={2000}
value={reasons[a.id] ?? ""}
onChange={(e) =>
setReasons((s) => ({ ...s, [a.id]: e.target.value }))
}
/>
</label>
<div className="inline-actions">
<button
className="button primary"
disabled={busy || (reasons[a.id]?.trim().length ?? 0) < 10}
onClick={() => reviewAppeal(a.id, "approve")}
>
Approve appeal · restore map
</button>
<button
className="button"
disabled={busy || (reasons[a.id]?.trim().length ?? 0) < 10}
onClick={() => reviewAppeal(a.id, "reject")}
>
Reject appeal · keep ban
</button>
</div>
</article>
))}
</section>
)}
{reportPreview && (
<PanoramaPreview
endpoint={`moderation/reports/${reportPreview.id}/panoramas`}
initialId={reportPreview.panorama ?? undefined}
onClose={() => setReportPreview(null)}
/>
)}
{preview && ( {preview && (
<PanoramaPreview <PanoramaPreview
endpoint={`moderation/${preview}/panoramas`} endpoint={`moderation/${preview}/panoramas`}

View File

@@ -176,13 +176,19 @@ export function OwnerContributions({
isOwner isOwner
/> />
<h3>Player submissions</h3> <h3>Player submissions</h3>
<p className="muted">
Submissions older than five days are hidden from this list. Accepted
panoramas stay in your map.
</p>
{locked && ( {locked && (
<p className="muted"> <p className="muted">
Wait for the current moderator review before accepting another Wait for the current moderator review before accepting another
submission. submission.
</p> </p>
)} )}
{!items.length && <p className="muted">No contributions yet.</p>} {!items.length && (
<p className="muted">No contributions from the last five days.</p>
)}
{items.map((c) => ( {items.map((c) => (
<article className="tool-card" key={c.id}> <article className="tool-card" key={c.id}>
<h3>{c.name}</h3> <h3>{c.name}</h3>

View File

@@ -0,0 +1,168 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "./StartGame";
import { reportCategories } from "@/shared/moderation";
export function ReportContent({
datasetId,
sessionId,
round,
}: {
datasetId?: string;
sessionId?: string;
round?: number;
}) {
const [open, setOpen] = useState(false);
const [target, setTarget] = useState("map");
const [category, setCategory] = useState("broken");
const [reason, setReason] = useState("");
const [evidenceUrl, setEvidenceUrl] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!open) return;
const element = dialog.current;
const previousFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
element?.showModal();
return () => {
element?.close();
previousFocus?.focus();
};
}, [open]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
await api("reports", {
datasetId,
sessionId,
round,
target,
category,
reason,
evidenceUrl: evidenceUrl.trim() || undefined,
});
setMessage(
"Report sent for moderator review. Only confirmed reports count toward the three-report map ban.",
);
setOpen(false);
setReason("");
setEvidenceUrl("");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<>
<button
className="button"
onClick={() => {
setOpen(true);
setError("");
setMessage("");
}}
>
Report content
</button>
{message && (
<p role="status" className="success-message">
{message}
</p>
)}
{open && (
<dialog
ref={dialog}
className="report-dialog"
aria-labelledby="report-title"
onCancel={() => setOpen(false)}
>
<form onSubmit={submit}>
<h2 id="report-title">Report content</h2>
<p className="muted">
Sign in to send a report. A moderator reviews the content and your
explanation. Three confirmed reports unpublish the map until an
owner appeal is approved.
</p>
<label className="field">
What are you reporting?
<select
value={target}
onChange={(e) => setTarget(e.target.value)}
>
<option value="map">Entire map</option>
{sessionId && (
<option value="panorama">
This panorama (round {(round ?? 0) + 1})
</option>
)}
</select>
</label>
<label className="field">
Reason
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
>
{Object.entries(reportCategories).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
<label className="field">
What happened?
<textarea
required
minLength={10}
maxLength={2000}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Describe the problem and where to find it."
/>
</label>
<label className="field">
Evidence link (optional)
<input
type="url"
maxLength={1000}
value={evidenceUrl}
onChange={(e) => setEvidenceUrl(e.target.value)}
placeholder="https://…"
/>
</label>
{error && (
<p role="alert" className="error-message">
{error}
</p>
)}
<div className="inline-actions">
<button
className="button primary"
disabled={busy || reason.trim().length < 10}
>
{busy ? "Sending…" : "Send report"}
</button>
<button
type="button"
className="button"
disabled={busy}
onClick={() => setOpen(false)}
>
Cancel
</button>
</div>
</form>
</dialog>
)}
</>
);
}

View File

@@ -22,6 +22,23 @@ The query must return the intended user's ID. An empty result means the person h
Treat the website role separately from Supabase organization membership: moderators do not need access to your Supabase project or its secret key. The existing test moderator account is a verification fixture; it does not grant access to your personal account. Treat the website role separately from Supabase organization membership: moderators do not need access to your Supabase project or its secret key. The existing test moderator account is a verification fixture; it does not grant access to your personal account.
# Community reports and map ban appeals
Signed-in players can use **Report content** on a published map page to report the entire map, or during a game to report the current panorama. Reports include a category, a 10–2000 character explanation, and an optional HTTP/HTTPS evidence link. The website derives panorama identity from the player's accessible game round; it retains the reported immutable revision for moderator inspection. Owners cannot report their own maps. Each person can have only one open or confirmed report per map until its ban is appealed successfully; dismissed reports can be corrected and resubmitted.
In `/moderation`, inspect the reported surface map and 360° locations, then **Confirm report · add strike** or **Dismiss report**, with written feedback. Pending and dismissed reports never count toward bans. Each distinct reporter can contribute one confirmed strike. Confirming the third report atomically unpublishes the map and blocks publication. The ban applies to the map, not the owner's account. Existing games retain their original revisions; the map disappears from the public catalog and cannot start new games.
The owner opens the banned map from `/dashboard` and uses **Appeal map ban**. One appeal can await review at a time. Moderators can inspect the map and approve or reject the appeal with feedback. Rejection keeps the ban and permits another appeal with new information. Approval restores the last published revision, resets the three-strike counter, closes pending reports from the old cycle, and preserves all report and appeal history. A moderator cannot confirm their own report, confirm reports on their own map, or approve their own map's appeal.
Contribution uploads older than five days are hidden from the dashboard and the owner's **Player submissions** list. Nothing is deleted: direct contribution links, accepted panoramas, and quota accounting still work. The cutoff uses the contribution's creation time, including pending submissions.
Apply `supabase/migrations/20261006074343_content_reports_and_appeals.sql` after all previous migrations before deploying this website. All new decision functions and the appeal table are restricted to the server service role, and appeal RLS is enabled. Run `tests/sql/moderation.sql` against a database with six test accounts (the transaction rolls back), or run the isolated local verification:
```sh
npm install --prefix .data/sql-verification --no-save --package-lock=false @electric-sql/pglite@0.3.14
node scripts/test-moderation-sql.mjs
```
# First beta map # First beta map
Published-map updates now have their own review state. Owners approve player submissions first and submit the combined map update; **Approve update** publishes a new immutable revision. Returning an update with feedback keeps the previous version playable. See [panorama contributions](panorama-contributions.md). Published-map updates now have their own review state. Owners approve player submissions first and submit the combined map update; **Approve update** publishes a new immutable revision. Returning an update with feedback keeps the previous version playable. See [panorama contributions](panorama-contributions.md).

View File

@@ -15,6 +15,7 @@ import { mediaUrl, mediaUrls } from "./catalog";
import { ServiceError, checkServiceError } from "./service-error"; import { ServiceError, checkServiceError } from "./service-error";
import { requestOrigin, verifyOrigin } from "./request-origin"; import { requestOrigin, verifyOrigin } from "./request-origin";
import { accountApi } from "./account-api"; import { accountApi } from "./account-api";
import { contributionCutoff, reportSchema } from "../shared/moderation";
const hash = (value: string) => const hash = (value: string) =>
createHash("sha256").update(value).digest("hex"); createHash("sha256").update(value).digest("hex");
const idSchema = z.string().uuid(); const idSchema = z.string().uuid();
@@ -335,6 +336,9 @@ export async function handle(request: NextRequest, path: string[]) {
"*, upload_jobs!dataset_id(state,progress,error,updated_at,lease_until,kind)", "*, upload_jobs!dataset_id(state,progress,error,updated_at,lease_until,kind)",
) )
.eq("owner_id", owner) .eq("owner_id", owner)
.or(
`parent_dataset_id.is.null,created_at.gt.${contributionCutoff()}`,
)
.order("created_at", { ascending: false }), .order("created_at", { ascending: false }),
); );
return NextResponse.json({ return NextResponse.json({
@@ -348,6 +352,32 @@ export async function handle(request: NextRequest, path: string[]) {
}); });
} }
const d = await ownedDataset(request, path[1]); const d = await ownedDataset(request, path[1]);
if (path[2] === "appeals" && path.length === 3 && !d.parent_dataset_id) {
if (method === "GET") {
const appeals = required(
await client
.from("map_appeals")
.select("*")
.eq("dataset_id", d.id)
.order("created_at", { ascending: false }),
);
return NextResponse.json({ appeals });
}
if (method === "POST") {
const body = z
.object({ reason: z.string().trim().min(10).max(2000) })
.parse(await jsonBody(request));
await limit("appeals:" + d.owner_id, 5, 3600);
const id = required(
await client.rpc("appeal_map_ban", {
p_dataset: d.id,
p_owner: d.owner_id,
p_reason: body.reason,
}),
);
return NextResponse.json({ id });
}
}
if (path[2] === "refresh-map") { if (path[2] === "refresh-map") {
if (d.parent_dataset_id) if (d.parent_dataset_id)
throw new Error("Only maps can refresh their guessing map"); throw new Error("Only maps can refresh their guessing map");
@@ -571,6 +601,7 @@ export async function handle(request: NextRequest, path: string[]) {
.from("datasets") .from("datasets")
.select("*") .select("*")
.eq("parent_dataset_id", target) .eq("parent_dataset_id", target)
.gt("created_at", contributionCutoff())
.order("created_at", { ascending: false }), .order("created_at", { ascending: false }),
); );
const profiles = rows.length const profiles = rows.length
@@ -751,6 +782,60 @@ export async function handle(request: NextRequest, path: string[]) {
} }
if (path[0] === "moderation") { if (path[0] === "moderation") {
const reviewer = await moderator(request); const reviewer = await moderator(request);
if (path[1] === "appeals" && path.length === 3 && method === "POST") {
const body = z
.object({
decision: z.enum(["approve", "reject"]),
reason: z.string().trim().min(10).max(2000),
})
.parse(await jsonBody(request));
checkServiceError(
(
await client.rpc("review_map_appeal", {
p_appeal: uuid(path[2]),
p_reviewer: reviewer,
p_decision: body.decision,
p_reason: body.reason,
})
).error,
);
return NextResponse.json({ ok: true });
}
if (
path[1] === "reports" &&
path[3] === "panoramas" &&
method === "GET" &&
[4, 5].includes(path.length)
) {
const report = required(
await client
.from("reports")
.select("*")
.eq("id", uuid(path[2]))
.single(),
);
const revision = required(
report.revision_id
? await client
.from("revisions")
.select("manifest,asset_root")
.eq("id", report.revision_id)
.single()
: await client
.from("revisions")
.select("manifest,asset_root")
.eq("dataset_id", report.dataset_id)
.order("created_at", { ascending: false })
.limit(1)
.single(),
);
const manifest = datasetSchema.parse(revision.manifest);
return panoramaResponse(
revision.asset_root,
manifest.viewpoints,
path[4],
);
}
if (method === "GET" && path[2] === "panoramas") { if (method === "GET" && path[2] === "panoramas") {
const d = required( const d = required(
await client await client
@@ -759,12 +844,13 @@ export async function handle(request: NextRequest, path: string[]) {
.eq("id", uuid(path[1])) .eq("id", uuid(path[1]))
.single(), .single(),
); );
if (!["review", "published"].includes(d.state)) if (!["review", "published", "unpublished"].includes(d.state))
throw new Error("Map is not awaiting review or published"); throw new Error("Map is not awaiting review or published");
if (d.parent_dataset_id) if (d.parent_dataset_id)
throw new Error("Contributions require owner approval first"); throw new Error("Contributions require owner approval first");
const input = required( const input = required(
d.state === "published" && d.update_state !== "review" (d.state === "published" && d.update_state !== "review") ||
d.state === "unpublished"
? await client ? await client
.from("revisions") .from("revisions")
.select("manifest") .select("manifest")
@@ -788,11 +874,49 @@ export async function handle(request: NextRequest, path: string[]) {
.from("datasets") .from("datasets")
.select("*") .select("*")
.is("parent_dataset_id", null) .is("parent_dataset_id", null)
.in("state", ["review", "published"]) .in("state", ["review", "published", "unpublished"])
.order("created_at"), .order("created_at"),
); );
const reports = required( const reports = required(
await client.from("reports").select("*").eq("state", "open"), await client
.from("reports")
.select("*")
.eq("state", "open")
.order("created_at"),
);
const appeals = required(
await client
.from("map_appeals")
.select("*")
.eq("state", "open")
.order("created_at"),
);
const reportViews = await Promise.all(
reports.map(async (report) => {
const revision = report.revision_id
? required(
await client
.from("revisions")
.select("manifest,asset_root")
.eq("id", report.revision_id)
.single(),
)
: null;
const manifest = revision
? datasetSchema.parse(revision.manifest)
: null;
return {
...report,
mapName:
maps.find((m) => m.id === report.dataset_id)?.name ??
"Reported map",
map: revision
? await mediaUrl(
manifest?.mapPath ?? `${revision.asset_root}/map.webp`,
)
: null,
};
}),
); );
return NextResponse.json({ return NextResponse.json({
maps: await Promise.all( maps: await Promise.all(
@@ -820,7 +944,8 @@ export async function handle(request: NextRequest, path: string[]) {
}; };
}), }),
), ),
reports, reports: reportViews,
appeals,
}); });
} }
if (method === "POST") { if (method === "POST") {
@@ -843,38 +968,80 @@ export async function handle(request: NextRequest, path: string[]) {
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
} }
if (path[0] === "reports" && method === "POST") { if (path[0] === "reports" && path.length === 1 && method === "POST") {
const owner = await contributor(request); const owner = await contributor(request);
await limit("reports:" + owner, 10, 3600); await limit("reports:" + owner, 10, 3600);
const b = z const b = reportSchema.parse(await jsonBody(request));
.object({ datasetId: idSchema, reason: z.string().min(10).max(2000) }) let datasetId = b.datasetId,
.parse(await jsonBody(request)); revisionId: string | undefined,
required( panoramaId: string | null = null;
if (b.sessionId) {
const game = await session(request, b.sessionId);
const revision = required(
await client await client
.from("reports") .from("revisions")
.insert({ .select("dataset_id,id")
dataset_id: b.datasetId, .eq("id", game.revision_id)
owner_id: owner,
reason: b.reason,
})
.select("id")
.single(), .single(),
); );
return NextResponse.json({ ok: true }); datasetId = revision.dataset_id;
} revisionId = revision.id;
if (path[0] === "reports" && method === "PATCH") { if (b.target === "panorama") {
await moderator(request); if (b.round! > game.current_round)
const b = z throw new Error("This round is not available yet");
.object({ state: z.literal("resolved") }) const round = required(
.parse(await jsonBody(request));
required(
await client await client
.from("reports") .from("game_rounds")
.update({ state: b.state }) .select("viewpoint")
.eq("id", uuid(path[1])) .eq("session_id", game.id)
.select("id") .eq("round_number", b.round!)
.single(), .single(),
); );
panoramaId = viewpointSchema.parse(round.viewpoint).id;
}
} else {
const map = required(
await client
.from("datasets")
.select("revision_id")
.eq("id", datasetId!)
.eq("state", "published")
.is("parent_dataset_id", null)
.single(),
);
revisionId = map.revision_id ?? undefined;
}
const id = required(
await client.rpc("submit_content_report", {
p_dataset: datasetId!,
p_owner: owner,
p_revision: revisionId!,
p_panorama: panoramaId,
p_category: b.category,
p_reason: b.reason,
p_evidence: b.evidenceUrl ?? null,
}),
);
return NextResponse.json({ id });
}
if (path[0] === "reports" && path.length === 2 && method === "PATCH") {
const reviewer = await moderator(request);
const b = z
.object({
decision: z.enum(["confirm", "dismiss"]),
reason: z.string().trim().min(10).max(2000),
})
.parse(await jsonBody(request));
checkServiceError(
(
await client.rpc("review_content_report", {
p_report: uuid(path[1]),
p_reviewer: reviewer,
p_decision: b.decision,
p_reason: b.reason,
})
).error,
);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json({ error: "Not found" }, { status: 404 });

View File

@@ -20,6 +20,9 @@ export type MapListing = {
cover_path: string | null; cover_path: string | null;
map_path: string | null; map_path: string | null;
rejection_reason: string | null; rejection_reason: string | null;
report_strikes: number;
banned_at: string | null;
ban_reason: string | null;
image?: string; image?: string;
mapImage?: string; mapImage?: string;
credit?: string; credit?: string;

View File

@@ -0,0 +1,26 @@
// Isolated Postgres verification. Install the optional test runtime with:
// npm install --prefix .data/sql-verification --no-save --package-lock=false @electric-sql/pglite@0.3.14
import { readFile, readdir } from "node:fs/promises";
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
const db = new PGlite();
try {
await db.exec(`create role anon; create role authenticated; create role service_role;
create schema auth; create table auth.users(id uuid primary key);
create function auth.uid() returns uuid language sql as $$ select null::uuid $$;
create schema storage; create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
for (const file of (await readdir("supabase/migrations"))
.filter((f) => f.endsWith(".sql"))
.sort()) {
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
console.log(`Migration passed: ${file}`);
}
await db.exec(await readFile("tests/sql/moderation.sql", "utf8"));
console.log(
"Moderation SQL passed: reporting, authorization, duplicate handling, strikes, bans, appeals, and history.",
);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
} finally {
await db.close();
}

View File

@@ -6,6 +6,35 @@ export type Json =
| { [key: string]: Json | undefined } | { [key: string]: Json | undefined }
| Json[]; | Json[];
type MapModerationFields = {
report_strikes: number;
report_cycle: number;
banned_at: string | null;
ban_reason: string | null;
};
type ReportModerationFields = {
category: string;
revision_id: string | null;
panorama_id: string | null;
evidence_url: string | null;
report_cycle: number;
reviewer_id: string | null;
reviewed_at: string | null;
review_reason: string | null;
};
type AppealRecord = {
id: string;
dataset_id: string;
owner_id: string;
report_cycle: number;
reason: string;
state: string;
created_at: string;
reviewer_id: string | null;
reviewed_at: string | null;
review_reason: string | null;
};
export type Database = { export type Database = {
// Allows to automatically instantiate createClient with right options // Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY) // instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
@@ -14,6 +43,16 @@ export type Database = {
}; };
public: { public: {
Tables: { Tables: {
map_appeals: {
Row: AppealRecord;
Insert: Partial<AppealRecord> &
Pick<
AppealRecord,
"dataset_id" | "owner_id" | "report_cycle" | "reason"
>;
Update: Partial<AppealRecord>;
Relationships: [];
};
catalog_servers: { catalog_servers: {
Row: { Row: {
address: string | null; address: string | null;
@@ -89,7 +128,7 @@ export type Database = {
]; ];
}; };
datasets: { datasets: {
Row: { Row: MapModerationFields & {
allow_contributions: boolean; allow_contributions: boolean;
bounds: Json; bounds: Json;
contribution_count: number; contribution_count: number;
@@ -112,7 +151,7 @@ export type Database = {
update_state: string | null; update_state: string | null;
updated_at: string; updated_at: string;
}; };
Insert: { Insert: Partial<MapModerationFields> & {
allow_contributions?: boolean; allow_contributions?: boolean;
bounds: Json; bounds: Json;
contribution_count?: number; contribution_count?: number;
@@ -135,7 +174,7 @@ export type Database = {
update_state?: string | null; update_state?: string | null;
updated_at?: string; updated_at?: string;
}; };
Update: { Update: Partial<MapModerationFields> & {
allow_contributions?: boolean; allow_contributions?: boolean;
bounds?: Json; bounds?: Json;
contribution_count?: number; contribution_count?: number;
@@ -322,7 +361,7 @@ export type Database = {
Relationships: []; Relationships: [];
}; };
reports: { reports: {
Row: { Row: ReportModerationFields & {
created_at: string; created_at: string;
dataset_id: string; dataset_id: string;
id: string; id: string;
@@ -330,7 +369,7 @@ export type Database = {
reason: string; reason: string;
state: string; state: string;
}; };
Insert: { Insert: Partial<ReportModerationFields> & {
created_at?: string; created_at?: string;
dataset_id: string; dataset_id: string;
id?: string; id?: string;
@@ -338,7 +377,7 @@ export type Database = {
reason: string; reason: string;
state?: string; state?: string;
}; };
Update: { Update: Partial<ReportModerationFields> & {
created_at?: string; created_at?: string;
dataset_id?: string; dataset_id?: string;
id?: string; id?: string;
@@ -481,6 +520,40 @@ export type Database = {
[_ in never]: never; [_ in never]: never;
}; };
Functions: { Functions: {
submit_content_report: {
Args: {
p_dataset: string;
p_owner: string;
p_revision: string;
p_panorama: string | null;
p_category: string;
p_reason: string;
p_evidence?: string | null;
};
Returns: string;
};
review_content_report: {
Args: {
p_report: string;
p_reviewer: string;
p_decision: string;
p_reason: string;
};
Returns: undefined;
};
appeal_map_ban: {
Args: { p_dataset: string; p_owner: string; p_reason: string };
Returns: string;
};
review_map_appeal: {
Args: {
p_appeal: string;
p_reviewer: string;
p_decision: string;
p_reason: string;
};
Returns: undefined;
};
refresh_map: { refresh_map: {
Args: { p_dataset: string; p_owner: string; p_source: string }; Args: { p_dataset: string; p_owner: string; p_source: string };
Returns: undefined; Returns: undefined;

52
shared/moderation.ts Normal file
View File

@@ -0,0 +1,52 @@
import { z } from "zod";
export const reportCategories = {
inappropriate: "Inappropriate or offensive content",
privacy: "Personal or private information",
wrong_world: "Wrong world or misleading map",
broken: "Broken panorama or map",
spam: "Spam or advertising",
other: "Other",
} as const;
export const reportSchema = z
.object({
datasetId: z.string().uuid().optional(),
sessionId: z.string().uuid().optional(),
round: z.number().int().min(0).max(4).optional(),
target: z.enum(["map", "panorama"]),
category: z.enum(
Object.keys(reportCategories) as [
keyof typeof reportCategories,
...Array<keyof typeof reportCategories>,
],
),
reason: z.string().trim().min(10).max(2000),
evidenceUrl: z
.string()
.trim()
.url()
.max(1000)
.refine(
(url) => /^https?:\/\//i.test(url),
"Use an HTTP or HTTPS evidence link",
)
.optional(),
})
.superRefine((report, context) => {
if (Boolean(report.datasetId) === Boolean(report.sessionId))
context.addIssue({
code: "custom",
message: "Choose a map or a game session",
});
if (
report.target === "panorama" &&
(!report.sessionId || report.round === undefined)
)
context.addIssue({
code: "custom",
message: "Choose a panorama from your game",
});
});
export const contributionCutoff = (now = Date.now()) =>
new Date(now - 5 * 24 * 60 * 60 * 1000).toISOString();

View File

@@ -0,0 +1,121 @@
-- Decisions and strikes are serialized on the map; only the server can mutate them.
alter table public.datasets
add column report_strikes integer not null default 0 check(report_strikes between 0 and 3),
add column report_cycle integer not null default 0,
add column banned_at timestamptz,
add column ban_reason text,
add constraint map_ban_state check(banned_at is null or (state='unpublished' and update_state is null)),
add constraint map_ban_threshold check(report_strikes<3 or banned_at is not null);
alter table public.reports
add column category text not null default 'other' check(category in ('inappropriate','privacy','wrong_world','broken','spam','other')),
add column revision_id uuid references public.revisions(id),
add column panorama_id text,
add column evidence_url text check(evidence_url is null or (length(evidence_url)<=1000 and evidence_url ~* '^https?://')),
add column report_cycle integer not null default 0,
add column reviewer_id uuid references public.profiles(id),
add column reviewed_at timestamptz,
add column review_reason text;
-- Legacy rows keep their resolved status; new reports use confirmed/dismissed.
alter table public.reports add constraint report_state check(state in ('open','resolved','confirmed','dismissed'));
create index reports_review_queue on public.reports(created_at) where state='open';
-- The row lock in submit_content_report handles legacy duplicate rows too.
create index reports_reporter_cycle on public.reports(dataset_id,owner_id,report_cycle);
create index contributions_recent on public.datasets(parent_dataset_id,created_at) where parent_dataset_id is not null;
create table public.map_appeals (
id uuid primary key default gen_random_uuid(),
dataset_id uuid not null references public.datasets(id),
owner_id uuid not null references auth.users(id),
report_cycle integer not null,
reason text not null check(length(trim(reason)) between 10 and 2000),
state text not null default 'open' check(state in ('open','approved','rejected')),
created_at timestamptz not null default now(),
reviewer_id uuid references public.profiles(id),
reviewed_at timestamptz,
review_reason text
);
create unique index map_appeal_pending on public.map_appeals(dataset_id) where state='open';
alter table public.map_appeals enable row level security;
revoke all on public.map_appeals from anon,authenticated;
grant all on public.map_appeals to service_role;
create function public.submit_content_report(p_dataset uuid,p_owner uuid,p_revision uuid,p_panorama text,p_category text,p_reason text,p_evidence text default null) returns uuid
language plpgsql security invoker set search_path='' as $$
declare d public.datasets; m jsonb; report uuid;
begin
select * into d from public.datasets where id=p_dataset and parent_dataset_id is null for update;
if not found or d.state<>'published' or d.banned_at is not null then raise exception 'Only published maps can be reported'; end if;
if p_owner is null or not exists(select 1 from public.profiles where id=p_owner) then raise exception 'Sign in to report content'; end if;
if d.owner_id=p_owner then raise exception 'You cannot report your own map'; end if;
if exists(select 1 from public.reports where dataset_id=d.id and owner_id=p_owner and report_cycle=d.report_cycle and state in ('open','confirmed')) then raise exception 'You already reported this map. A moderator will review your report'; end if;
if p_category is null or p_category not in ('inappropriate','privacy','wrong_world','broken','spam','other') or coalesce(length(trim(p_reason)),0) not between 10 and 2000 then raise exception 'Choose a reason and describe the problem in 10–2000 characters'; end if;
if p_evidence is not null and (length(p_evidence)>1000 or p_evidence !~* '^https?://') then raise exception 'Use an HTTP or HTTPS evidence link'; end if;
select manifest into m from public.revisions where id=p_revision and dataset_id=d.id;
if not found then raise exception 'Reported map version not found'; end if;
if p_panorama is not null and not exists(select 1 from jsonb_array_elements(m->'viewpoints') p where p->>'id'=p_panorama) then raise exception 'Reported panorama not found'; end if;
insert into public.reports(dataset_id,owner_id,revision_id,panorama_id,category,reason,evidence_url,report_cycle)
values(d.id,p_owner,p_revision,p_panorama,p_category,trim(p_reason),p_evidence,d.report_cycle) returning id into report;
return report;
end; $$;
create function public.review_content_report(p_report uuid,p_reviewer uuid,p_decision text,p_reason text) returns void
language plpgsql security invoker set search_path='' as $$
declare r public.reports; d public.datasets; map_id uuid; strikes integer;
begin
if not exists(select 1 from public.profiles where id=p_reviewer and role='moderator') then raise exception 'Moderator permission required'; end if;
select dataset_id into map_id from public.reports where id=p_report;
select * into d from public.datasets where id=map_id for update;
select * into r from public.reports where id=p_report for update;
if not found or r.state<>'open' or r.report_cycle<>d.report_cycle then raise exception 'Report is no longer awaiting review'; end if;
if p_decision is null or p_decision not in ('confirm','dismiss') or coalesce(length(trim(p_reason)),0) not between 10 and 2000 then raise exception 'Provide a review decision and feedback in 10–2000 characters'; end if;
if p_decision='confirm' then
if d.banned_at is not null then raise exception 'This map is already banned; dismiss the remaining report or review its appeal'; end if;
if p_reviewer=r.owner_id or p_reviewer=d.owner_id then raise exception 'An independent moderator must confirm this report'; end if;
-- Distinct reporters provide each strike, including legacy reports.
if exists(select 1 from public.reports where dataset_id=d.id and owner_id=r.owner_id and report_cycle=d.report_cycle and state='confirmed') then raise exception 'This reporter already contributed a strike'; end if;
strikes:=d.report_strikes+1;
update public.datasets set report_strikes=strikes,
state=case when strikes>=3 then 'unpublished' else state end,
update_state=case when strikes>=3 then null else update_state end,
banned_at=case when strikes>=3 then now() end,
ban_reason=case when strikes>=3 then trim(p_reason) end,updated_at=now() where id=d.id;
end if;
update public.reports set state=case when p_decision='confirm' then 'confirmed' else 'dismissed' end,
reviewer_id=p_reviewer,reviewed_at=now(),review_reason=trim(p_reason) where id=r.id;
end; $$;
create function public.appeal_map_ban(p_dataset uuid,p_owner uuid,p_reason text) returns uuid
language plpgsql security invoker set search_path='' as $$
declare d public.datasets; appeal uuid;
begin
select * into d from public.datasets where id=p_dataset and owner_id=p_owner and parent_dataset_id is null for update;
if not found then raise exception 'Map owner permission required'; end if;
if d.banned_at is null then raise exception 'This map has no ban to appeal'; end if;
if coalesce(length(trim(p_reason)),0) not between 10 and 2000 then raise exception 'Explain your appeal in 10–2000 characters'; end if;
if exists(select 1 from public.map_appeals where dataset_id=d.id and state='open') then raise exception 'An appeal is already awaiting review'; end if;
insert into public.map_appeals(dataset_id,owner_id,report_cycle,reason) values(d.id,p_owner,d.report_cycle,trim(p_reason)) returning id into appeal;
return appeal;
end; $$;
create function public.review_map_appeal(p_appeal uuid,p_reviewer uuid,p_decision text,p_reason text) returns void
language plpgsql security invoker set search_path='' as $$
declare a public.map_appeals; d public.datasets; map_id uuid;
begin
if not exists(select 1 from public.profiles where id=p_reviewer and role='moderator') then raise exception 'Moderator permission required'; end if;
select dataset_id into map_id from public.map_appeals where id=p_appeal;
select * into d from public.datasets where id=map_id for update;
select * into a from public.map_appeals where id=p_appeal for update;
if not found or a.state<>'open' or d.banned_at is null or a.report_cycle<>d.report_cycle then raise exception 'Appeal is no longer awaiting review'; end if;
if p_reviewer=d.owner_id then raise exception 'An independent moderator must review your appeal'; end if;
if p_decision is null or p_decision not in ('approve','reject') or coalesce(length(trim(p_reason)),0) not between 10 and 2000 then raise exception 'Provide an appeal decision and feedback in 10–2000 characters'; end if;
if p_decision='approve' then
if d.revision_id is null then raise exception 'Map has no approved version to restore'; end if;
update public.datasets set report_strikes=0,report_cycle=report_cycle+1,banned_at=null,ban_reason=null,state='published',updated_at=now() where id=d.id;
update public.reports set state='dismissed',reviewer_id=p_reviewer,reviewed_at=now(),review_reason='Closed when the map ban appeal was approved.' where dataset_id=d.id and report_cycle=d.report_cycle and state='open';
end if;
update public.map_appeals set state=case when p_decision='approve' then 'approved' else 'rejected' end,reviewer_id=p_reviewer,reviewed_at=now(),review_reason=trim(p_reason) where id=a.id;
end; $$;
revoke execute on function public.submit_content_report(uuid,uuid,uuid,text,text,text,text),public.review_content_report(uuid,uuid,text,text),public.appeal_map_ban(uuid,uuid,text),public.review_map_appeal(uuid,uuid,text,text) from public,anon,authenticated;
grant execute on function public.submit_content_report(uuid,uuid,uuid,text,text,text,text),public.review_content_report(uuid,uuid,text,text),public.appeal_map_ban(uuid,uuid,text),public.review_map_appeal(uuid,uuid,text,text) to service_role;

68
tests/moderation.test.ts Normal file
View File

@@ -0,0 +1,68 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { contributionCutoff, reportSchema } from "../shared/moderation";
const datasetId = "11111111-1111-4111-8111-111111111111";
const sessionId = "22222222-2222-4222-8222-222222222222";
const report = {
datasetId,
target: "map",
category: "broken",
reason: "Terrain is missing from the map.",
};
test("reports require an unambiguous target and a meaningful explanation", () => {
assert.equal(reportSchema.parse(report).target, "map");
for (const change of [
{ datasetId: undefined },
{ sessionId },
{ target: "panorama" },
{ reason: " " },
{ category: "unknown" },
])
assert.equal(
reportSchema.safeParse({ ...report, ...change }).success,
false,
);
assert.equal(
reportSchema.parse({ ...report, reason: " Terrain is missing. " }).reason,
"Terrain is missing.",
);
});
test("panorama reports identify the played session and round without client-provided location IDs", () => {
const panorama = {
...report,
datasetId: undefined,
sessionId,
target: "panorama",
round: 3,
};
assert.equal(reportSchema.safeParse(panorama).success, true);
for (const round of [undefined, -1, 5, 0.5])
assert.equal(reportSchema.safeParse({ ...panorama, round }).success, false);
});
test("evidence links allow only HTTP and HTTPS", () => {
for (const evidenceUrl of [
"https://example.com/proof",
"http://example.com/proof",
])
assert.equal(
reportSchema.safeParse({ ...report, evidenceUrl }).success,
true,
);
for (const evidenceUrl of [
"javascript:alert(1)",
"data:text/html,test",
"file:///secret",
"ftp://example.com",
])
assert.equal(
reportSchema.safeParse({ ...report, evidenceUrl }).success,
false,
);
});
test("contribution list cutoff is exactly five days and crosses month boundaries", () => {
assert.equal(
contributionCutoff(Date.parse("2026-10-03T12:34:56Z")),
"2026-09-28T12:34:56.000Z",
);
});

90
tests/sql/moderation.sql Normal file
View File

@@ -0,0 +1,90 @@
-- Self-contained fixtures; safe to run on the configured project (rolls back).
begin;
do $$
declare owner uuid; moderator uuid; reporter uuid; second uuid; third uuid; fourth uuid; fixture uuid; revision uuid; report uuid; appeal uuid; r2 uuid; r3 uuid; r4 uuid;
begin
-- Reuse accounts on the configured project; seed accounts in the isolated runtime.
if not exists(select 1 from auth.users) then
insert into auth.users(id) select gen_random_uuid() from generate_series(1,6);
end if;
select id into owner from auth.users order by id limit 1;
select id into moderator from auth.users where id<>owner order by id limit 1;
select id into reporter from auth.users where id not in (owner,moderator) order by id limit 1;
select id into second from auth.users where id not in (owner,moderator,reporter) order by id limit 1;
select id into third from auth.users where id not in (owner,moderator,reporter,second) order by id limit 1;
select id into fourth from auth.users where id not in (owner,moderator,reporter,second,third) order by id limit 1;
if fourth is null then raise exception 'Six fixture accounts are required'; end if;
insert into public.profiles(id) values(owner),(moderator),(reporter),(second),(third),(fourth) on conflict(id) do nothing;
update public.profiles set role='moderator' where id=moderator;
update public.profiles set role='contributor' where id in (owner,reporter,second,third,fourth);
insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
values(owner,'Report regression','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published',5) returning id into fixture;
insert into public.revisions(dataset_id,asset_root,manifest) values(fixture,fixture::text,'{"viewpoints":[{"id":"p0"}]}') returning id into revision;
update public.datasets set revision_id=revision where id=fixture;
report:=public.submit_content_report(fixture,reporter,revision,'p0','broken','The panorama has missing terrain.');
if (select report_strikes from public.datasets where id=fixture)<>0 then raise exception 'Unreviewed report added a strike'; end if;
begin
perform public.submit_content_report(fixture,reporter,revision,null,'spam','A second report from the same person.');
raise exception 'Duplicate report succeeded';
exception when others then if sqlerrm not like 'You already reported%' then raise; end if; end;
begin
perform public.submit_content_report(fixture,owner,revision,null,'spam','An owner reported their own map.');
raise exception 'Self-report succeeded';
exception when others then if sqlerrm<>'You cannot report your own map' then raise; end if; end;
begin
perform public.submit_content_report(fixture,second,revision,'unknown','broken','A panorama outside this revision.');
raise exception 'Invalid panorama accepted';
exception when others then if sqlerrm<>'Reported panorama not found' then raise; end if; end;
begin
perform public.review_content_report(report,owner,'confirm','Confirmed by the map owner.');
raise exception 'Unauthorized moderator succeeded';
exception when others then if sqlerrm<>'Moderator permission required' then raise; end if; end;
perform public.review_content_report(report,moderator,'dismiss','The reported terrain looks correct.');
if (select report_strikes from public.datasets where id=fixture)<>0 then raise exception 'Dismissed report added a strike'; end if;
report:=public.submit_content_report(fixture,reporter,revision,null,'spam','The map contains inappropriate advertisements.');
perform public.review_content_report(report,moderator,'confirm','Confirmed advertising throughout the map.');
begin
perform public.review_content_report(report,moderator,'confirm','Replayed moderation request.');
raise exception 'Repeated review added a strike';
exception when others then if sqlerrm<>'Report is no longer awaiting review' then raise; end if; end;
r2:=public.submit_content_report(fixture,second,revision,null,'privacy','The map displays personal information.');
r3:=public.submit_content_report(fixture,third,revision,'p0','inappropriate','Offensive content in this panorama.');
r4:=public.submit_content_report(fixture,fourth,revision,null,'spam','Another pending community report.');
perform public.review_content_report(r2,moderator,'confirm','Confirmed private information in the map.');
if not exists(select 1 from public.datasets where id=fixture and report_strikes=2 and state='published' and banned_at is null) then raise exception 'Map unpublished too early'; end if;
perform public.review_content_report(r3,moderator,'confirm','Confirmed offensive content in this panorama.');
if not exists(select 1 from public.datasets where id=fixture and report_strikes=3 and state='unpublished' and banned_at is not null) then raise exception 'Third strike did not ban map'; end if;
begin
perform public.create_game(fixture,'normal',null,'test-guest');
raise exception 'Banned map started a new game';
exception when others then if sqlerrm<>'Map unavailable' then raise; end if; end;
begin
perform public.submit_draft(fixture,owner);
raise exception 'Owner resubmitted a banned map';
exception when others then if sqlerrm<>'Draft is not ready for submission' then raise; end if; end;
begin
update public.datasets set state='published' where id=fixture;
raise exception 'Ban bypass succeeded';
exception when check_violation then null; end;
begin
perform public.appeal_map_ban(fixture,reporter,'Please restore someone else''s map.');
raise exception 'Non-owner appeal succeeded';
exception when others then if sqlerrm<>'Map owner permission required' then raise; end if; end;
appeal:=public.appeal_map_ban(fixture,owner,'Please reconsider these reports and restore my map.');
begin
perform public.appeal_map_ban(fixture,owner,'Another appeal while the first is pending.');
raise exception 'Duplicate appeal succeeded';
exception when others then if sqlerrm<>'An appeal is already awaiting review' then raise; end if; end;
perform public.review_map_appeal(appeal,moderator,'reject','The violations are present and the ban remains.');
if (select banned_at from public.datasets where id=fixture) is null then raise exception 'Rejected appeal lifted ban'; end if;
appeal:=public.appeal_map_ban(fixture,owner,'Here is new context explaining why the reports were mistaken.');
perform public.review_map_appeal(appeal,moderator,'approve','Evidence confirms the map should be restored.');
if not exists(select 1 from public.datasets where id=fixture and report_strikes=0 and state='published' and banned_at is null and report_cycle=1 and revision_id=revision) then raise exception 'Appeal did not restore approved version'; end if;
if (select state from public.reports where id=r4)<>'dismissed' then raise exception 'Stale reports survived appeal'; end if;
if (select count(*) from public.reports where dataset_id=fixture and state='confirmed')<>3 then raise exception 'Report history was lost'; end if;
perform public.submit_content_report(fixture,reporter,revision,null,'broken','A new issue after the approved appeal.');
if has_function_privilege('authenticated','public.review_content_report(uuid,uuid,text,text)','EXECUTE') or has_function_privilege('anon','public.appeal_map_ban(uuid,uuid,text)','EXECUTE') then raise exception 'Privileged function exposed'; end if;
if not (select relrowsecurity from pg_class where oid='public.map_appeals'::regclass) then raise exception 'Appeal RLS is disabled'; end if;
if has_table_privilege('authenticated','public.map_appeals','SELECT') or has_table_privilege('anon','public.map_appeals','INSERT') then raise exception 'Appeal history exposed to public API'; end if;
end; $$;
rollback;