feat(pages): report a public page, and let staff take one down

Merged
HA
1mo ago
+13437-20

Summary

  • Anyone looking at a public page can report it: a reason, optional details, and an optional email if they are signed out. Rate limited to 5 per hour per user or per reporter IP.
  • Staff can take a page down. A takedown deletes the page's usercontent manifest, which is the Worker's authorization source, so the document, its thumbnail, and its assets all stop resolving immediately. It also stops the page unfurling, refuses every write from the creator, and sweeps that page's open reports to upheld.
  • Reasons are a text column validated by zod, not a Postgres enum, so adding or renaming a moderation category is a one-line change rather than a migration.

Why / Context

Public pages are reachable by anyone with the link, and until now there was no way for a viewer to flag one or for us to act on it. This is the minimum needed to respond to a malicious or abusive page: a reporting path that does not require an account, and a takedown that actually ends serving rather than just hiding a link.

How It Works

Reporting. page.report is a publicProcedure, so a signed-out viewer can use it. The rate-limit key is the user id when signed in and an HMAC of the reporter IP otherwise, which keeps anonymous reporters limitable and groupable without storing their address. Reports are pinned to the version being served at the time, so a later publish does not move the evidence.

Takedown. takenDownAt on pages is the single flag, and every path that serves or mutates a page consults it:

PathBehavior once taken down
writePageManifestdeletes the manifest; the Worker 404s document, thumbnail and assets
page.publicViewreturns null
previewAccessreturns missing, so it stops unfurling even for its creator
assertPageWritableFORBIDDEN, so publish, update, assets and visibility changes are all refused
page.reportNOT_FOUND, so reports stop stacking up behind a decision already made

loadManifest in the usercontent Worker reads R2 per request with no cache and every route fails closed on a missing manifest, so the takedown takes effect on the next request rather than after a TTL.

Ordering. takedown runs the page flag and the report sweep in one transaction, then deletes the manifest and rolls the transaction back if that fails. The delete cannot move inside the transaction: writePageManifest reads the page on its own connection, would still see a live page, and would write a fresh manifest instead of deleting one. There is a comment at the call site saying so.

Design Decisions

  • reason is text, not a Postgres enum. ALTER TYPE ... ADD VALUE is cheap to run, but it still couples adding a moderation category to a migration and a deploy, and enum values can never be renamed or removed once shipped. Moderation categories get renamed. pageReportReasonValues is now the single source of truth: zod validates against it at the tRPC boundary, and the report dialog's labels are a Record keyed by PageReportReason, so a category added to the list and missed in the dialog is a compile error naming the missing one. Verified by temporarily adding a 9th reason: error TS2741: Property 'self_harm' is missing. The dialog's import is type-only, so nothing is added to the client bundle.
  • page_report_status stays an enum. open / upheld / dismissed is a real state machine that will not churn, and the database is the right place to enforce it.
  • Reporter IPs are stored hashed, never raw. Enough to rate limit and to group reports from one source, without holding an address.

Manual QA Checklist

Reporting

  • Report button appears on a public page for both signed-in and signed-out viewers
  • Submitting with only a reason succeeds; the dialog switches to the confirmation state
  • The email field appears only when signed out, and a malformed address is rejected
  • Details longer than 4000 characters are rejected
  • The 6th report within an hour returns "Too many reports. Try again later."

Takedown

  • After page.takedown, the public page URL no longer renders the content
  • The document, thumbnail and asset URLs all 404 rather than serving from cache
  • A Slack/Discord unfurl of the page stops previewing, including for its creator
  • The creator cannot publish, update, change visibility, or upload assets to the page
  • That page's open reports are now upheld with reviewedAt and reviewedByUserId set
  • page.restore brings the page back and clears the takedown note

Authorization

  • listReports / takedown / restore / reviewReport / reportCounts return FORBIDDEN for a non-staff account
  • Reporting a slug that does not exist, is not public, or is already taken down returns NOT_FOUND

Migration

  • 0129_page_reports_and_takedown.sql applies cleanly on a fresh DB and on a branch with existing pages
  • Existing pages are unaffected: taken_down_at is NULL and they serve normally

Testing

  • bun run typecheck --force (all 42 packages)
  • bun run lint
  • bun run check:i18n (16 locales, 0 missing of 5646)
  • bunx sherif
  • bun run test: @superset/trpc 392 pass / 0 fail, @superset/web 53 pass / 0 fail

Pre-existing failures unrelated to this branch, left alone: 41 jest.advanceTimersByTime is not a function errors in @superset/host-service git-watcher and @superset/desktop terminal-ws-transport under bun 1.3.2, plus a @superset/workspace-fs pathTypes timing test that flakes under full-suite load and passes 3/3 in isolation.

Risks / Rollout

Migration lock. The last statement, ALTER TABLE pages ADD CONSTRAINT pages_taken_down_by_user_id_users_id_fk, validates the new FK against every row in pages. Because drizzle runs all pending migrations in one transaction, the ACCESS EXCLUSIVE lock taken on pages by the earlier ADD COLUMN statements is held through that scan, so reads and writes of pages block for its duration. It also holds SHARE ROW EXCLUSIVE on auth.users from the page_reports FK onward, which blocks writes to auth.users (reads are unaffected, ROW SHARE does not conflict). pages is under 1M rows, so the scan is sub-second and this is safe to run under load. If pages grows past a few million, split the constraint into ADD CONSTRAINT ... NOT VALID plus a later VALIDATE CONSTRAINT.

Rollback. The feature is additive. Reverting the code leaves taken_down_at set on any page taken down in the interim, which would be ignored by the reverted code, so any such page would start serving again; re-publishing it would also restore its manifest. To stop impact without a revert, call page.restore.

Migration numbering. Originally generated as 0127; renumbered to 0128 after 0127_environments_region landed on main, then to 0129 after 0128_drop_environments_name_unique landed. Each renumber was regenerated with drizzle-kit against the merged schema; the SQL is unchanged. The PR's Neon preview branch predates the renumber and must be deleted so the preview migration check does a real run.

Known Limitations

  • No admin UI yet. listReports, reviewReport, takedown, restore and reportCounts exist and are staff-gated but have no caller, so a takedown currently means invoking the tRPC procedure directly. The UI is a deliberate follow-up.
  • No dedup on reports: the same reporter can file repeatedly on the same page across rate-limit windows.
  • reporterIp reads x-forwarded-for the same way four other call sites in the repo already do. That header is only trustworthy because the edge overwrites it; the rate limit inherits that assumption.

Follow-ups

  • Admin reports queue in apps/admin, following the existing (dashboard)/growth tile pattern.
  • Notify the page owner when their page is taken down. Right now takenDownNote is recorded but never surfaced.

Summary by cubic

Anyone viewing a public page can now report it, and staff can take a page down so it stops being served on the next request.

Reporting

  • Any signed-out viewer can report a page with a reason, optional details, and an optional email; no account needed.
  • Rate limited to 5 reports per hour per user or per reporter IP; reporter IPs are stored hashed, never raw.
  • Report reasons are a text column validated by zod, so adding or renaming a moderation category is a one-line change, not a migration.
  • A signed-in reporter's email is ignored — the account is already the contact — and server errors surface in the viewer's locale.

Takedown

  • A staff takedown deletes the page's usercontent manifest, which 404s the document, thumbnail, and assets on the next request and stops it unfurling, even for its creator.
  • The creator can no longer publish, update, or change visibility once taken down; those writes also require takenDownAt IS NULL, closing the race against a concurrent takedown. Open reports on the page are swept to upheld.
  • restore brings the page back; the staff procedures have no admin UI yet, so a takedown currently means invoking the tRPC procedure directly.
  • Migration 0129 is additive — a new page_reports table and three nullable columns on pages, leaving existing pages unaffected.

Summary by CodeRabbit

  • New Features

    • Added a way to report public pages, with selectable reasons and optional details; signed-out visitors can also provide an email address.
    • Added notices explaining that public page content is unverified and identifying its publisher.
    • Added tools for administrators to review reports and take down or restore pages.
  • Bug Fixes

    • Taken-down pages are no longer available in public views or editable, and their shared manifests are removed.
  • Documentation

    • Added translations for reporting flows and page notices in supported languages.