diff --git a/package.json b/package.json index 0bb454b..e109605 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build:ui": "bun build public/ui.ts --outfile public/ui.js", "build:htmx": "cp node_modules/htmx.org/dist/htmx.min.js public/htmx.min.js", "backfill": "bun run scripts/backfill.ts", + "moderate": "bun run scripts/moderate.ts", "export-contributors": "bun run scripts/export-contributors.ts", "test": "bun test", "lint": "biome check src/ public/ tests/", diff --git a/scripts/moderate.ts b/scripts/moderate.ts new file mode 100644 index 0000000..da02db6 --- /dev/null +++ b/scripts/moderate.ts @@ -0,0 +1,167 @@ +#!/usr/bin/env bun + +// Appview moderation CLI. Hides abusive content from *our* reads without ever +// touching a PDS — the underlying wiki.lichen.* records stay live and resolvable +// by other clients. "Operator" = whoever can SSH to the box and run this against +// the DB at DB_PATH (prod: /var/lib/lichen/lichen.db). +// +// bun run moderate hide @alice.test/badwiki [--reason="spam"] [--by=julien] +// bun run moderate hide @alice.test/badwiki --user [--reason] [--by] # all wikis + the DID +// bun run moderate unhide @alice.test/badwiki # un-hide the wiki +// bun run moderate unhide @alice.test/badwiki --user # lift the DID ban +// bun run moderate list +// +// Read hide hides a single wiki (keyed by at_uri). --user bans the owner DID: +// every wiki it owns (now and future) is hidden, and its future writes are +// dropped (Phase 5). Hiding never deletes rows; unhide/unban fully reverses it. + +import { resolveHandleToDid } from "../src/lib/profile.ts"; +import { + banDid, + getWiki, + hideWiki, + listModeration, + unbanDid, + unhideWiki, +} from "../src/server/db/queries/index.ts"; + +const USAGE = `Usage: + bun run moderate hide <@handle/slug | did:…/slug> [--reason=…] [--by=…] + bun run moderate hide <@handle | did:…> --user [--reason=…] [--by=…] + bun run moderate unhide <@handle/slug | did:…/slug> + bun run moderate unhide <@handle | did:…> --user + bun run moderate list`; + +interface ParsedArgs { + command: "hide" | "unhide" | "list"; + target?: string; + user: boolean; + reason: string | null; + by: string; +} + +function parseArgs(argv: string[]): ParsedArgs { + const command = argv[0]; + if (command !== "hide" && command !== "unhide" && command !== "list") { + throw new Error(`unknown command: ${command ?? "(none)"}`); + } + + let target: string | undefined; + let user = false; + let reason: string | null = null; + let by = process.env["USER"] ?? "cli"; + + for (let i = 1; i < argv.length; i++) { + const arg = argv[i] ?? ""; + if (arg === "--user") { + user = true; + } else if (arg.startsWith("--reason=")) { + reason = arg.slice("--reason=".length); + } else if (arg === "--reason") { + reason = argv[++i] ?? null; + } else if (arg.startsWith("--by=")) { + by = arg.slice("--by=".length); + } else if (arg === "--by") { + by = argv[++i] ?? by; + } else if (arg.startsWith("--")) { + throw new Error(`unknown flag: ${arg}`); + } else if (target === undefined) { + target = arg; + } else { + throw new Error(`unexpected argument: ${arg}`); + } + } + + return { command, ...(target ? { target } : {}), user, reason, by }; +} + +// "@handle/slug", "did:…/slug", "@handle", or a raw "did:…" → owner + optional slug. +function splitTarget(target: string): { owner: string; slug?: string } { + const slash = target.indexOf("/"); + if (slash === -1) return { owner: target }; + return { owner: target.slice(0, slash), slug: target.slice(slash + 1) }; +} + +async function resolveDid(owner: string): Promise { + const did = await resolveHandleToDid(owner); + if (!did) throw new Error(`could not resolve "${owner}" to a DID`); + return did; +} + +async function run(args: ParsedArgs): Promise { + if (args.command === "list") { + const rows = listModeration(); + if (rows.length === 0) { + console.log("No moderation actions."); + return; + } + for (const r of rows) { + const meta = [ + r.moderator && `by ${r.moderator}`, + r.reason && `"${r.reason}"`, + ] + .filter(Boolean) + .join(" "); + console.log( + `${r.created_at} ${r.subject_type.padEnd(4)} ${r.subject}${meta ? ` ${meta}` : ""}`, + ); + } + return; + } + + if (!args.target) throw new Error(`${args.command} requires a target`); + const { owner, slug } = splitTarget(args.target); + const did = await resolveDid(owner); + + // --user: act on the owner DID. Hides every wiki it owns (now and future). + if (args.user) { + if (args.command === "hide") { + banDid(did, args.reason, args.by); + console.log( + `Banned DID ${did} (all wikis hidden, future writes dropped).`, + ); + } else { + unbanDid(did); + console.log(`Lifted DID ban for ${did}.`); + } + return; + } + + // Single wiki: keyed by at_uri. Prefer the DB row; else construct it so a + // not-yet-seen wiki can be pre-emptively hidden (rkey is the slug). + if (!slug) { + throw new Error( + `${args.command} needs a wiki slug (got "${args.target}"). Use --user to act on the whole account.`, + ); + } + const atUri = + getWiki(did, slug)?.at_uri ?? `at://${did}/wiki.lichen.wiki/${slug}`; + + if (args.command === "hide") { + hideWiki(atUri, args.reason, args.by); + console.log(`Hidden wiki ${atUri}.`); + } else { + unhideWiki(atUri); + console.log(`Un-hidden wiki ${atUri}.`); + } +} + +async function main(): Promise { + let args: ParsedArgs; + try { + args = parseArgs(process.argv.slice(2)); + } catch (err) { + console.error(`${String(err)}\n\n${USAGE}`); + return 2; + } + + try { + await run(args); + return 0; + } catch (err) { + console.error(String(err)); + return 1; + } +} + +process.exit(await main()); diff --git a/src/lib/access-resolve.ts b/src/lib/access-resolve.ts index 8bf7154..65accee 100644 --- a/src/lib/access-resolve.ts +++ b/src/lib/access-resolve.ts @@ -4,6 +4,7 @@ import { getMemberRole, getRequest, getWiki, + isWikiHidden, } from "../server/db/queries/index.ts"; import { resolveUserTheme } from "../views/theme/index.ts"; import { @@ -15,7 +16,7 @@ import { type WikiRequestContext, } from "./access.ts"; import { csrfTokenFor } from "./csrf.ts"; -import { ForbiddenError, NotFoundError } from "./errors.ts"; +import { ForbiddenError, NotFoundError, WikiRemovedError } from "./errors.ts"; import { resolveLocale } from "./i18n/index.ts"; import { resolveHandleToDid, resolveProfile } from "./profile.ts"; @@ -110,6 +111,13 @@ export async function resolveRequestContext( }; } + // Full takedown: every direct route funnels through here, so one check blocks + // wiki home, notes, edit, history, export, settings, members and sidebar — + // including for the owner. Appview-local only; the PDS records are untouched. + if (isWikiHidden(wiki)) { + throw new WikiRemovedError(undefined, { i18nKey: "wikiRemoved" }); + } + const role = did ? getMemberRole(wiki.at_uri, did) : null; const access = getAccessLevel(wiki, did, role); const hasPendingRequest = diff --git a/src/lib/errors.ts b/src/lib/errors.ts index a09240c..c91169c 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -45,6 +45,12 @@ export class ForbiddenError extends AppError { } } +export class WikiRemovedError extends AppError { + constructor(message = "This wiki has been removed", opts?: ErrorOpts) { + super(message, 410, opts); + } +} + export class PdsWriteError extends AppError { constructor(message: string, opts?: ErrorOpts) { super(message, 502, opts); diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 42223d8..19f2a03 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -236,9 +236,14 @@ export const en: Messages = { requestFailed: "Something went wrong. Please try again.", invalidThemeMode: "Invalid theme mode.", invalidTheme: "Invalid theme.", + wikiRemoved: "This wiki has been removed.", }, notFound: { heading: "Page not found", backHome: "Back to home", }, + removed: { + heading: "This wiki has been removed", + body: "This wiki was removed by the site operator.", + }, }; diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index 0621566..aca232e 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -227,11 +227,16 @@ export interface Messages { requestFailed: string; invalidThemeMode: string; invalidTheme: string; + wikiRemoved: string; }; notFound: { heading: string; backHome: string; }; + removed: { + heading: string; + body: string; + }; } export type PartialMessages = Partial< diff --git a/src/server/app.ts b/src/server/app.ts index f32f5c6..111f599 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -5,6 +5,7 @@ import { atprotoRoutes } from "../atproto/routes.ts"; import { AppError } from "../lib/errors.ts"; import { fmt, resolveLocale, t } from "../lib/i18n/index.ts"; import { notFoundPage } from "../views/not-found.ts"; +import { removedPage } from "../views/removed.ts"; import { canonicalHandlePlugin } from "./canonical-handle-plugin.ts"; import { getDb } from "./db/index.ts"; import { pruneExpiredAppSessions } from "./db/queries/index.ts"; @@ -61,6 +62,16 @@ export function buildApp() { error.i18nVars, ); } + if (error.statusCode === 410) { + const locale = resolveLocale( + request.headers.get("cookie"), + request.headers.get("accept-language"), + ); + return new Response(removedPage({ locale }), { + status: 410, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } return new Response(error.message, { status: error.statusCode }); } if (code === "NOT_FOUND") { diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index 0ae6cd9..bd87198 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -30,6 +30,15 @@ export { upsertMembership, upsertRequest, } from "./membership.ts"; +export { + banDid, + hideWiki, + isDidBanned, + isWikiHidden, + listModeration, + unbanDid, + unhideWiki, +} from "./moderation.ts"; export type { NoteSearchResult } from "./note.ts"; export { createNote, diff --git a/src/server/db/queries/moderation.ts b/src/server/db/queries/moderation.ts new file mode 100644 index 0000000..b9b63a1 --- /dev/null +++ b/src/server/db/queries/moderation.ts @@ -0,0 +1,93 @@ +import { getDb } from "../index.ts"; +import type { WikiRow } from "../types.ts"; + +export interface ModerationRow { + subject_type: "wiki" | "did"; + subject: string; + reason: string | null; + moderator: string | null; + created_at: string; +} + +function upsert( + subjectType: "wiki" | "did", + subject: string, + reason: string | null, + moderator: string | null, +): void { + const db = getDb(); + db.run( + `INSERT INTO moderation_actions (subject_type, subject, reason, moderator) + VALUES (?, ?, ?, ?) + ON CONFLICT(subject_type, subject) DO UPDATE SET + reason = excluded.reason, + moderator = excluded.moderator, + created_at = datetime('now')`, + [subjectType, subject, reason, moderator], + ); +} + +function remove(subjectType: "wiki" | "did", subject: string): void { + const db = getDb(); + db.run( + "DELETE FROM moderation_actions WHERE subject_type = ? AND subject = ?", + [subjectType, subject], + ); +} + +export function hideWiki( + atUri: string, + reason: string | null, + moderator: string | null, +): void { + upsert("wiki", atUri, reason, moderator); +} + +export function unhideWiki(atUri: string): void { + remove("wiki", atUri); +} + +export function banDid( + did: string, + reason: string | null, + moderator: string | null, +): void { + upsert("did", did, reason, moderator); +} + +export function unbanDid(did: string): void { + remove("did", did); +} + +// Hidden if the wiki itself is taken down, or its owner DID is banned. +export function isWikiHidden(wiki: Pick): boolean { + const db = getDb(); + const row = db + .query( + `SELECT 1 FROM moderation_actions + WHERE (subject_type = 'wiki' AND subject = ?) + OR (subject_type = 'did' AND subject = ?) + LIMIT 1`, + ) + .get(wiki.at_uri, wiki.did); + return row !== null; +} + +export function isDidBanned(did: string): boolean { + const db = getDb(); + const row = db + .query( + "SELECT 1 FROM moderation_actions WHERE subject_type = 'did' AND subject = ?", + ) + .get(did); + return row !== null; +} + +export function listModeration(): ModerationRow[] { + const db = getDb(); + return db + .query( + "SELECT subject_type, subject, reason, moderator, created_at FROM moderation_actions ORDER BY created_at DESC", + ) + .all() as ModerationRow[]; +} diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 290f934..471ea13 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -177,6 +177,21 @@ export function initSchema(db: Database): void { ) `); + // Appview-only moderation. A row hides content from our reads without touching + // any PDS — the underlying records stay live. Kept off the wikis row so it + // survives upsertWiki (fired on every wiki event) and the delete cascade, and + // so a DID can be banned before it owns any wiki. + db.run(` + CREATE TABLE IF NOT EXISTS moderation_actions ( + subject_type TEXT NOT NULL CHECK (subject_type IN ('wiki', 'did')), + subject TEXT NOT NULL, + reason TEXT, + moderator TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (subject_type, subject) + ) + `); + // Opaque app sessions. The prod session cookie (`sid`) holds one of these // random ids — never the user's DID, which is public and thus forgeable. Maps // to a DID + expiry; rows are pruned on read and at startup. (Dev mode has no diff --git a/src/views/removed.ts b/src/views/removed.ts new file mode 100644 index 0000000..3aafdc0 --- /dev/null +++ b/src/views/removed.ts @@ -0,0 +1,20 @@ +import { escapeHtml } from "../lib/html.ts"; +import { t } from "../lib/i18n/index.ts"; +import { type LayoutOptions, layout } from "./layout.ts"; +import { primaryButtonClass, THEME } from "./theme/index.ts"; + +// 410 tombstone for content taken down by the appview operator. The reason is +// deliberately not shown — it could itself be abusive or defamatory. +export function removedPage(options: LayoutOptions = {}): string { + const locale = options.locale ?? "en"; + const msg = t(locale); + + const body = `
+
410
+

${escapeHtml(msg.removed.heading)}

+

${escapeHtml(msg.removed.body)}

+ ${escapeHtml(msg.notFound.backHome)} +
`; + + return layout(msg.removed.heading, body, { ...options, noindex: true }); +} diff --git a/tests/integration/moderation.test.ts b/tests/integration/moderation.test.ts new file mode 100644 index 0000000..79db3c9 --- /dev/null +++ b/tests/integration/moderation.test.ts @@ -0,0 +1,81 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + banDid, + getWiki, + hideWiki, + unbanDid, + unhideWiki, +} from "../../src/server/db/queries/index.ts"; +import { cleanupWikiAndDependents } from "../helpers/cleanup.ts"; +import { + ALICE, + createDiff, + emitNote, + emitRevision, + emitWiki, +} from "./helpers.ts"; +import { fetch, loginCookie } from "./http-helpers.ts"; + +const WIKI_SLUG = "mod-takedown-wiki"; +const NOTE_SLUG = "mod-note"; +let wikiAtUri: string; + +beforeAll(() => { + cleanupWikiAndDependents(WIKI_SLUG); + const wiki = emitWiki(ALICE.did, "Takedown Wiki", "public", WIKI_SLUG); + wikiAtUri = wiki.uri; + const note = emitNote(ALICE.did, NOTE_SLUG, "Mod Note", wikiAtUri); + emitRevision(ALICE.did, note.uri, createDiff("", "secret content")); +}); + +afterAll(() => { + unhideWiki(wikiAtUri); + unbanDid(ALICE.did); + cleanupWikiAndDependents(WIKI_SLUG); +}); + +const wikiUrl = `/@${ALICE.handle}/${WIKI_SLUG}`; +const noteUrl = `/@${ALICE.handle}/${WIKI_SLUG}/${NOTE_SLUG}`; + +describe("wiki takedown via hideWiki", () => { + test("wiki and note are reachable before takedown", async () => { + expect((await fetch("GET", wikiUrl)).status).toBe(200); + expect((await fetch("GET", noteUrl)).status).toBe(200); + }); + + test("hidden wiki returns a 410 HTML tombstone on every direct route", async () => { + hideWiki(wikiAtUri, "abuse", "test"); + + const wikiRes = await fetch("GET", wikiUrl); + expect(wikiRes.status).toBe(410); + expect(wikiRes.headers.get("content-type")).toContain("text/html"); + + expect((await fetch("GET", noteUrl)).status).toBe(410); + expect((await fetch("GET", `${noteUrl}/edit`)).status).toBe(410); + expect((await fetch("GET", `${wikiUrl}/-/settings`)).status).toBe(410); + }); + + test("takedown blocks the owner too", async () => { + const cookie = await loginCookie(ALICE.handle); + expect((await fetch("GET", wikiUrl, { cookie })).status).toBe(410); + }); + + test("unhide restores access", async () => { + unhideWiki(wikiAtUri); + expect((await fetch("GET", wikiUrl)).status).toBe(200); + expect((await fetch("GET", noteUrl)).status).toBe(200); + }); +}); + +describe("DID ban via banDid", () => { + test("bans hide the owner's wiki, unban restores it", async () => { + // Sanity: the wiki row still exists; only reads are filtered. + expect(getWiki(ALICE.did, WIKI_SLUG)).not.toBeNull(); + + banDid(ALICE.did, "operator", "test"); + expect((await fetch("GET", wikiUrl)).status).toBe(410); + + unbanDid(ALICE.did); + expect((await fetch("GET", wikiUrl)).status).toBe(200); + }); +}); diff --git a/tests/server/db/queries/moderation.test.ts b/tests/server/db/queries/moderation.test.ts new file mode 100644 index 0000000..fddbf8e --- /dev/null +++ b/tests/server/db/queries/moderation.test.ts @@ -0,0 +1,99 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { getDb } from "../../../../src/server/db/index.ts"; +import { + banDid, + hideWiki, + isDidBanned, + isWikiHidden, + listModeration, + unbanDid, + unhideWiki, +} from "../../../../src/server/db/queries/index.ts"; + +const db = getDb(); + +const DID = "did:plc:modtest0000000000000000"; +const OTHER_DID = "did:plc:modtest1111111111111111"; +const AT_URI = `at://${DID}/wiki.lichen.wiki/badwiki`; +const OTHER_AT_URI = `at://${OTHER_DID}/wiki.lichen.wiki/finewiki`; + +afterAll(() => { + db.run("DELETE FROM moderation_actions WHERE subject IN (?, ?, ?, ?)", [ + AT_URI, + OTHER_AT_URI, + DID, + OTHER_DID, + ]); +}); + +describe("hideWiki / unhideWiki", () => { + test("round-trips a single wiki takedown", () => { + expect(isWikiHidden({ at_uri: AT_URI, did: DID })).toBe(false); + hideWiki(AT_URI, "spam", "julien"); + expect(isWikiHidden({ at_uri: AT_URI, did: DID })).toBe(true); + unhideWiki(AT_URI); + expect(isWikiHidden({ at_uri: AT_URI, did: DID })).toBe(false); + }); + + test("re-hiding refreshes reason and moderator", () => { + hideWiki(AT_URI, "spam", "alice"); + hideWiki(AT_URI, "abuse", "bob"); + const row = listModeration().find((r) => r.subject === AT_URI); + expect(row?.reason).toBe("abuse"); + expect(row?.moderator).toBe("bob"); + unhideWiki(AT_URI); + }); +}); + +describe("banDid / unbanDid", () => { + test("round-trips a DID ban", () => { + expect(isDidBanned(DID)).toBe(false); + banDid(DID, null, "julien"); + expect(isDidBanned(DID)).toBe(true); + unbanDid(DID); + expect(isDidBanned(DID)).toBe(false); + }); +}); + +describe("isWikiHidden truth table", () => { + test("hidden directly", () => { + hideWiki(AT_URI, null, null); + expect(isWikiHidden({ at_uri: AT_URI, did: DID })).toBe(true); + unhideWiki(AT_URI); + }); + + test("hidden via owner DID ban", () => { + banDid(DID, null, null); + expect(isWikiHidden({ at_uri: AT_URI, did: DID })).toBe(true); + // A different owner is unaffected by this DID ban. + expect(isWikiHidden({ at_uri: OTHER_AT_URI, did: OTHER_DID })).toBe(false); + unbanDid(DID); + }); + + test("both direct hide and DID ban", () => { + hideWiki(AT_URI, null, null); + banDid(DID, null, null); + expect(isWikiHidden({ at_uri: AT_URI, did: DID })).toBe(true); + unhideWiki(AT_URI); + expect(isWikiHidden({ at_uri: AT_URI, did: DID })).toBe(true); // still banned + unbanDid(DID); + }); + + test("neither", () => { + expect(isWikiHidden({ at_uri: AT_URI, did: DID })).toBe(false); + }); +}); + +describe("listModeration", () => { + test("returns wiki and did rows", () => { + hideWiki(AT_URI, "r1", "m1"); + banDid(DID, "r2", "m2"); + const rows = listModeration(); + const wikiRow = rows.find((r) => r.subject === AT_URI); + const didRow = rows.find((r) => r.subject === DID); + expect(wikiRow?.subject_type).toBe("wiki"); + expect(didRow?.subject_type).toBe("did"); + unhideWiki(AT_URI); + unbanDid(DID); + }); +});