From 27ebc9510917f38edc0c001627f072136417c64f Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 15 Sep 2026 13:11:19 -0400 Subject: [PATCH] [www] beta mod panel --- apps/api/migrations/0020_report_ban_audit.sql | 24 + apps/api/src/bans-db.ts | 91 ++ apps/api/src/reports-db.ts | 242 +++- apps/api/src/routes/moderation.ts | 26 +- apps/api/src/test/integration/api.test.ts | 259 +++- apps/www/src/client/App.tsx | 242 +--- apps/www/src/client/Moderation.tsx | 1228 +++++++++++++++++ apps/www/src/client/api.ts | 257 ++++ apps/www/src/client/styles.css | 150 ++ apps/www/src/context.ts | 41 +- apps/www/src/staff.ts | 413 ++++++ apps/www/src/test/integration/api.test.ts | 470 ++++++- apps/www/src/www.app.ts | 35 + apps/www/vitest.config.ts | 43 + apps/www/wrangler.jsonc | 26 +- 15 files changed, 3299 insertions(+), 248 deletions(-) create mode 100644 apps/api/migrations/0020_report_ban_audit.sql create mode 100644 apps/www/src/client/Moderation.tsx create mode 100644 apps/www/src/client/api.ts create mode 100644 apps/www/src/staff.ts diff --git a/apps/api/migrations/0020_report_ban_audit.sql b/apps/api/migrations/0020_report_ban_audit.sql new file mode 100644 index 0000000..4e86563 --- /dev/null +++ b/apps/api/migrations/0020_report_ban_audit.sql @@ -0,0 +1,24 @@ +-- Who handed a ban down, and when. A ban lives on the `report` row it rests on (see +-- 0009_report_ban.sql), which recorded the decision but not the deciding: there was no way +-- to tell which moderator set `banned`, and no way to tell WHEN they set it. Generated from +-- src/reports-db.ts (SCHEMA_DDL) — keep in sync. +-- +-- `banned_at` is not bookkeeping. The client's block screen reads `TimeoutStartedAt` and +-- `Duration` as a PAIR — the block runs from the start for the duration — and +-- `moderationBlockDetails` had only the report's `created_at` to offer as that start. So a +-- ban applied to a month-old report told the banned player their block began a month ago, +-- and the duration (computed from there to `ban_expires`) came out a month short: a 7-day +-- ban on a 30-day-old report reads as already served. With this column the ban's own start +-- is recorded and `banBlockDetails` uses it, falling back to `created_at` so every row +-- written before this migration keeps behaving exactly as it did. +-- +-- Both are NULL on an unbanned row, and are CLEARED when a ban is lifted — the report +-- itself survives, as it does for `banned`/`ban_expires`. So a non-null `banned_at` means +-- "a ban is or was in force from here", which is the same thing `banned = 1` means. +-- +-- Neither column is indexed. `banned_by_player_id` is read per-row when a moderator looks +-- at a ban, never filtered on; the ban reads that exist go by player or by the ban flag +-- (idx_report_banned). Add an index alongside a query that needs one. + +ALTER TABLE report ADD COLUMN banned_by_player_id INTEGER; +ALTER TABLE report ADD COLUMN banned_at TEXT; diff --git a/apps/api/src/bans-db.ts b/apps/api/src/bans-db.ts index 7ffeea9..53bc9e1 100644 --- a/apps/api/src/bans-db.ts +++ b/apps/api/src/bans-db.ts @@ -206,3 +206,94 @@ export async function isPlayerBlocked( ): Promise { return (await resolveBan(db, accountId, options)) !== null } + +/** + * An account a ban on someone else would also reach, and what links the two. + * + * `via` is never `'account'` here: this is the answer to "who ELSE", so the account asked + * about is excluded by the query rather than returned as a match on itself. + */ +export interface LinkedAccount { + accountId: number + username: string | null + via: Exclude + /** + * What matched — the shared platform id, or the shared IP address. Shown to the + * moderator so an IP match can be judged on its face: an address that reads as a + * carrier or campus range is a different proposition from a residential one. + */ + value: string +} + +/** + * Which OTHER accounts a ban on `accountId` would also block — the preview a moderator + * sees before handing one down (`www`: `GET /api/staff/players/:id/linked`). + * + * This is `resolveBan` read backwards. That function asks "does any ban reach this + * caller", driving from the few banned reports inward, because it runs on every matchmake + * and every token grant. This one asks "who would this ban reach", driving from one + * account's identities outward — a question with no hot path, asked once when a moderator + * is about to act, and the only way to see the IP arm's blast radius BEFORE it lands + * rather than from a support ticket afterwards. + * + * Honours the same `arms`, so a preview on a server running `BAN_EVASION_MATCH=platform` + * doesn't list households that would not in fact be blocked. A disabled arm contributes + * nothing rather than being listed-but-flagged: the question is who gets blocked, and the + * answer under that setting is that they don't. + * + * The username comes off the account blob, which is `auth`'s — read here for the same + * reason the resolution query reads it: the moderator needs to know WHO, and an id alone + * makes the preview unreadable. A missing blob yields null rather than dropping the row; + * the block would still reach that account. + * + * An account matched by BOTH arms appears twice, once per arm. Deliberate: the arms are + * separate evidence, and "shares a Steam identity AND an address" is worth seeing as two + * lines. Platform rows sort first, being the arm that proves more. + */ +export async function linkedAccounts( + db: D1Database, + accountId: number, + arms: BanMatchArms = DEFAULT_BAN_MATCH_ARMS +): Promise { + if (!arms.ip && !arms.platform) return [] + + const { results } = await db + .prepare( + `WITH me AS ( + SELECT + NULLIF(json_extract(data, '$.signupIp'), '') AS signup_ip, + NULLIF(json_extract(data, '$.lastLoginIp'), '') AS last_login_ip + FROM account WHERE account_id = ?1 + ), + ips AS ( + SELECT signup_ip AS ip FROM me WHERE signup_ip IS NOT NULL + UNION SELECT last_login_ip FROM me WHERE last_login_ip IS NOT NULL + ), + ids AS ( + SELECT platform, platform_id FROM platform_account WHERE account_id = ?1 + ) + SELECT p.account_id AS accountId, + json_extract(a.data, '$.username') AS username, + 'platform' AS via, + p.platform_id AS value + FROM platform_account p + JOIN ids ON ids.platform = p.platform AND ids.platform_id = p.platform_id + LEFT JOIN account a ON a.account_id = p.account_id + WHERE ?2 = 1 AND p.account_id <> ?1 + UNION + SELECT a.account_id AS accountId, + json_extract(a.data, '$.username') AS username, + 'ip' AS via, + ips.ip AS value + FROM account a, ips + WHERE ?3 = 1 AND a.account_id <> ?1 + AND ips.ip IN ( + json_extract(a.data, '$.signupIp'), + json_extract(a.data, '$.lastLoginIp') + ) + ORDER BY via DESC, accountId` + ) + .bind(accountId, arms.platform ? 1 : 0, arms.ip ? 1 : 0) + .all() + return results +} diff --git a/apps/api/src/reports-db.ts b/apps/api/src/reports-db.ts index f529306..f0c02bb 100644 --- a/apps/api/src/reports-db.ts +++ b/apps/api/src/reports-db.ts @@ -7,9 +7,16 @@ * what a player submitted: the table is a log of exactly what was reported. * * The `api` worker owns this schema/migration (migrations/0004_report.sql, - * 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql and - * 0017_report_custom_avatar_item.sql, applied under its own `migrations_table` so it - * doesn't clash with the other workers' migrations that share the database). + * 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql, + * 0017_report_custom_avatar_item.sql and 0020_report_ban_audit.sql, applied under its own + * `migrations_table` so it doesn't clash with the other workers' migrations that share the + * database). + * + * The moderation-side READS here — `searchReports`, `getTopReported`, `getBansInForce` — + * are not called by any endpoint on this worker. They back the staff panel served by + * `www` (`/api/staff/*`), which imports them: the endpoints are a recflare addition with + * no counterpart in the real service, and those live on `www` rather than on the workers + * that reimplement the game's own API. The SQL stays here, with the table that owns it. * * A reported player EVENT, INVENTION or CUSTOM AVATAR ITEM lands here too, rather than in a * table of its own: same fields, same moderation life. Such a row carries `event_id`, @@ -32,7 +39,8 @@ /** * Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql + - * 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql). + * 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql + + * 0020_report_ban_audit.sql). * * None of `event_id`, `invention_id` or `custom_avatar_item_id` is indexed: each is written * on every report of its kind and read by nothing — no query here filters on any of them, @@ -56,7 +64,9 @@ export const SCHEMA_DDL: string[] = [ ban_expires TEXT, event_id INTEGER, invention_id INTEGER, - custom_avatar_item_id TEXT + custom_avatar_item_id TEXT, + banned_by_player_id INTEGER, + banned_at TEXT )`, `CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`, `CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`, @@ -103,6 +113,18 @@ export interface ReportRow { * it does not know who made it. */ custom_avatar_item_id: string | null + /** + * The moderator who set `banned` on this row, or NULL when nobody has (and cleared + * again when a ban is lifted). Read per-row by the staff panel; nothing filters on it. + */ + banned_by_player_id: number | null + /** + * ISO-8601 UTC instant the ban was HANDED DOWN — distinct from `created_at`, which is + * when the report was filed, and the two can be months apart. This is what the client's + * block screen counts the ban from (see `banBlockDetails`); rows written before + * 0020_report_ban_audit.sql carry NULL and fall back to `created_at`. + */ + banned_at: string | null } /** @@ -159,7 +181,11 @@ export async function createReport(db: D1Database, input: NewReport): Promise { const { results } = await db .prepare('SELECT * FROM report WHERE reported_player_id = ?1 ORDER BY id DESC') @@ -217,6 +243,12 @@ export async function isPlayerBanned( * permanent ban. Passing `banned: false` lifts the ban and clears the expiry, leaving the * report itself intact. * + * `bannedBy` is the acting moderator, and is recorded alongside `banned_at` — the instant + * the ban was handed down, which is NOT the report's `created_at` and is what the client's + * block screen counts from. A lift clears all four columns together: an unbanned row must + * not keep an audit trail saying a ban runs from somewhere, and `banned = 0` with a + * `banned_at` still set would read as a ban to anything checking the timestamp. + * * Returns the updated row, or null when there is no report with that id — so the caller * can tell "banned" from "banned nobody" (wrangler's `d1 execute --json` reports no * changes count, hence RETURNING). @@ -224,11 +256,203 @@ export async function isPlayerBanned( export async function banFromReport( db: D1Database, reportId: number, - options: { banned?: boolean; banExpires?: string | null } = {} + options: { banned?: boolean; banExpires?: string | null; bannedBy?: number | null } = {} ): Promise { const banned = options.banned ?? true return db - .prepare('UPDATE report SET banned = ?2, ban_expires = ?3 WHERE id = ?1 RETURNING *') - .bind(reportId, banned ? 1 : 0, banned ? (options.banExpires ?? null) : null) + .prepare( + `UPDATE report + SET banned = ?2, ban_expires = ?3, banned_by_player_id = ?4, banned_at = ?5 + WHERE id = ?1 + RETURNING *` + ) + .bind( + reportId, + banned ? 1 : 0, + banned ? (options.banExpires ?? null) : null, + banned ? (options.bannedBy ?? null) : null, + banned ? new Date().toISOString() : null + ) .first() } + +/** + * A page of reports matching a moderator's filters — the staff panel's search + * (`www`: `GET /api/staff/reports`). + * + * Every filter is optional and ANDed; an empty filter set is the whole table, newest + * first. `ORDER BY id DESC` rather than by `created_at`: the id is AUTOINCREMENT, so it is + * already chronological, and it is the primary key — ordering by the timestamp column, + * which is unindexed, would sort the whole result set. The date window is applied to + * `created_at` (an ISO-8601 string, so a lexical comparison IS a chronological one) and + * `to` is EXCLUSIVE, which is what makes a day-boundary window composable. + * + * `banned` filters on the FLAG, not on whether a ban is in force: a moderator reviewing + * what has been actioned wants the expired ones too (`getBansInForce` is the other + * question). The count is a second statement rather than a window function, so the caller + * can page without re-deriving the total — it counts the same predicate, unpaged. + */ +export interface ReportSearch { + reportedPlayerId?: number | null + reporterPlayerId?: number | null + reportCategory?: number | null + /** true = banned rows only, false = unbanned only, null/undefined = both. */ + banned?: boolean | null + /** ISO-8601; inclusive lower bound on `created_at`. */ + from?: string | null + /** ISO-8601; EXCLUSIVE upper bound on `created_at`. */ + to?: string | null +} + +/** A page of search results, plus how many rows the filters match in total. */ +export interface ReportPage { + reports: ReportRow[] + total: number +} + +export async function searchReports( + db: D1Database, + filters: ReportSearch = {}, + page: { skip?: number; take?: number } = {} +): Promise { + // Built as a parallel list of clauses and binds so a filter is added in one place and + // can't drift between the page query and the count query, which share both. + const clauses: string[] = [] + const binds: Array = [] + const where = (sql: string, value: number | string) => { + binds.push(value) + clauses.push(sql.replace('?', `?${binds.length}`)) + } + + if (filters.reportedPlayerId != null) where('reported_player_id = ?', filters.reportedPlayerId) + if (filters.reporterPlayerId != null) where('reporter_player_id = ?', filters.reporterPlayerId) + if (filters.reportCategory != null) where('report_category = ?', filters.reportCategory) + if (filters.banned != null) where('banned = ?', filters.banned ? 1 : 0) + if (filters.from) where('created_at >= ?', filters.from) + if (filters.to) where('created_at < ?', filters.to) + + const predicate = clauses.length === 0 ? '' : ` WHERE ${clauses.join(' AND ')}` + // Clamped rather than trusted: `take` reaches this from a query string, and an + // unbounded one would hand a moderator's browser the entire table. + const take = Math.min(Math.max(page.take ?? 50, 1), 200) + const skip = Math.max(page.skip ?? 0, 0) + + const [rows, count] = await db.batch([ + db + .prepare( + `SELECT * FROM report${predicate} ORDER BY id DESC LIMIT ?${binds.length + 1} OFFSET ?${binds.length + 2}` + ) + .bind(...binds, take, skip), + db.prepare(`SELECT COUNT(*) AS total FROM report${predicate}`).bind(...binds), + ]) + + return { + reports: rows.results as ReportRow[], + total: (count.results as Array<{ total: number }>)[0]?.total ?? 0, + } +} + +/** One report by id, or null. The detail behind a search row. */ +export async function getReportById(db: D1Database, reportId: number): Promise { + return db.prepare('SELECT * FROM report WHERE id = ?1').bind(reportId).first() +} + +/** A player who has collected reports, as the staff panel's triage list shows them. */ +export interface ReportedPlayerTally { + playerId: number + /** Reports filed against them inside the window. */ + reports: number + /** + * How many DIFFERENT accounts filed those reports. The number that actually means + * something: one player filing forty reports against someone they are feuding with + * should not outrank four unconnected players filing one each. + */ + distinctReporters: number + /** `created_at` of the most recent report in the window. */ + lastReportAt: string + /** Whether a ban is in force against them right now — so the list can skip the done ones. */ + bannedNow: boolean +} + +/** + * The players with the most reports against them — the staff panel's triage list + * (`www`: `GET /api/staff/reports/top-reported`). + * + * WINDOWED by default (30 days), because the unwindowed version ossifies: an account that + * collected reports a year ago and stopped would sit at the top forever, above whoever is + * a problem this week. Pass `sinceDays: null` for all time. + * + * Ranked by distinct reporters FIRST and raw count second, for the reason + * `distinctReporters` exists at all. `minReports` keeps the long tail of single reports + * out — almost every report is a one-off, and a list of them is not a triage list. + * + * `GROUP BY reported_player_id` rides `idx_report_reported`. The in-force ban is decided + * in the same statement (a correlated EXISTS over the partial `idx_report_banned`) rather + * than with a round trip per player. + */ +export async function getTopReported( + db: D1Database, + options: { sinceDays?: number | null; minReports?: number; take?: number; now?: Date } = {} +): Promise { + const now = options.now ?? new Date() + const sinceDays = options.sinceDays === undefined ? 30 : options.sinceDays + // An all-time window is expressed as a floor no timestamp can precede, so the SQL keeps + // one shape rather than growing a second predicate. + const since = + sinceDays === null + ? '0000-01-01T00:00:00.000Z' + : new Date(now.getTime() - sinceDays * 86_400_000).toISOString() + const minReports = Math.max(options.minReports ?? 3, 1) + const take = Math.min(Math.max(options.take ?? 50, 1), 200) + + const { results } = await db + .prepare( + `SELECT + r.reported_player_id AS playerId, + COUNT(*) AS reports, + COUNT(DISTINCT r.reporter_player_id) AS distinctReporters, + MAX(r.created_at) AS lastReportAt, + EXISTS ( + SELECT 1 FROM report b + WHERE b.reported_player_id = r.reported_player_id AND b.banned = 1 + AND (b.ban_expires IS NULL OR b.ban_expires > ?2) + ) AS bannedNow + FROM report r + WHERE r.created_at >= ?1 + GROUP BY r.reported_player_id + HAVING COUNT(*) >= ?3 + ORDER BY distinctReporters DESC, reports DESC, lastReportAt DESC + LIMIT ?4` + ) + .bind(since, now.toISOString(), minReports, take) + .all & { bannedNow: number }>() + + return results.map((row) => ({ ...row, bannedNow: row.bannedNow === 1 })) +} + +/** + * Every ban in force right now, longest-lasting first — the staff panel's standing-bans + * list (`www`: `GET /api/staff/bans`). + * + * "In force" is `getActiveBan`'s test applied to the whole table rather than to one + * player: a row whose `ban_expires` has passed has served its time and is not listed, + * though it stays as the record that it happened. A player with several in force appears + * once per ban — each is a separate report, and which report justified which ban is the + * point of the list. + */ +export async function getBansInForce( + db: D1Database, + now: Date = new Date(), + take = 200 +): Promise { + const { results } = await db + .prepare( + `SELECT * FROM report + WHERE banned = 1 AND (ban_expires IS NULL OR ban_expires > ?1) + ORDER BY ban_expires IS NOT NULL, ban_expires DESC, id DESC + LIMIT ?2` + ) + .bind(now.toISOString(), Math.min(Math.max(take, 1), 500)) + .all() + return results +} diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 21b763c..ad169b3 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -262,10 +262,19 @@ const NOT_BLOCKED = { * The block details for a ban in force — the `report` row a moderator set `banned` on. * * `Duration` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the - * start for the duration. The start is the report's `created_at` — nothing records when - * the ban itself was handed down, and the report is the record the ban rests on — and the - * duration is the seconds from there to `ban_expires`, so the two sum to the expiry; or - * `PERMANENT_BAN_DURATION` when there is none. The category is the one the report was + * start for the duration. The start is `banned_at`, the instant the ban was handed down + * (see 0020_report_ban_audit.sql), and the duration is the seconds from there to + * `ban_expires`, so the two sum to the expiry; or `PERMANENT_BAN_DURATION` when there is + * none. + * + * It falls back to the report's `created_at` for a row banned before that column existed. + * The two can be months apart, and using `created_at` as the start — which is what this + * did before there was anything else to use — misreports both halves of the pair: a 7-day + * ban applied to a 30-day-old report told the player their block began a month ago and + * ended three weeks ago. Every pre-migration row still reads exactly as it used to, which + * is the point of the fallback rather than a coalesce to now. + * + * The category is the one the report was * filed under, so the client's ban screen names the reason. `Message` is a fixed "Rule * violation" rather than the report's `details` — those are the REPORTER's words, and the * banned player isn't shown them, for the same reason `PlayerIdReporter` stays null: the @@ -274,7 +283,8 @@ const NOT_BLOCKED = { * screen dressings, none of which this server hands out. */ function banBlockDetails(ban: ReportRow) { - const startedAt = Date.parse(ban.created_at) + const startedAtIso = ban.banned_at ?? ban.created_at + const startedAt = Date.parse(startedAtIso) const duration = ban.ban_expires === null ? PERMANENT_BAN_DURATION @@ -285,7 +295,7 @@ function banBlockDetails(ban: ReportRow) { Duration: duration, IsBan: true, Message: 'Rule violation', - TimeoutStartedAt: ban.created_at, + TimeoutStartedAt: startedAtIso, } } @@ -321,7 +331,9 @@ export const moderationRoutes = new Hono({ strict: false }) 'screen) — so a caller with one in force gets ' + '`IsBan: true`, the `ReportCategory` the report was filed under, the fixed ' + '`Message` “Rule violation”, and the block’s span as the pair the client reads ' + - 'them as: `TimeoutStartedAt` is the report’s `created_at` and `Duration` the ' + + 'them as: `TimeoutStartedAt` is when the ban was handed down (`banned_at`, ' + + 'falling back to the report’s `created_at` for a ban set before that column ' + + 'existed) and `Duration` the ' + 'seconds from there to `ban_expires` (2147483647, the int32 max, for a permanent ' + 'ban). ' + '`PlayerIdReporter` stays null: it names a kicking host, and the reporter is not ' + diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 9da33ca..7708135 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -47,9 +47,13 @@ import { banFromReport, createReport, getActiveBan, + getBansInForce, + getReportById, getReportsAgainst, + getTopReported, isPlayerBanned, SCHEMA_DDL as REPORTS_SCHEMA_DDL, + searchReports, } from '../../reports-db' import { CheerCategory, @@ -4677,6 +4681,183 @@ describe('player reports', () => { expect(await banFromReport(env.DB, 999_999)).toBeNull() }) + // The audit trail 0020 added: who handed the ban down, and when. `banned_at` is not + // bookkeeping — it is what the client's block screen counts the ban from (see + // `banBlockDetails`), which is why a lift has to clear it along with the flag. + test('banFromReport records the acting moderator and the instant it landed', async () => { + await submit({ PlayerIdReported: '216' }, await bearer()) + const [row] = await getReportsAgainst(env.DB, 216) + // An unactioned report carries neither. + expect(row).toMatchObject({ banned_by_player_id: null, banned_at: null }) + + const before = Date.now() + const banned = await banFromReport(env.DB, row!.id, { bannedBy: 9001 }) + expect(banned!.banned_by_player_id).toBe(9001) + expect(Date.parse(banned!.banned_at!)).toBeGreaterThanOrEqual(before) + // Stamped by the write, so it is the ban's own time rather than the report's. + expect(banned!.banned_at).not.toBe(row!.created_at) + + // A lift clears all four together. Leaving `banned_at` behind on an unbanned row + // would read as a ban to anything checking the timestamp. + const lifted = await banFromReport(env.DB, row!.id, { banned: false }) + expect(lifted).toMatchObject({ + banned: 0, + ban_expires: null, + banned_by_player_id: null, + banned_at: null, + }) + }) + + // The moderation READS — searchReports / getTopReported / getBansInForce. No endpoint + // on this worker calls them: they back the staff panel `www` serves (`/api/staff/*`), + // which imports them, because the panel is a recflare addition with no counterpart in + // the real service while the SQL belongs with the table. Tested here, where the table + // and its migrations live. + describe('moderation reads', () => { + test('searchReports filters, orders newest-first and counts the full match', async () => { + const target = 3300 + await createReport(env.DB, { + reporterPlayerId: 3301, + reportedPlayerId: target, + reportCategory: 101, + details: 'one', + }) + await createReport(env.DB, { + reporterPlayerId: 3302, + reportedPlayerId: target, + reportCategory: 102, + details: 'two', + }) + await createReport(env.DB, { reporterPlayerId: 3301, reportedPlayerId: 3399 }) + + const byPlayer = await searchReports(env.DB, { reportedPlayerId: target }) + expect(byPlayer.total).toBe(2) + // `ORDER BY id DESC` — the id is AUTOINCREMENT, so it is already chronological + // and is the primary key, where `created_at` is unindexed. + expect(byPlayer.reports.map((r) => r.details)).toEqual(['two', 'one']) + + expect((await searchReports(env.DB, { reporterPlayerId: 3302 })).total).toBe(1) + expect( + (await searchReports(env.DB, { reportedPlayerId: target, reportCategory: 101 })).total + ).toBe(1) + + // `take` pages the ROWS; `total` stays the unpaged count of the same predicate, so + // a pager can say "3–4 of 9" without asking twice. + const page = await searchReports(env.DB, { reportedPlayerId: target }, { take: 1 }) + expect(page.reports).toHaveLength(1) + expect(page.total).toBe(2) + }) + + test('searchReports filters on the ban FLAG, expired bans included', async () => { + const report = await createReport(env.DB, { + reporterPlayerId: 3311, + reportedPlayerId: 3310, + }) + // Banned, and the ban has since run out — so it is not in force, but it IS a + // report that was actioned, which is what a moderator reviewing their own work + // is looking for. + await banFromReport(env.DB, report.id, { banExpires: '2020-01-01T00:00:00.000Z' }) + + expect((await searchReports(env.DB, { reportedPlayerId: 3310, banned: true })).total).toBe(1) + expect((await searchReports(env.DB, { reportedPlayerId: 3310, banned: false })).total).toBe(0) + // An omitted filter is BOTH, not false. + expect((await searchReports(env.DB, { reportedPlayerId: 3310 })).total).toBe(1) + }) + + test('searchReports windows on created_at, with an exclusive upper bound', async () => { + const report = await createReport(env.DB, { + reporterPlayerId: 3321, + reportedPlayerId: 3320, + }) + const filed = '2024-06-15T12:00:00.000Z' + await env.DB.prepare('UPDATE report SET created_at = ?2 WHERE id = ?1') + .bind(report.id, filed) + .run() + + const inWindow = await searchReports(env.DB, { + reportedPlayerId: 3320, + from: '2024-06-01T00:00:00.000Z', + to: '2024-07-01T00:00:00.000Z', + }) + expect(inWindow.total).toBe(1) + + // `from` is inclusive, `to` exclusive — which is what makes day-boundary windows + // composable rather than double-counting the row on the seam. + expect((await searchReports(env.DB, { reportedPlayerId: 3320, from: filed })).total).toBe(1) + expect((await searchReports(env.DB, { reportedPlayerId: 3320, to: filed })).total).toBe(0) + }) + + test('getTopReported ranks distinct reporters ahead of raw count', async () => { + // Four reports from one person, against one player… + for (let i = 0; i < 4; i++) { + await createReport(env.DB, { reporterPlayerId: 3331, reportedPlayerId: 3330 }) + } + // …and three reports from three different people, against another. + for (const reporter of [3341, 3342, 3343]) { + await createReport(env.DB, { reporterPlayerId: reporter, reportedPlayerId: 3340 }) + } + + const rows = await getTopReported(env.DB, { minReports: 3 }) + const feud = rows.find((r) => r.playerId === 3330) + const real = rows.find((r) => r.playerId === 3340) + expect(feud).toMatchObject({ reports: 4, distinctReporters: 1 }) + expect(real).toMatchObject({ reports: 3, distinctReporters: 3 }) + // Fewer reports, but from more people — so ahead. One player filing twenty + // reports about someone they are feuding with must not top the queue. + expect(rows.indexOf(real!)).toBeLessThan(rows.indexOf(feud!)) + }) + + test('getTopReported windows, thresholds, and flags a ban in force', async () => { + for (const reporter of [3351, 3352, 3353]) { + await createReport(env.DB, { reporterPlayerId: reporter, reportedPlayerId: 3350 }) + } + // Backdate them all a year, so only an all-time read sees them. The default + // window is 30 days precisely so the queue is who is a problem NOW — an account + // that collected reports last year and stopped would otherwise sit at the top + // forever. + await env.DB.prepare('UPDATE report SET created_at = ?1 WHERE reported_player_id = 3350') + .bind(new Date(Date.now() - 365 * 86_400_000).toISOString()) + .run() + + expect( + (await getTopReported(env.DB, { minReports: 1 })).map((r) => r.playerId) + ).not.toContain(3350) + const allTime = await getTopReported(env.DB, { sinceDays: null, minReports: 1 }) + expect(allTime.map((r) => r.playerId)).toContain(3350) + expect(allTime.find((r) => r.playerId === 3350)?.bannedNow).toBe(false) + + // A ban in force is decided in the same statement, so the queue can skip the + // ones already dealt with. + const [row] = await getReportsAgainst(env.DB, 3350) + await banFromReport(env.DB, row!.id) + const banned = await getTopReported(env.DB, { sinceDays: null, minReports: 1 }) + expect(banned.find((r) => r.playerId === 3350)?.bannedNow).toBe(true) + }) + + test('getBansInForce lists in-force bans only, and getReportById reads one row', async () => { + const permanent = await createReport(env.DB, { + reporterPlayerId: 3361, + reportedPlayerId: 3360, + }) + const expired = await createReport(env.DB, { + reporterPlayerId: 3363, + reportedPlayerId: 3362, + }) + await banFromReport(env.DB, permanent.id, { bannedBy: 9002 }) + await banFromReport(env.DB, expired.id, { banExpires: '2020-01-01T00:00:00.000Z' }) + + const ids = (await getBansInForce(env.DB)).map((b) => b.id) + expect(ids).toContain(permanent.id) + // A ban that has served its time is not in force, though the row stays as the + // record that it happened — the same rule `getActiveBan` applies. + expect(ids).not.toContain(expired.id) + + const one = await getReportById(env.DB, permanent.id) + expect(one).toMatchObject({ id: permanent.id, banned: 1, banned_by_player_id: 9002 }) + expect(await getReportById(env.DB, 999_999)).toBeNull() + }) + }) + // What the banned player is TOLD. The block screen reads this; it's the same row // matchmake and login refuse on, described rather than merely enforced. describe('moderationBlockDetails', () => { @@ -4729,16 +4910,16 @@ describe('player reports', () => { }) // Duration and TimeoutStartedAt are a pair in the client — the block runs from the - // start for the duration. The start is the report's created_at (nothing records when - // the ban itself landed), and a permanent ban runs for the largest span the int - // holds. + // start for the duration. The start is `banned_at`, the instant the ban was handed + // down (see 0020_report_ban_audit.sql), NOT the report's created_at: the two can be + // months apart. A permanent ban runs for the largest span the int holds. test('describes a permanent ban', async () => { await submit( { PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' }, await bearer() ) const [row] = await getReportsAgainst(env.DB, 221) - await banFromReport(env.DB, row!.id) + const banned = await banFromReport(env.DB, row!.id) expect(await details('POST', '221')).toEqual({ ...NOT_BLOCKED, @@ -4749,21 +4930,79 @@ describe('player reports', () => { // the reporter is not shown to the player they reported (PlayerIdReporter // stays null). Message: 'Rule violation', - TimeoutStartedAt: row!.created_at, + TimeoutStartedAt: banned!.banned_at, }) }) // A timed ban's Duration is the seconds from the start to the expiry, so the pair // sums to `ban_expires` — not the seconds left as of the request. - test('describes a timed ban as its report’s created_at plus the span to expiry', async () => { + test('describes a timed ban as its banned_at plus the span to expiry', async () => { await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer()) const [row] = await getReportsAgainst(env.DB, 222) - const banExpires = new Date(Date.parse(row!.created_at) + 3600 * 1000) - await banFromReport(env.DB, row!.id, { banExpires: banExpires.toISOString() }) + // The expiry is set relative to NOW, which is what a moderator handing down a + // one-hour ban means — and now is `banned_at`, not the report's created_at. + const banExpires = new Date(Date.now() + 3600 * 1000) + const banned = await banFromReport(env.DB, row!.id, { + banExpires: banExpires.toISOString(), + }) - expect(await details('GET', '222')).toEqual({ - ...NOT_BLOCKED, + // `Duration` is asserted as a range below, so it is left out of the object match + // — NOT_BLOCKED carries a 0 for it, which would win over the real value. + const { Duration: _duration, ...unblocked } = NOT_BLOCKED + const body = (await details('GET', '222')) as Record + expect(body).toMatchObject({ + ...unblocked, ReportCategory: 103, + IsBan: true, + Message: 'Rule violation', + TimeoutStartedAt: banned!.banned_at, + }) + // Within a second of the hour: `banned_at` is stamped by the write, so the span + // is the hour minus however long the write took. + expect(body.Duration).toBeGreaterThan(3595) + expect(body.Duration).toBeLessThanOrEqual(3600) + }) + + // The whole point of `banned_at`: a ban applied to an OLD report used to be + // described as having started when the report was filed, so a 7-day ban on a + // month-old report told the player their block began a month ago — and the duration, + // measured from there, came out as already served. Both halves of the pair now + // start from the ban. + test('counts a ban from when it was handed down, not from when the report was filed', async () => { + await submit({ PlayerIdReported: '224', ReportCategory: '102' }, await bearer()) + const [row] = await getReportsAgainst(env.DB, 224) + // Backdate the report a month, as if it had sat in the queue. + const filed = new Date(Date.now() - 30 * 86_400_000).toISOString() + await env.DB.prepare('UPDATE report SET created_at = ?2 WHERE id = ?1') + .bind(row!.id, filed) + .run() + + const banned = await banFromReport(env.DB, row!.id, { + banExpires: new Date(Date.now() + 7 * 86_400_000).toISOString(), + }) + const body = (await details('GET', '224')) as Record + expect(body.TimeoutStartedAt).toBe(banned!.banned_at) + expect(body.TimeoutStartedAt).not.toBe(filed) + // Seven days, not the negative-then-clamped span the report's date would give. + expect(body.Duration).toBeGreaterThan(7 * 86_400 - 5) + expect(body.Duration).toBeLessThanOrEqual(7 * 86_400) + }) + + // A row banned BEFORE 0020 added the column carries no `banned_at`, and must keep + // reading exactly as it used to rather than falling back to "now" — which would + // silently restart every standing ban the moment this shipped. + test('falls back to created_at for a ban with no recorded banned_at', async () => { + await submit({ PlayerIdReported: '225', ReportCategory: '101' }, await bearer()) + const [row] = await getReportsAgainst(env.DB, 225) + await env.DB.prepare( + `UPDATE report SET banned = 1, ban_expires = ?2, banned_at = NULL WHERE id = ?1` + ) + .bind(row!.id, new Date(Date.parse(row!.created_at) + 3600 * 1000).toISOString()) + .run() + + expect(await details('GET', '225')).toEqual({ + ...NOT_BLOCKED, + ReportCategory: 101, Duration: 3600, IsBan: true, Message: 'Rule violation', diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 0b57a51..323ad9f 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -12,31 +12,22 @@ import { QUEST_DOWNLOAD_URL, SOURCE_REPO, } from '../links' +// The session token, the worker hostnames and the `call` every request goes through — +// see api.ts for why they're a module of their own rather than defined here. +import { + accountIdForUsername, + call, + hasToken, + isAdmin, + setHosts, + setToken, + useAction, + where, +} from './api' +import { ModerationPage } from './Moderation' import type { ReactNode } from 'react' - -/** - * The SPA calls the SAME endpoints the game does — `auth` for tokens and the password - * change, `accounts` for the profile, `api` for the photo feed, `notify` for the admin - * broadcasts — rather than proxying each one through `www`, exactly as rec.net's own - * site did. Those workers answer CORS for it (see their `withDefaultCors()`), and the - * access token lives here in the browser. - * - * `www` serves only two things of its own (see www.app.ts): the config below, and - * signup, which is Turnstile-gated and so cannot leave the server. - */ - -/** Where each worker lives. From `/api/config`, never baked into this build. */ -interface Hosts { - auth: string - accounts: string - api: string - img: string - notify: string - rooms: string - cdn: string - storage: string -} +import type { Hosts } from './api' /** * Site config from `www`. `signupEnabled` is false when the operator has no Turnstile @@ -167,144 +158,6 @@ const ACCESSIBILITY_LABEL: Record = { */ const WEB_PLATFORM = '4' -/** - * The session's access token, in localStorage so a reload stays signed in. - * - * Readable by page JS, which the httpOnly cookie this replaced was not — that is the - * tradeoff that comes with the browser calling the workers itself, and it's the same - * posture the game client has. Nothing third-party runs on this origin except the - * Turnstile widget, which is Cloudflare's own. - */ -const TOKEN_KEY = 'rf_token' -let token: string | null = localStorage.getItem(TOKEN_KEY) - -function setToken(next: string | null) { - token = next - if (next === null) localStorage.removeItem(TOKEN_KEY) - else localStorage.setItem(TOKEN_KEY, next) -} - -/** - * Filled in once `/api/config` lands, before any worker call is made — a module value - * rather than a prop threaded through every form, since the components that call a - * worker only render after the config resolves. - */ -let hosts: Hosts | null = null - -/** The hostnames, once known. Throws rather than guessing a domain. */ -function where(): Hosts { - if (hosts === null) throw new Error('Still starting up — please reload the page.') - return hosts -} - -/** - * Roles that unlock the admin controls. Mirrors the notify worker's `ADMIN_ROLES` gate — - * this only decides whether to SHOW them; notify verifies the token on every call. - */ -const ADMIN_ROLES = new Set(['developer', 'moderator']) - -/** - * Whether the session token carries an admin role. Decodes the `role` claim WITHOUT - * verifying it — a page holds no signing key, and faking one here only reveals buttons - * whose endpoints reject the same token. A malformed token reads as "not admin". - */ -function isAdmin(): boolean { - const payload = token?.split('.')[1] - if (!payload) return false - try { - const b64 = payload.replace(/-/g, '+').replace(/_/g, '/') - const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=') - const claims = JSON.parse(atob(padded)) as { role?: unknown } - return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string)) - } catch { - return false - } -} - -/** - * An OAuth machine code (`invalid_grant`, `server_error`) rather than a sentence — a - * lower_snake_case word with no spaces. A worker that speaks OAuth puts one of these in - * `error`, where the readable reason is in `error_description`. - */ -const isErrorCode = (s: string) => /^[a-z][a-z\d]*(_[a-z\d]+)+$/.test(s) - -/** - * The message worth showing for a refusal. `error` wins, since that's where a worker - * puts a sentence it wrote for the player — but NOT when it's a bare OAuth code, which - * tells nobody anything. Some refusals carry no body at all (accounts answers a - * malformed email with an empty 400), hence the last-resort line. - */ -function errorMessage(data: Record, status: number): string { - const error = typeof data.error === 'string' ? data.error : '' - const description = typeof data.error_description === 'string' ? data.error_description : '' - return ( - (error && !(isErrorCode(error) && description) && error) || - description || - error || - `Request failed (${status})` - ) -} - -interface CallOptions { - method?: 'GET' | 'POST' | 'PUT' - /** Form fields — auth and accounts read their input with Hono's `parseBody()`. */ - form?: Record - /** A JSON body — what notify's internal endpoints take instead. */ - json?: unknown - /** - * A multipart body — what `storage`'s `/upload` takes, since it carries a file. Passed - * to `fetch` as-is: the browser writes the `content-type` itself, because only it - * knows the boundary it generated. - */ - multipart?: FormData - /** Send the session token. */ - authed?: boolean - /** - * What to say when the worker refuses with a 400 and NO body. Several accounts routes - * do exactly that (email, display name, bio), so without this the player reads - * "Request failed (400)" — the status, not the reason. - */ - refusal?: string -} - -/** Call a worker. Returns the parsed body, or throws with something worth showing. */ -async function call>(url: string, opts: CallOptions = {}): Promise { - const headers: Record = {} - if (opts.authed && token) headers.authorization = `Bearer ${token}` - let body: string | FormData | undefined - if (opts.form) { - headers['content-type'] = 'application/x-www-form-urlencoded' - body = new URLSearchParams(opts.form).toString() - } else if (opts.json !== undefined) { - headers['content-type'] = 'application/json' - body = JSON.stringify(opts.json) - } else if (opts.multipart) { - // Deliberately no content-type: setting one would omit the boundary. - body = opts.multipart - } - - const res = await fetch(url, { - method: opts.method ?? (body === undefined ? 'GET' : 'POST'), - headers, - body, - }) - const data = (await res.json().catch(() => ({}))) as Record - - if (!res.ok) { - // Expired or revoked. Cleared here so no caller has to remember to. - if (res.status === 401 && opts.authed) { - setToken(null) - throw new Error('Your session has expired. Please sign in again.') - } - // Only when the body really is empty — a worker that did send a reason keeps it. - if (opts.refusal !== undefined && res.status === 400 && Object.keys(data).length === 0) { - throw new Error(opts.refusal) - } - throw new Error(errorMessage(data, res.status)) - } - return data as T -} - /** The signed-in account, straight from `accounts`. */ const fetchMe = (): Promise => call(`${where().accounts}/account/me`, { authed: true }) @@ -586,28 +439,6 @@ const coachMessage = ( authed: true, }) -/** - * Resolve an `@username` to the account id the workers address a player by. - * - * `accounts` serves no exact-name lookup, so this goes through the PREFIX search and - * keeps only an exact (case-insensitive) hit: a prefix match is a different player, and - * sending a message to whoever happened to sort first would be worse than refusing. The - * exact name always sorts first among its own prefixes, so it's inside the search limit - * whenever it exists. - */ -async function accountIdForUsername(input: string): Promise { - const name = input.trim().replace(/^@/, '') - if (name === '') throw new Error('Enter a username to send to.') - const matches = await call>( - `${where().accounts}/account/search?name=${encodeURIComponent(name)}` - ) - const found = Array.isArray(matches) - ? matches.find((m) => m.username?.toLowerCase() === name.toLowerCase()) - : undefined - if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`) - return found.accountId -} - /** Minimal history-based router: current pathname + a navigate() that pushes state. */ function useRouter() { const [path, setPath] = useState(() => window.location.pathname) @@ -887,9 +718,9 @@ export function App() { // closed rather than guessing where the workers are. call('/api/config') .then(async ({ hosts: resolved, ...site }) => { - hosts = resolved + setHosts(resolved) setConfig(site) - if (token === null) return setAccount(null) + if (!hasToken()) return setAccount(null) // A stored token that `accounts` rejects is stale — `call` has already dropped // it, so this just falls back to signed-out rather than surfacing an error. await fetchMe() @@ -937,6 +768,12 @@ export function App() { // Its own page rather than a dashboard tab: this path is Discord's registered // redirect URI, so it has to be one stable URL a cold load can land on. + ) : path === '/moderation' ? ( + // A whole surface rather than another dashboard tab, and its own URL so a + // moderator can keep it open. The path is deliberately NOT in wrangler.jsonc's + // `run_worker_first`, so a cold load falls through to the SPA shell; the page + // then gates itself on the token's role, and every endpoint behind it re-checks. + ) : roomId !== null ? ( ) : ( @@ -995,6 +832,18 @@ function NavBar({ {account === undefined ? null : account ? ( <> + {/* Staff only, and cosmetic: the link is hidden for everyone else, but + `/moderation` is a real URL anyone can type — the page and every + endpoint behind it check the token's role themselves. */} + {isAdmin() && ( + + Moderation + + )} My account @@ -1830,29 +1679,6 @@ function VisibilityBadge({ accessibility }: { accessibility: number }) { ) } - -/** Small hook wrapping a submit handler with pending/error/success state. */ -function useAction() { - const [pending, setPending] = useState(false) - const [error, setError] = useState('') - const [done, setDone] = useState('') - - const run = useCallback(async (fn: () => Promise) => { - setPending(true) - setError('') - setDone('') - try { - setDone(await fn()) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setPending(false) - } - }, []) - - return { pending, error, done, run } -} - /** * Turnstile's browser API, as much of it as the signup widget uses. Loaded from * Cloudflare at runtime (see loadTurnstile) rather than bundled, so it isn't in diff --git a/apps/www/src/client/Moderation.tsx b/apps/www/src/client/Moderation.tsx new file mode 100644 index 0000000..48ed127 --- /dev/null +++ b/apps/www/src/client/Moderation.tsx @@ -0,0 +1,1228 @@ +import { useCallback, useEffect, useState } from 'react' + +import { KickReportCategory } from '../../../notify/src/notification-payloads' +import { accountIdForUsername, call, isAdmin, useAction, usernamesFor } from './api' + +import type { PublicAccount } from './api' + +/** + * The staff moderation panel, at `/moderation`. + * + * Its own file rather than another section of App.tsx: App renders this, so the shared + * plumbing both need lives in api.ts (see the note there), and this is a whole surface + * rather than one more form on the account dashboard. + * + * Everything here talks to `www`'s own `/api/staff/*` endpoints (see src/staff.ts) — + * except the names, which come from `accounts`' bulk lookup, the same endpoint the game + * uses. The staff reads return account IDS; a moderation table whose rows are numbers is + * unreadable, and joining names on the server would tie those reads to a table another + * worker owns. + * + * The `isAdmin()` gate below is cosmetic. It decodes the token's `role` claim without + * verifying it, because a page holds no signing key — faking it reveals a table that + * every endpoint behind it answers 403 to. `requireStaff` is the real gate. + */ + +/** What the panel is showing. The player history is a drill-down from any of them. */ +type Tab = 'queue' | 'search' | 'bans' | 'new' + +const TABS: Array<{ id: Tab; label: string }> = [ + // Queue first: "who is a problem right now" is the question someone opens this page + // with, where the search is what you use once you know who you're looking at. + { id: 'queue', label: 'Report queue' }, + { id: 'search', label: 'Search reports' }, + { id: 'bans', label: 'Standing bans' }, + { id: 'new', label: 'File a report' }, +] + +/** + * A stored report, as `www` serves the row — snake_case, because it IS the row. Not every + * column is rendered; the ones that are not (the measured heights, the instance type) are + * kept in the type so a row can be passed around whole. + */ +interface ReportRow { + id: number + reporter_player_id: number + reported_player_id: number + report_category: number + details: string | null + room_id: number | null + room_instance_type: string | null + created_at: string + banned: number + ban_expires: string | null + event_id: number | null + invention_id: number | null + custom_avatar_item_id: string | null + banned_by_player_id: number | null + banned_at: string | null +} + +/** A moderator-issued warning, as the `warning` table stores it. */ +interface WarningRow { + id: number + moderator_player_id: number + warned_player_id: number + report_category: number + display_reason: string | null + moderator_note: string | null + created_at: string +} + +/** A row of the report queue — see `getTopReported`. */ +interface ReportedPlayerTally { + playerId: number + reports: number + distinctReporters: number + lastReportAt: string + bannedNow: boolean +} + +/** An account a ban would also reach, and what links it — see `linkedAccounts`. */ +interface LinkedAccount { + accountId: number + username: string | null + via: 'platform' | 'ip' + value: string +} + +/** + * What a report's category means, keyed off the client's own enum so a renumbering moves + * these labels with it rather than leaving them quietly wrong. + * + * The names are the client's, made readable: `CoCSexual` is the code of conduct's sexual + * content rule, and a moderator reading a table should not have to know that. An id with + * no entry is shown as the bare number — the column is stored verbatim and unmapped (see + * the report write), so a build that reports something new must not render a blank. + */ +const CATEGORY_LABEL: Record = { + [KickReportCategory.Moderator]: 'Moderator action', + [KickReportCategory.Unknown]: 'Unspecified', + [KickReportCategory.Harassment]: 'Harassment', + [KickReportCategory.Cheating]: 'Cheating', + [KickReportCategory.AFK]: 'Inactive (AFK)', + [KickReportCategory.Misc]: 'Game conduct', + [KickReportCategory.Underage]: 'Underage', + [KickReportCategory.VoteKick]: 'Vote to kick', + [KickReportCategory.MisleadingPurchases]: 'Misleading purchases', + [KickReportCategory.CoCUnderage]: 'Underage (CoC)', + [KickReportCategory.CoCSexual]: 'Sexual content', + [KickReportCategory.CoCDiscrimination]: 'Discrimination', + [KickReportCategory.CoCTrolling]: 'Griefing / trolling', + [KickReportCategory.CoCNameOrProfile]: 'Name or profile', + [KickReportCategory.InappropriateClothing]: 'Inappropriate clothing', + [KickReportCategory.IssuingInaccurateReports]: 'Inaccurate reports', +} + +/** The categories the file-a-report form offers, in the order it lists them. */ +const FILEABLE_CATEGORIES = [ + KickReportCategory.CoCDiscrimination, + KickReportCategory.CoCSexual, + KickReportCategory.CoCTrolling, + KickReportCategory.Harassment, + KickReportCategory.CoCNameOrProfile, + KickReportCategory.CoCUnderage, + KickReportCategory.Cheating, + KickReportCategory.InappropriateClothing, + KickReportCategory.IssuingInaccurateReports, + KickReportCategory.Misc, +] + +const categoryLabel = (id: number): string => CATEGORY_LABEL[id] ?? `Category ${id}` + +/** How many reports one page of search results holds. */ +const PAGE_SIZE = 25 + +/** A date as a moderator reads it — local time, to the minute, no seconds. */ +function when(iso: string | null): string { + if (iso === null) return '—' + const parsed = Date.parse(iso) + if (Number.isNaN(parsed)) return iso + return new Date(parsed).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +/** + * What a ban's expiry says on screen. A null expiry is permanent, not missing — the two + * read identically as an empty cell, and confusing them is the difference between a + * week's ban and a life one. + */ +const expiryLabel = (report: ReportRow): string => + report.banned !== 1 ? '—' : report.ban_expires === null ? 'Permanent' : when(report.ban_expires) + +/** + * What kind of thing a report is against. The three id columns are mutually exclusive and + * a row with none of them is an ordinary player report (see reports-db) — which is the + * only way to tell the kinds apart, so it is worth a column of its own. + */ +function reportKind(report: ReportRow): string { + if (report.event_id !== null) return 'Event' + if (report.invention_id !== null) return 'Invention' + if (report.custom_avatar_item_id !== null) return 'Avatar item' + return 'Player' +} + +/** A player as a name plus id — the id always shown, since ids are what the rows carry. */ +function PlayerName({ id, names }: { id: number; names: Map }) { + const account = names.get(id) + return ( + + {account?.username ? `@${account.username}` : 'unknown'} + #{id} + + ) +} + +/** + * The read of a staff endpoint, with the loading and failure states the panel renders. + * `data === null` with no error means still in flight. + * + * A `null` path means "nothing to read yet" and holds whatever was last loaded — the + * search uses it so the table doesn't clear itself between submissions. Re-reads when the + * path changes (it carries the query string, so that covers every filter change) or when + * a caller's dep does: the panel passes a revision counter it bumps after any write, so + * banning someone refreshes whichever tables are on screen. + */ +function useStaffData(path: string | null, deps: unknown[] = []) { + const [data, setData] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + if (path === null) return + // Guards against the response of a superseded read landing after a newer one — two + // filter changes in quick succession would otherwise render whichever finished last. + let live = true + setError('') + call(path, { authed: true }) + .then((result) => live && setData(result)) + .catch((err: unknown) => live && setError(err instanceof Error ? err.message : String(err))) + return () => { + live = false + } + // The caller's deps are spread in deliberately — the lint rule can't see through a + // variable-length array, and `path` plus those deps is the whole trigger set. + // oxlint-disable-next-line react-hooks/exhaustive-deps + }, [path, ...deps]) + + return { data, error } +} + +/** + * Names for every id a page of rows mentions, in one bulk lookup. + * + * Re-runs when the rows change, and holds the previous names while the next lookup is in + * flight, so a table doesn't flash back to bare ids between pages. + */ +function useNames(ids: number[]) { + const [names, setNames] = useState>(new Map()) + const key = ids.join(',') + + useEffect(() => { + if (key === '') return + let live = true + void usernamesFor(key.split(',').map(Number)).then((found) => { + // Merged rather than replaced: the panel holds several tables, and a lookup for + // one must not blank the names in another. + if (live) setNames((prev) => new Map([...prev, ...found])) + }) + return () => { + live = false + } + }, [key]) + + return names +} + +export function ModerationPage({ + account, + navigate, +}: { + account: { accountId: number; username: string } | null | undefined + navigate: (to: string) => void +}) { + const [tab, setTab] = useState('queue') + // The player whose history is open, or null for the tab's own view. Local state rather + // than a route: it's a drill-down inside the panel, and a moderator walks in and out of + // several in a sitting without wanting each one in their back button. + const [openPlayer, setOpenPlayer] = useState(null) + // The report a ban is being composed against. Held here so the dialog can be opened + // from any of the tables. + const [banning, setBanning] = useState(null) + // Bumped after any write, to re-read whichever tables are on screen. + const [revision, setRevision] = useState(0) + const changed = useCallback(() => setRevision((n) => n + 1), []) + + if (account === undefined) { + return ( +
+
+

Checking your session…

+
+
+ ) + } + + if (account === null) { + return ( +
+
+

Moderation

+

You need to be signed in to use the moderation tools.

+ +
+
+ ) + } + + // A signed-in player who isn't staff. Told plainly rather than shown a sign-in form: + // signing in again would change nothing, and a 404 would just make them wonder. + if (!isAdmin()) { + return ( +
+
+

Moderation

+

+ This area is for moderators. Your account doesn't have the moderator or developer + role. +

+ +
+
+ ) + } + + return ( +
+
+
Moderating as
+
@{account.username}
+
+ #{account.accountId} · actions you take here are recorded against this account +
+
+ + {openPlayer !== null ? ( + setOpenPlayer(null)} + /> + ) : ( +
+ +
+ {tab === 'queue' ? ( + + ) : tab === 'search' ? ( + + ) : tab === 'bans' ? ( + + ) : ( + { + changed() + setBanning(report) + }} + /> + )} +
+
+ )} + + {banning !== null && ( + setBanning(null)} + onDone={() => { + setBanning(null) + changed() + }} + /> + )} +
+ ) +} + +/** + * The report queue: who has collected the most reports lately. + * + * Ranked by DISTINCT reporters ahead of raw count (see `getTopReported`), because one + * player filing twenty reports against someone they're feuding with is a different thing + * from twenty players each filing one. The window defaults to 30 days so the list is who + * is a problem now rather than whoever has ever accumulated the most. + */ +function ReportQueue({ + revision, + onOpenPlayer, +}: { + revision: number + onOpenPlayer: (id: number) => void +}) { + const [sinceDays, setSinceDays] = useState('30') + const [minReports, setMinReports] = useState('3') + const { data, error } = useStaffData( + `/api/staff/reports/top-reported?sinceDays=${encodeURIComponent(sinceDays)}&minReports=${encodeURIComponent(minReports)}`, + [revision] + ) + const names = useNames((data ?? []).map((row) => row.playerId)) + + return ( +
+

Report queue

+

+ Players with the most reports against them, ranked by how many different people reported + them. Already-banned players are marked but still listed — a ban that is about to expire is + worth seeing. +

+
+ + +
+ + {error &&

{error}

} + {data === null ? ( + !error &&

Loading…

+ ) : data.length === 0 ? ( +

Nobody has been reported that many times in this window.

+ ) : ( +
+ + + + + + + + + + + {data.map((row) => ( + + + + + + + + ))} + +
PlayerReportsReportersLast reported +
+ + {row.bannedNow && Banned} + {row.reports}{row.distinctReporters}{when(row.lastReportAt)} + +
+
+ )} +
+ ) +} + +/** The search form's state — mirrors the query params `searchReportsHandler` reads. */ +interface SearchFilters { + reported: string + reporter: string + category: string + banned: string + from: string + to: string +} + +const EMPTY_FILTERS: SearchFilters = { + reported: '', + reporter: '', + category: '', + banned: '', + from: '', + to: '', +} + +/** + * Search the report log. + * + * The two player boxes take either an id or an `@username`, resolved through `accounts`' + * search before the query goes out — a moderator following up a Discord report has a name, + * not a number, and making them look it up elsewhere first is the kind of friction that + * ends in nobody using the panel. + */ +function ReportSearch({ + revision, + onOpenPlayer, + onBan, + onChanged, +}: { + revision: number + onOpenPlayer: (id: number) => void + onBan: (report: ReportRow) => void + onChanged: () => void +}) { + const [form, setForm] = useState(EMPTY_FILTERS) + // The query actually in flight, separate from what's typed: a search runs when it is + // submitted, not on every keystroke, and the ids in it are resolved by then. + const [query, setQuery] = useState('?take=' + PAGE_SIZE) + const [skip, setSkip] = useState(0) + const { pending, error: formError, run } = useAction() + + const path = query === null ? null : `/api/staff/reports${query}&skip=${skip}` + const { data, error } = useStaffData<{ reports: ReportRow[]; total: number }>(path, [revision]) + const reports = data?.reports ?? [] + const names = useNames(reports.flatMap((r) => [r.reported_player_id, r.reporter_player_id])) + + const submit = (e: React.FormEvent) => { + e.preventDefault() + void run(async () => { + const params = new URLSearchParams({ take: String(PAGE_SIZE) }) + // Either box may hold a name; resolve before searching so the query is by id, as + // the report rows are. + if (form.reported.trim() !== '') { + params.set('reportedPlayerId', String(await playerIdFrom(form.reported))) + } + if (form.reporter.trim() !== '') { + params.set('reporterPlayerId', String(await playerIdFrom(form.reporter))) + } + if (form.category !== '') params.set('reportCategory', form.category) + if (form.banned !== '') params.set('banned', form.banned) + // A date input gives a bare `YYYY-MM-DD`; `to` is exclusive on the server, so a + // day entered there includes that whole day only if it is pushed to the next + // midnight. Both are sent as local midnights, which is what the moderator meant. + if (form.from !== '') params.set('from', new Date(form.from).toISOString()) + if (form.to !== '') { + const to = new Date(form.to) + to.setDate(to.getDate() + 1) + params.set('to', to.toISOString()) + } + setSkip(0) + setQuery(`?${params.toString()}`) + return '' + }) + } + + const total = data?.total ?? 0 + + return ( +
+

Search reports

+

+ Every report ever filed, newest first. The player boxes take an id or an @username. +

+
+ + + + + + +
+ + +
+
+ + {formError &&

{formError}

} + {error &&

{error}

} + + {data === null ? ( + !error &&

Loading…

+ ) : reports.length === 0 ? ( +

No reports match those filters.

+ ) : ( + <> + +
+ + + {skip + 1}–{Math.min(skip + PAGE_SIZE, total)} of {total} + + +
+ + )} +
+ ) +} + +/** An id typed as a number, or an `@username` resolved through `accounts`. */ +async function playerIdFrom(input: string): Promise { + const trimmed = input.trim() + if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10) + return accountIdForUsername(trimmed) +} + +/** The report rows themselves — shared by the search and the player history. */ +function ReportTable({ + reports, + names, + onOpenPlayer, + onBan, + onChanged, +}: { + reports: ReportRow[] + names: Map + onOpenPlayer?: (id: number) => void + onBan: (report: ReportRow) => void + onChanged: () => void +}) { + return ( +
+ + + + + + + + + + + + + + + {reports.map((report) => ( + + + + + + + + {/* The reporter's own words, untruncated in the title so a long + description is readable without leaving the table. */} + + + + + ))} + +
#FiledAgainstByKindCategoryDetailsBan +
{report.id}{when(report.created_at)} + {onOpenPlayer ? ( + + ) : ( + + )} + + + {reportKind(report)}{categoryLabel(report.report_category)} + {report.details ?? none given} + + {report.banned === 1 ? ( + {expiryLabel(report)} + ) : ( + — + )} + + {report.banned === 1 ? ( + + ) : ( + + )} +
+
+ ) +} + +/** + * Lift the ban on a report. Confirmed first — it is one click next to a row of them, and + * the mistake puts a banned player straight back in the game. + */ +function LiftBanButton({ report, onDone }: { report: ReportRow; onDone: () => void }) { + const { pending, error, run } = useAction() + return ( + <> + + {error &&

{error}

} + + ) +} + +/** Every ban in force right now. */ +function StandingBans({ + revision, + onOpenPlayer, + onChanged, +}: { + revision: number + onOpenPlayer: (id: number) => void + onChanged: () => void +}) { + const { data, error } = useStaffData('/api/staff/bans', [revision]) + const bans = data ?? [] + const names = useNames(bans.flatMap((b) => [b.reported_player_id, b.banned_by_player_id ?? 0])) + + return ( +
+

Standing bans

+

+ Bans in force right now, longest-lasting first. An expired ban isn't here — it has + served its time, and the report stays as the record that it happened. +

+ {error &&

{error}

} + {data === null ? ( + !error &&

Loading…

+ ) : bans.length === 0 ? ( +

Nobody is banned.

+ ) : ( +
+ + + + + + + + + + + + + {bans.map((ban) => ( + + + + + {/* `banned_at` where there is one; a ban handed down before that + column existed has only the report's own date to show. */} + + + + + + ))} + +
PlayerReportReasonBannedByLifts +
+ + #{ban.id}{categoryLabel(ban.report_category)} + {ban.banned_at === null ? ( + + {when(ban.created_at)}? + + ) : ( + when(ban.banned_at) + )} + + {ban.banned_by_player_id === null ? ( + unknown + ) : ( + + )} + {ban.ban_expires === null ? 'Never' : when(ban.ban_expires)} + +
+
+ )} +
+ ) +} + +/** + * Everything on file about one player: the reports against them, the warnings handed + * down, the ban in force, and who else a ban would reach. + * + * One screen because it is one decision. A moderator about to ban someone wants the + * history and the blast radius in front of them, not in three places. + */ +function PlayerHistory({ + playerId, + revision, + onBan, + onClose, +}: { + playerId: number + revision: number + onBan: (report: ReportRow) => void + onClose: () => void +}) { + const { data, error } = useStaffData<{ + playerId: number + reports: ReportRow[] + warnings: WarningRow[] + activeBan: ReportRow | null + }>(`/api/staff/players/${playerId}`, [revision]) + const reports = data?.reports ?? [] + const warnings = data?.warnings ?? [] + const names = useNames([ + playerId, + ...reports.map((r) => r.reporter_player_id), + ...warnings.map((w) => w.moderator_player_id), + ]) + + return ( + <> +
+ +

+ +

+ {error &&

{error}

} + {data === null ? ( + !error &&

Loading…

+ ) : data.activeBan === null ? ( +

+ Not banned. {reports.length} report{reports.length === 1 ? '' : 's'} on file,{' '} + {warnings.length} warning{warnings.length === 1 ? '' : 's'} handed down. +

+ ) : ( +

+ Banned on report #{data.activeBan.id} for{' '} + {categoryLabel(data.activeBan.report_category)} —{' '} + {data.activeBan.ban_expires === null + ? 'permanently' + : `until ${when(data.activeBan.ban_expires)}`} + . +

+ )} +
+ + + +
+

Reports

+ {reports.length === 0 ? ( +

Nobody has reported this player.

+ ) : ( + + )} +
+ +
+

Warnings

+

+ Warnings a moderator handed down, from the game's own warning endpoint. Nothing here + dispatches them; the rows are the record. +

+ {warnings.length === 0 ? ( +

No warnings on file.

+ ) : ( +
+ + + + + + + + + + + + {warnings.map((warning) => ( + + + + + + + + ))} + +
WhenByCategoryShown to the playerInternal note
{when(warning.created_at)} + + {categoryLabel(warning.report_category)}{warning.display_reason ?? —}{warning.moderator_note ?? —}
+
+ )} +
+ + ) +} + +/** + * Who ELSE a ban on this player would block. + * + * The IP arm is coarse by design — households, NAT, campus and carrier networks put + * unrelated players behind one address — so a ban can reach people who did nothing. That + * trade is the operator's to make, but it should be made with this list in front of them + * rather than discovered from a support ticket. + */ +function LinkedAccountsPanel({ playerId }: { playerId: number }) { + const { data, error } = useStaffData<{ + arms: { ip: boolean; platform: boolean } + linked: LinkedAccount[] + }>(`/api/staff/players/${playerId}/linked`) + + if (error !== '') + return ( +
+

{error}

+
+ ) + if (data === null) return null + + const arms = [data.arms.platform && 'shared platform identity', data.arms.ip && 'shared IP'] + .filter(Boolean) + .join(' and ') + + return ( +
+

Who a ban would also reach

+ {arms === '' ? ( +

+ Ban-evasion matching is switched off on this server, so a ban reaches exactly the one + account. +

+ ) : data.linked.length === 0 ? ( +

+ No other account shares a {arms} with this one — a ban reaches only them. +

+ ) : ( + <> +

+ Evasion matching is on for {arms}. These accounts would be blocked too: +

+
    + {data.linked.map((linked) => ( +
  • + + {linked.via === 'platform' ? 'Same platform login' : 'Same IP'} + {' '} + {linked.username ? `@${linked.username}` : 'unknown'} + + {' '} + #{linked.accountId} · {linked.value} + +
  • + ))} +
+ {data.arms.ip && ( +

+ An IP match is not proof of the same person. Households and shared networks look + identical to it. +

+ )} + + )} +
+ ) +} + +/** + * File a report by hand. + * + * A ban lives ON a report (see reports-db), so acting on something nobody happened to + * report — found in a log, seen first-hand, escalated from Discord — needs a row to hang + * it off. The reporter is the acting moderator, taken from the token; that is the record + * of who raised it, which is why nothing marks these rows as staff-created. + * + * Hands the new report straight to the ban dialog, since filing one is almost always the + * first half of banning somebody. + */ +function FileReport({ onFiled }: { onFiled: (report: ReportRow) => void }) { + const [player, setPlayer] = useState('') + const [category, setCategory] = useState(String(KickReportCategory.Misc)) + const [details, setDetails] = useState('') + const [roomId, setRoomId] = useState('') + const { pending, error, done, run } = useAction() + + return ( +
+

File a report

+

+ For something no player reported. It is filed under your account, and opens the ban dialog + once saved — you don't have to ban, and the row stands on its own either way. +

+
{ + e.preventDefault() + void run(async () => { + const report = await call('/api/staff/reports', { + authed: true, + json: { + reportedPlayerId: await playerIdFrom(player), + reportCategory: Number(category), + details: details.trim(), + roomId: roomId.trim() === '' ? 0 : Number(roomId), + }, + }) + setPlayer('') + setDetails('') + setRoomId('') + onFiled(report) + return `Filed report #${report.id}.` + }) + }} + > + + +