mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] beta mod panel
This commit is contained in:
@@ -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;
|
||||||
@@ -206,3 +206,94 @@ export async function isPlayerBlocked(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return (await resolveBan(db, accountId, options)) !== null
|
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<BanVia, 'account'>
|
||||||
|
/**
|
||||||
|
* 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<LinkedAccount[]> {
|
||||||
|
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<LinkedAccount>()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|||||||
+233
-9
@@ -7,9 +7,16 @@
|
|||||||
* what a player submitted: the table is a log of exactly what was reported.
|
* 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,
|
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
|
||||||
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql and
|
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql,
|
||||||
* 0017_report_custom_avatar_item.sql, applied under its own `migrations_table` so it
|
* 0017_report_custom_avatar_item.sql and 0020_report_ban_audit.sql, applied under its own
|
||||||
* doesn't clash with the other workers' migrations that share the database).
|
* `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
|
* 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`,
|
* 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 +
|
* 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
|
* 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,
|
* 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,
|
ban_expires TEXT,
|
||||||
event_id INTEGER,
|
event_id INTEGER,
|
||||||
invention_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_reported ON report (reported_player_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_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.
|
* it does not know who made it.
|
||||||
*/
|
*/
|
||||||
custom_avatar_item_id: string | null
|
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<Re
|
|||||||
return row!
|
return row!
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every report filed against a player, newest first. Backs a future moderation view. */
|
/**
|
||||||
|
* Every report filed against a player, newest first — unpaged, because it is the whole
|
||||||
|
* history a moderator reads on one account (`www`: `GET /api/staff/players/:id`), not a
|
||||||
|
* feed. `searchReports` is the paged, filtered form for looking across accounts.
|
||||||
|
*/
|
||||||
export async function getReportsAgainst(db: D1Database, playerId: number): Promise<ReportRow[]> {
|
export async function getReportsAgainst(db: D1Database, playerId: number): Promise<ReportRow[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare('SELECT * FROM report WHERE reported_player_id = ?1 ORDER BY id DESC')
|
.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
|
* permanent ban. Passing `banned: false` lifts the ban and clears the expiry, leaving the
|
||||||
* report itself intact.
|
* 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
|
* 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
|
* can tell "banned" from "banned nobody" (wrangler's `d1 execute --json` reports no
|
||||||
* changes count, hence RETURNING).
|
* changes count, hence RETURNING).
|
||||||
@@ -224,11 +256,203 @@ export async function isPlayerBanned(
|
|||||||
export async function banFromReport(
|
export async function banFromReport(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
reportId: number,
|
reportId: number,
|
||||||
options: { banned?: boolean; banExpires?: string | null } = {}
|
options: { banned?: boolean; banExpires?: string | null; bannedBy?: number | null } = {}
|
||||||
): Promise<ReportRow | null> {
|
): Promise<ReportRow | null> {
|
||||||
const banned = options.banned ?? true
|
const banned = options.banned ?? true
|
||||||
return db
|
return db
|
||||||
.prepare('UPDATE report SET banned = ?2, ban_expires = ?3 WHERE id = ?1 RETURNING *')
|
.prepare(
|
||||||
.bind(reportId, banned ? 1 : 0, banned ? (options.banExpires ?? null) : null)
|
`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<ReportRow>()
|
.first<ReportRow>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ReportPage> {
|
||||||
|
// 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<number | string> = []
|
||||||
|
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<ReportRow | { total: number }>([
|
||||||
|
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<ReportRow | null> {
|
||||||
|
return db.prepare('SELECT * FROM report WHERE id = ?1').bind(reportId).first<ReportRow>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<ReportedPlayerTally[]> {
|
||||||
|
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<Omit<ReportedPlayerTally, 'bannedNow'> & { 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<ReportRow[]> {
|
||||||
|
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<ReportRow>()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|||||||
@@ -262,10 +262,19 @@ const NOT_BLOCKED = {
|
|||||||
* The block details for a ban in force — the `report` row a moderator set `banned` on.
|
* 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
|
* `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
|
* start for the duration. The start is `banned_at`, the instant the ban was handed down
|
||||||
* the ban itself was handed down, and the report is the record the ban rests on — and the
|
* (see 0020_report_ban_audit.sql), and the duration is the seconds from there to
|
||||||
* duration is the seconds from there to `ban_expires`, so the two sum to the expiry; or
|
* `ban_expires`, so the two sum to the expiry; or `PERMANENT_BAN_DURATION` when there is
|
||||||
* `PERMANENT_BAN_DURATION` when there is none. The category is the one the report was
|
* 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
|
* 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
|
* 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
|
* 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.
|
* screen dressings, none of which this server hands out.
|
||||||
*/
|
*/
|
||||||
function banBlockDetails(ban: ReportRow) {
|
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 =
|
const duration =
|
||||||
ban.ban_expires === null
|
ban.ban_expires === null
|
||||||
? PERMANENT_BAN_DURATION
|
? PERMANENT_BAN_DURATION
|
||||||
@@ -285,7 +295,7 @@ function banBlockDetails(ban: ReportRow) {
|
|||||||
Duration: duration,
|
Duration: duration,
|
||||||
IsBan: true,
|
IsBan: true,
|
||||||
Message: 'Rule violation',
|
Message: 'Rule violation',
|
||||||
TimeoutStartedAt: ban.created_at,
|
TimeoutStartedAt: startedAtIso,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +331,9 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
'screen) — so a caller with one in force gets ' +
|
'screen) — so a caller with one in force gets ' +
|
||||||
'`IsBan: true`, the `ReportCategory` the report was filed under, the fixed ' +
|
'`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 ' +
|
'`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 ' +
|
'seconds from there to `ban_expires` (2147483647, the int32 max, for a permanent ' +
|
||||||
'ban). ' +
|
'ban). ' +
|
||||||
'`PlayerIdReporter` stays null: it names a kicking host, and the reporter is not ' +
|
'`PlayerIdReporter` stays null: it names a kicking host, and the reporter is not ' +
|
||||||
|
|||||||
@@ -47,9 +47,13 @@ import {
|
|||||||
banFromReport,
|
banFromReport,
|
||||||
createReport,
|
createReport,
|
||||||
getActiveBan,
|
getActiveBan,
|
||||||
|
getBansInForce,
|
||||||
|
getReportById,
|
||||||
getReportsAgainst,
|
getReportsAgainst,
|
||||||
|
getTopReported,
|
||||||
isPlayerBanned,
|
isPlayerBanned,
|
||||||
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||||
|
searchReports,
|
||||||
} from '../../reports-db'
|
} from '../../reports-db'
|
||||||
import {
|
import {
|
||||||
CheerCategory,
|
CheerCategory,
|
||||||
@@ -4677,6 +4681,183 @@ describe('player reports', () => {
|
|||||||
expect(await banFromReport(env.DB, 999_999)).toBeNull()
|
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
|
// 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.
|
// matchmake and login refuse on, described rather than merely enforced.
|
||||||
describe('moderationBlockDetails', () => {
|
describe('moderationBlockDetails', () => {
|
||||||
@@ -4729,16 +4910,16 @@ describe('player reports', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Duration and TimeoutStartedAt are a pair in the client — the block runs from the
|
// 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
|
// start for the duration. The start is `banned_at`, the instant the ban was handed
|
||||||
// the ban itself landed), and a permanent ban runs for the largest span the int
|
// down (see 0020_report_ban_audit.sql), NOT the report's created_at: the two can be
|
||||||
// holds.
|
// months apart. A permanent ban runs for the largest span the int holds.
|
||||||
test('describes a permanent ban', async () => {
|
test('describes a permanent ban', async () => {
|
||||||
await submit(
|
await submit(
|
||||||
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
|
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
|
||||||
await bearer()
|
await bearer()
|
||||||
)
|
)
|
||||||
const [row] = await getReportsAgainst(env.DB, 221)
|
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({
|
expect(await details('POST', '221')).toEqual({
|
||||||
...NOT_BLOCKED,
|
...NOT_BLOCKED,
|
||||||
@@ -4749,21 +4930,79 @@ describe('player reports', () => {
|
|||||||
// the reporter is not shown to the player they reported (PlayerIdReporter
|
// the reporter is not shown to the player they reported (PlayerIdReporter
|
||||||
// stays null).
|
// stays null).
|
||||||
Message: 'Rule violation',
|
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
|
// 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.
|
// 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())
|
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
|
||||||
const [row] = await getReportsAgainst(env.DB, 222)
|
const [row] = await getReportsAgainst(env.DB, 222)
|
||||||
const banExpires = new Date(Date.parse(row!.created_at) + 3600 * 1000)
|
// The expiry is set relative to NOW, which is what a moderator handing down a
|
||||||
await banFromReport(env.DB, row!.id, { banExpires: banExpires.toISOString() })
|
// 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({
|
// `Duration` is asserted as a range below, so it is left out of the object match
|
||||||
...NOT_BLOCKED,
|
// — 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<string, unknown>
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
...unblocked,
|
||||||
ReportCategory: 103,
|
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<string, unknown>
|
||||||
|
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,
|
Duration: 3600,
|
||||||
IsBan: true,
|
IsBan: true,
|
||||||
Message: 'Rule violation',
|
Message: 'Rule violation',
|
||||||
|
|||||||
+34
-208
@@ -12,31 +12,22 @@ import {
|
|||||||
QUEST_DOWNLOAD_URL,
|
QUEST_DOWNLOAD_URL,
|
||||||
SOURCE_REPO,
|
SOURCE_REPO,
|
||||||
} from '../links'
|
} 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'
|
import type { ReactNode } from 'react'
|
||||||
|
import type { Hosts } from './api'
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Site config from `www`. `signupEnabled` is false when the operator has no Turnstile
|
* Site config from `www`. `signupEnabled` is false when the operator has no Turnstile
|
||||||
@@ -167,144 +158,6 @@ const ACCESSIBILITY_LABEL: Record<number, string> = {
|
|||||||
*/
|
*/
|
||||||
const WEB_PLATFORM = '4'
|
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<string, unknown>, 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<string, string>
|
|
||||||
/** 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<T = Record<string, unknown>>(url: string, opts: CallOptions = {}): Promise<T> {
|
|
||||||
const headers: Record<string, string> = {}
|
|
||||||
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<string, unknown>
|
|
||||||
|
|
||||||
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`. */
|
/** The signed-in account, straight from `accounts`. */
|
||||||
const fetchMe = (): Promise<SelfAccount> =>
|
const fetchMe = (): Promise<SelfAccount> =>
|
||||||
call<SelfAccount>(`${where().accounts}/account/me`, { authed: true })
|
call<SelfAccount>(`${where().accounts}/account/me`, { authed: true })
|
||||||
@@ -586,28 +439,6 @@ const coachMessage = (
|
|||||||
authed: true,
|
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<number> {
|
|
||||||
const name = input.trim().replace(/^@/, '')
|
|
||||||
if (name === '') throw new Error('Enter a username to send to.')
|
|
||||||
const matches = await call<Array<{ accountId?: number; username?: string }>>(
|
|
||||||
`${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. */
|
/** Minimal history-based router: current pathname + a navigate() that pushes state. */
|
||||||
function useRouter() {
|
function useRouter() {
|
||||||
const [path, setPath] = useState(() => window.location.pathname)
|
const [path, setPath] = useState(() => window.location.pathname)
|
||||||
@@ -887,9 +718,9 @@ export function App() {
|
|||||||
// closed rather than guessing where the workers are.
|
// closed rather than guessing where the workers are.
|
||||||
call<SiteConfig & { hosts: Hosts }>('/api/config')
|
call<SiteConfig & { hosts: Hosts }>('/api/config')
|
||||||
.then(async ({ hosts: resolved, ...site }) => {
|
.then(async ({ hosts: resolved, ...site }) => {
|
||||||
hosts = resolved
|
setHosts(resolved)
|
||||||
setConfig(site)
|
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
|
// 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.
|
// it, so this just falls back to signed-out rather than surfacing an error.
|
||||||
await fetchMe()
|
await fetchMe()
|
||||||
@@ -937,6 +768,12 @@ export function App() {
|
|||||||
// Its own page rather than a dashboard tab: this path is Discord's registered
|
// 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.
|
// redirect URI, so it has to be one stable URL a cold load can land on.
|
||||||
<ClaimPage account={account} config={config} navigate={navigate} />
|
<ClaimPage account={account} config={config} navigate={navigate} />
|
||||||
|
) : 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.
|
||||||
|
<ModerationPage account={account} navigate={navigate} />
|
||||||
) : roomId !== null ? (
|
) : roomId !== null ? (
|
||||||
<RoomPage account={account} roomId={roomId} navigate={navigate} />
|
<RoomPage account={account} roomId={roomId} navigate={navigate} />
|
||||||
) : (
|
) : (
|
||||||
@@ -995,6 +832,18 @@ function NavBar({
|
|||||||
</a>
|
</a>
|
||||||
{account === undefined ? null : account ? (
|
{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() && (
|
||||||
|
<Link
|
||||||
|
to="/moderation"
|
||||||
|
navigate={navigate}
|
||||||
|
className={path === '/moderation' ? 'active' : ''}
|
||||||
|
>
|
||||||
|
Moderation
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<Link to="/account" navigate={navigate} className={path === '/account' ? 'active' : ''}>
|
<Link to="/account" navigate={navigate} className={path === '/account' ? 'active' : ''}>
|
||||||
My account
|
My account
|
||||||
</Link>
|
</Link>
|
||||||
@@ -1830,29 +1679,6 @@ function VisibilityBadge({ accessibility }: { accessibility: number }) {
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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<string>) => {
|
|
||||||
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
|
* 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
|
* Cloudflare at runtime (see loadTurnstile) rather than bundled, so it isn't in
|
||||||
|
|||||||
@@ -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<number, string> = {
|
||||||
|
[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<number, PublicAccount> }) {
|
||||||
|
const account = names.get(id)
|
||||||
|
return (
|
||||||
|
<span className="mod-player">
|
||||||
|
{account?.username ? `@${account.username}` : 'unknown'}
|
||||||
|
<span className="muted"> #{id}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T>(path: string | null, deps: unknown[] = []) {
|
||||||
|
const [data, setData] = useState<T | null>(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<T>(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<Map<number, PublicAccount>>(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<Tab>('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<number | null>(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<ReportRow | null>(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 (
|
||||||
|
<main className="shell wide">
|
||||||
|
<section className="card">
|
||||||
|
<p className="muted">Checking your session…</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account === null) {
|
||||||
|
return (
|
||||||
|
<main className="shell">
|
||||||
|
<section className="card">
|
||||||
|
<h1>Moderation</h1>
|
||||||
|
<p className="muted">You need to be signed in to use the moderation tools.</p>
|
||||||
|
<button onClick={() => navigate('/login')}>Sign in</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<main className="shell">
|
||||||
|
<section className="card">
|
||||||
|
<h1>Moderation</h1>
|
||||||
|
<p className="muted">
|
||||||
|
This area is for moderators. Your account doesn't have the moderator or developer
|
||||||
|
role.
|
||||||
|
</p>
|
||||||
|
<button onClick={() => navigate('/')}>Back to the homepage</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="shell mod">
|
||||||
|
<section className="card identity">
|
||||||
|
<div className="muted">Moderating as</div>
|
||||||
|
<div className="big">@{account.username}</div>
|
||||||
|
<div className="handle">
|
||||||
|
#{account.accountId} · actions you take here are recorded against this account
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{openPlayer !== null ? (
|
||||||
|
<PlayerHistory
|
||||||
|
playerId={openPlayer}
|
||||||
|
revision={revision}
|
||||||
|
onBan={setBanning}
|
||||||
|
onClose={() => setOpenPlayer(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="workspace">
|
||||||
|
<nav className="vtabs">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={t.id === tab ? 'active' : ''}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="panel">
|
||||||
|
{tab === 'queue' ? (
|
||||||
|
<ReportQueue revision={revision} onOpenPlayer={setOpenPlayer} />
|
||||||
|
) : tab === 'search' ? (
|
||||||
|
<ReportSearch
|
||||||
|
revision={revision}
|
||||||
|
onOpenPlayer={setOpenPlayer}
|
||||||
|
onBan={setBanning}
|
||||||
|
onChanged={changed}
|
||||||
|
/>
|
||||||
|
) : tab === 'bans' ? (
|
||||||
|
<StandingBans revision={revision} onOpenPlayer={setOpenPlayer} onChanged={changed} />
|
||||||
|
) : (
|
||||||
|
<FileReport
|
||||||
|
onFiled={(report) => {
|
||||||
|
changed()
|
||||||
|
setBanning(report)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{banning !== null && (
|
||||||
|
<BanDialog
|
||||||
|
report={banning}
|
||||||
|
onClose={() => setBanning(null)}
|
||||||
|
onDone={() => {
|
||||||
|
setBanning(null)
|
||||||
|
changed()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ReportedPlayerTally[]>(
|
||||||
|
`/api/staff/reports/top-reported?sinceDays=${encodeURIComponent(sinceDays)}&minReports=${encodeURIComponent(minReports)}`,
|
||||||
|
[revision]
|
||||||
|
)
|
||||||
|
const names = useNames((data ?? []).map((row) => row.playerId))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Report queue</h2>
|
||||||
|
<p className="muted">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div className="mod-filters">
|
||||||
|
<label>
|
||||||
|
Window
|
||||||
|
<select value={sinceDays} onChange={(e) => setSinceDays(e.target.value)}>
|
||||||
|
<option value="7">Last 7 days</option>
|
||||||
|
<option value="30">Last 30 days</option>
|
||||||
|
<option value="90">Last 90 days</option>
|
||||||
|
{/* The server reads 0 as "no window" — see the handler. */}
|
||||||
|
<option value="0">All time</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Minimum reports
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={minReports}
|
||||||
|
onChange={(e) => setMinReports(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{data === null ? (
|
||||||
|
!error && <p className="muted">Loading…</p>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<p className="muted">Nobody has been reported that many times in this window.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mod-scroll">
|
||||||
|
<table className="mod-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Player</th>
|
||||||
|
<th>Reports</th>
|
||||||
|
<th>Reporters</th>
|
||||||
|
<th>Last reported</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((row) => (
|
||||||
|
<tr key={row.playerId}>
|
||||||
|
<td>
|
||||||
|
<PlayerName id={row.playerId} names={names} />
|
||||||
|
{row.bannedNow && <span className="badge mod-banned">Banned</span>}
|
||||||
|
</td>
|
||||||
|
<td>{row.reports}</td>
|
||||||
|
<td>{row.distinctReporters}</td>
|
||||||
|
<td>{when(row.lastReportAt)}</td>
|
||||||
|
<td>
|
||||||
|
<button className="linkish" onClick={() => onOpenPlayer(row.playerId)}>
|
||||||
|
History
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<SearchFilters>(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<string | null>('?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 (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Search reports</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Every report ever filed, newest first. The player boxes take an id or an @username.
|
||||||
|
</p>
|
||||||
|
<form className="mod-filters" onSubmit={submit}>
|
||||||
|
<label>
|
||||||
|
Reported player
|
||||||
|
<input
|
||||||
|
value={form.reported}
|
||||||
|
placeholder="@name or id"
|
||||||
|
onChange={(e) => setForm({ ...form, reported: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Reported by
|
||||||
|
<input
|
||||||
|
value={form.reporter}
|
||||||
|
placeholder="@name or id"
|
||||||
|
onChange={(e) => setForm({ ...form, reporter: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Category
|
||||||
|
<select
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Any</option>
|
||||||
|
{Object.entries(CATEGORY_LABEL).map(([id, label]) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Ban state
|
||||||
|
<select
|
||||||
|
value={form.banned}
|
||||||
|
onChange={(e) => setForm({ ...form, banned: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option value="true">Actioned (banned)</option>
|
||||||
|
<option value="false">Not actioned</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
From
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.from}
|
||||||
|
onChange={(e) => setForm({ ...form, from: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
To
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.to}
|
||||||
|
onChange={(e) => setForm({ ...form, to: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="mod-filter-actions">
|
||||||
|
<button type="submit" disabled={pending}>
|
||||||
|
{pending ? 'Searching…' : 'Search'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linkish"
|
||||||
|
onClick={() => {
|
||||||
|
setForm(EMPTY_FILTERS)
|
||||||
|
setSkip(0)
|
||||||
|
setQuery(`?take=${PAGE_SIZE}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{formError && <p className="error">{formError}</p>}
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{data === null ? (
|
||||||
|
!error && <p className="muted">Loading…</p>
|
||||||
|
) : reports.length === 0 ? (
|
||||||
|
<p className="muted">No reports match those filters.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ReportTable
|
||||||
|
reports={reports}
|
||||||
|
names={names}
|
||||||
|
onOpenPlayer={onOpenPlayer}
|
||||||
|
onBan={onBan}
|
||||||
|
onChanged={onChanged}
|
||||||
|
/>
|
||||||
|
<div className="mod-pager">
|
||||||
|
<button disabled={skip === 0} onClick={() => setSkip(Math.max(skip - PAGE_SIZE, 0))}>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="muted">
|
||||||
|
{skip + 1}–{Math.min(skip + PAGE_SIZE, total)} of {total}
|
||||||
|
</span>
|
||||||
|
<button disabled={skip + PAGE_SIZE >= total} onClick={() => setSkip(skip + PAGE_SIZE)}>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An id typed as a number, or an `@username` resolved through `accounts`. */
|
||||||
|
async function playerIdFrom(input: string): Promise<number> {
|
||||||
|
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<number, PublicAccount>
|
||||||
|
onOpenPlayer?: (id: number) => void
|
||||||
|
onBan: (report: ReportRow) => void
|
||||||
|
onChanged: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mod-scroll">
|
||||||
|
<table className="mod-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Filed</th>
|
||||||
|
<th>Against</th>
|
||||||
|
<th>By</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>Ban</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{reports.map((report) => (
|
||||||
|
<tr key={report.id}>
|
||||||
|
<td>{report.id}</td>
|
||||||
|
<td>{when(report.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
{onOpenPlayer ? (
|
||||||
|
<button
|
||||||
|
className="linkish"
|
||||||
|
onClick={() => onOpenPlayer(report.reported_player_id)}
|
||||||
|
>
|
||||||
|
<PlayerName id={report.reported_player_id} names={names} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<PlayerName id={report.reported_player_id} names={names} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<PlayerName id={report.reporter_player_id} names={names} />
|
||||||
|
</td>
|
||||||
|
<td>{reportKind(report)}</td>
|
||||||
|
<td>{categoryLabel(report.report_category)}</td>
|
||||||
|
{/* The reporter's own words, untruncated in the title so a long
|
||||||
|
description is readable without leaving the table. */}
|
||||||
|
<td className="mod-details" title={report.details ?? ''}>
|
||||||
|
{report.details ?? <span className="muted">none given</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{report.banned === 1 ? (
|
||||||
|
<span className="badge mod-banned">{expiryLabel(report)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="muted">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{report.banned === 1 ? (
|
||||||
|
<LiftBanButton report={report} onDone={onChanged} />
|
||||||
|
) : (
|
||||||
|
<button className="linkish" onClick={() => onBan(report)}>
|
||||||
|
Ban…
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="linkish"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
void run(async () => {
|
||||||
|
if (!confirm(`Lift the ban on player #${report.reported_player_id}?`)) return ''
|
||||||
|
await call(`/api/staff/reports/${report.id}/ban`, {
|
||||||
|
authed: true,
|
||||||
|
json: { banned: false },
|
||||||
|
})
|
||||||
|
onDone()
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pending ? 'Lifting…' : 'Lift ban'}
|
||||||
|
</button>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every ban in force right now. */
|
||||||
|
function StandingBans({
|
||||||
|
revision,
|
||||||
|
onOpenPlayer,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
revision: number
|
||||||
|
onOpenPlayer: (id: number) => void
|
||||||
|
onChanged: () => void
|
||||||
|
}) {
|
||||||
|
const { data, error } = useStaffData<ReportRow[]>('/api/staff/bans', [revision])
|
||||||
|
const bans = data ?? []
|
||||||
|
const names = useNames(bans.flatMap((b) => [b.reported_player_id, b.banned_by_player_id ?? 0]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Standing bans</h2>
|
||||||
|
<p className="muted">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{data === null ? (
|
||||||
|
!error && <p className="muted">Loading…</p>
|
||||||
|
) : bans.length === 0 ? (
|
||||||
|
<p className="muted">Nobody is banned.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mod-scroll">
|
||||||
|
<table className="mod-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Player</th>
|
||||||
|
<th>Report</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Banned</th>
|
||||||
|
<th>By</th>
|
||||||
|
<th>Lifts</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bans.map((ban) => (
|
||||||
|
<tr key={ban.id}>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="linkish"
|
||||||
|
onClick={() => onOpenPlayer(ban.reported_player_id)}
|
||||||
|
>
|
||||||
|
<PlayerName id={ban.reported_player_id} names={names} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>#{ban.id}</td>
|
||||||
|
<td>{categoryLabel(ban.report_category)}</td>
|
||||||
|
{/* `banned_at` where there is one; a ban handed down before that
|
||||||
|
column existed has only the report's own date to show. */}
|
||||||
|
<td>
|
||||||
|
{ban.banned_at === null ? (
|
||||||
|
<span className="muted" title="Recorded before ban timestamps were kept">
|
||||||
|
{when(ban.created_at)}?
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
when(ban.banned_at)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{ban.banned_by_player_id === null ? (
|
||||||
|
<span className="muted">unknown</span>
|
||||||
|
) : (
|
||||||
|
<PlayerName id={ban.banned_by_player_id} names={names} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{ban.ban_expires === null ? 'Never' : when(ban.ban_expires)}</td>
|
||||||
|
<td>
|
||||||
|
<LiftBanButton report={ban} onDone={onChanged} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<>
|
||||||
|
<section className="card">
|
||||||
|
<button className="linkish" onClick={onClose}>
|
||||||
|
← Back to the tables
|
||||||
|
</button>
|
||||||
|
<h2>
|
||||||
|
<PlayerName id={playerId} names={names} />
|
||||||
|
</h2>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{data === null ? (
|
||||||
|
!error && <p className="muted">Loading…</p>
|
||||||
|
) : data.activeBan === null ? (
|
||||||
|
<p className="muted">
|
||||||
|
Not banned. {reports.length} report{reports.length === 1 ? '' : 's'} on file,{' '}
|
||||||
|
{warnings.length} warning{warnings.length === 1 ? '' : 's'} handed down.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="ok">
|
||||||
|
Banned on report #{data.activeBan.id} for{' '}
|
||||||
|
{categoryLabel(data.activeBan.report_category)} —{' '}
|
||||||
|
{data.activeBan.ban_expires === null
|
||||||
|
? 'permanently'
|
||||||
|
: `until ${when(data.activeBan.ban_expires)}`}
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<LinkedAccountsPanel playerId={playerId} />
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Reports</h2>
|
||||||
|
{reports.length === 0 ? (
|
||||||
|
<p className="muted">Nobody has reported this player.</p>
|
||||||
|
) : (
|
||||||
|
<ReportTable reports={reports} names={names} onBan={onBan} onChanged={onClose} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Warnings</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Warnings a moderator handed down, from the game's own warning endpoint. Nothing here
|
||||||
|
dispatches them; the rows are the record.
|
||||||
|
</p>
|
||||||
|
{warnings.length === 0 ? (
|
||||||
|
<p className="muted">No warnings on file.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mod-scroll">
|
||||||
|
<table className="mod-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>By</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Shown to the player</th>
|
||||||
|
<th>Internal note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{warnings.map((warning) => (
|
||||||
|
<tr key={warning.id}>
|
||||||
|
<td>{when(warning.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
<PlayerName id={warning.moderator_player_id} names={names} />
|
||||||
|
</td>
|
||||||
|
<td>{categoryLabel(warning.report_category)}</td>
|
||||||
|
<td>{warning.display_reason ?? <span className="muted">—</span>}</td>
|
||||||
|
<td>{warning.moderator_note ?? <span className="muted">—</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<section className="card">
|
||||||
|
<p className="error">{error}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
if (data === null) return null
|
||||||
|
|
||||||
|
const arms = [data.arms.platform && 'shared platform identity', data.arms.ip && 'shared IP']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' and ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Who a ban would also reach</h2>
|
||||||
|
{arms === '' ? (
|
||||||
|
<p className="muted">
|
||||||
|
Ban-evasion matching is switched off on this server, so a ban reaches exactly the one
|
||||||
|
account.
|
||||||
|
</p>
|
||||||
|
) : data.linked.length === 0 ? (
|
||||||
|
<p className="muted">
|
||||||
|
No other account shares a {arms} with this one — a ban reaches only them.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="muted">
|
||||||
|
Evasion matching is on for {arms}. These accounts would be blocked too:
|
||||||
|
</p>
|
||||||
|
<ul className="mod-linked">
|
||||||
|
{data.linked.map((linked) => (
|
||||||
|
<li key={`${linked.via}-${linked.accountId}-${linked.value}`}>
|
||||||
|
<span className={`badge ${linked.via === 'platform' ? 'live' : ''}`}>
|
||||||
|
{linked.via === 'platform' ? 'Same platform login' : 'Same IP'}
|
||||||
|
</span>{' '}
|
||||||
|
{linked.username ? `@${linked.username}` : 'unknown'}
|
||||||
|
<span className="muted">
|
||||||
|
{' '}
|
||||||
|
#{linked.accountId} · {linked.value}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{data.arms.ip && (
|
||||||
|
<p className="muted">
|
||||||
|
An IP match is not proof of the same person. Households and shared networks look
|
||||||
|
identical to it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<section className="card">
|
||||||
|
<h2>File a report</h2>
|
||||||
|
<p className="muted">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void run(async () => {
|
||||||
|
const report = await call<ReportRow>('/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}.`
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Player
|
||||||
|
<input
|
||||||
|
value={player}
|
||||||
|
placeholder="@name or id"
|
||||||
|
required
|
||||||
|
onChange={(e) => setPlayer(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Category
|
||||||
|
<select value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||||
|
{FILEABLE_CATEGORIES.map((id) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{categoryLabel(id)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
What happened
|
||||||
|
<textarea
|
||||||
|
value={details}
|
||||||
|
rows={4}
|
||||||
|
placeholder="What you saw, and where you saw it. Kept internally."
|
||||||
|
onChange={(e) => setDetails(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Room id (optional)
|
||||||
|
<input
|
||||||
|
value={roomId}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="If it happened in a particular room"
|
||||||
|
onChange={(e) => setRoomId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{done && <p className="ok">{done}</p>}
|
||||||
|
<button type="submit" disabled={pending}>
|
||||||
|
{pending ? 'Filing…' : 'File report'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ban lengths offered, in the order the dialog lists them. `null` is permanent. */
|
||||||
|
const BAN_LENGTHS: Array<{ label: string; days: number | null }> = [
|
||||||
|
{ label: '1 day', days: 1 },
|
||||||
|
{ label: '3 days', days: 3 },
|
||||||
|
{ label: '7 days', days: 7 },
|
||||||
|
{ label: '30 days', days: 30 },
|
||||||
|
{ label: '90 days', days: 90 },
|
||||||
|
{ label: 'Permanent', days: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand down a ban on one report.
|
||||||
|
*
|
||||||
|
* Takes a LENGTH rather than a date: a moderator decides "7 days", and the server turns
|
||||||
|
* that into the expiry it stores, so nobody is doing calendar arithmetic over a ban. The
|
||||||
|
* evasion preview is shown here too — this is the moment the blast radius matters.
|
||||||
|
*
|
||||||
|
* Says out loud what applying it does beyond the row: the player is thrown out of the
|
||||||
|
* instance they are standing in, which is the part that is not obvious from "ban".
|
||||||
|
*/
|
||||||
|
function BanDialog({
|
||||||
|
report,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
report: ReportRow
|
||||||
|
onClose: () => void
|
||||||
|
onDone: () => void
|
||||||
|
}) {
|
||||||
|
const [choice, setChoice] = useState('7')
|
||||||
|
const { pending, error, run } = useAction()
|
||||||
|
const names = useNames([report.reported_player_id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card mod-dialog">
|
||||||
|
<h2>
|
||||||
|
Ban <PlayerName id={report.reported_player_id} names={names} />
|
||||||
|
</h2>
|
||||||
|
<p className="muted">
|
||||||
|
On report #{report.id} — {categoryLabel(report.report_category)}
|
||||||
|
{report.details ? `: “${report.details}”` : ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<LinkedAccountsPanel playerId={report.reported_player_id} />
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void run(async () => {
|
||||||
|
const length = BAN_LENGTHS.find((l) => String(l.days) === choice)
|
||||||
|
await call(`/api/staff/reports/${report.id}/ban`, {
|
||||||
|
authed: true,
|
||||||
|
// `permanent` rather than an absent duration: the server reads "no
|
||||||
|
// duration" as permanent too, but saying it explicitly means a bug in
|
||||||
|
// this form can't quietly hand out a life ban.
|
||||||
|
json:
|
||||||
|
length?.days === null || length === undefined
|
||||||
|
? { banned: true, permanent: true }
|
||||||
|
: { banned: true, days: length.days },
|
||||||
|
})
|
||||||
|
onDone()
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Length
|
||||||
|
<select value={choice} onChange={(e) => setChoice(e.target.value)}>
|
||||||
|
{BAN_LENGTHS.map((length) => (
|
||||||
|
<option key={length.label} value={String(length.days)}>
|
||||||
|
{length.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className="muted">
|
||||||
|
They are thrown out of the room they're in right now, and refused at matchmaking
|
||||||
|
until the ban lifts. The report stays as the record either way, and a ban can be lifted
|
||||||
|
from the tables.
|
||||||
|
</p>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<div className="mod-filter-actions">
|
||||||
|
<button type="submit" disabled={pending}>
|
||||||
|
{pending ? 'Banning…' : 'Apply ban'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="linkish" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SPA's worker plumbing: the session token, where the workers live, and the one
|
||||||
|
* `call` every view makes its requests through.
|
||||||
|
*
|
||||||
|
* Extracted from App.tsx so the moderation panel (Moderation.tsx) can share it — App
|
||||||
|
* RENDERS that panel, so it can't be the module the panel imports from. The token is
|
||||||
|
* module-level mutable state, which belongs in a module of its own rather than in
|
||||||
|
* whichever component happened to define it first.
|
||||||
|
*
|
||||||
|
* 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 what cannot leave the server: the
|
||||||
|
* site config, Turnstile-gated signup, the Discord benefits claim, and the staff
|
||||||
|
* moderation endpoints (see www.app.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Where each worker lives. From `/api/config`, never baked into this build. */
|
||||||
|
export interface Hosts {
|
||||||
|
auth: string
|
||||||
|
accounts: string
|
||||||
|
api: string
|
||||||
|
img: string
|
||||||
|
notify: string
|
||||||
|
rooms: string
|
||||||
|
cdn: string
|
||||||
|
storage: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
|
||||||
|
export function setToken(next: string | null) {
|
||||||
|
token = next
|
||||||
|
if (next === null) localStorage.removeItem(TOKEN_KEY)
|
||||||
|
else localStorage.setItem(TOKEN_KEY, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a session token is stored — what the app asks on startup to decide between
|
||||||
|
* "check who this is" and "signed out". A getter rather than an exported `token`, so the
|
||||||
|
* only way to change it stays {@link setToken} and nothing can drift from localStorage.
|
||||||
|
*/
|
||||||
|
export const hasToken = (): boolean => token !== null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|
||||||
|
/** Record where the workers live, once `/api/config` has said. */
|
||||||
|
export function setHosts(next: Hosts) {
|
||||||
|
hosts = next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The hostnames, once known. Throws rather than guessing a domain. */
|
||||||
|
export 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".
|
||||||
|
*/
|
||||||
|
export 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<string, unknown>, 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<string, string>
|
||||||
|
/** 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. */
|
||||||
|
export async function call<T = Record<string, unknown>>(
|
||||||
|
url: string,
|
||||||
|
opts: CallOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
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<string, unknown>
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small hook wrapping a submit handler with pending/error/success state. */
|
||||||
|
export function useAction() {
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [done, setDone] = useState('')
|
||||||
|
|
||||||
|
const run = useCallback(async (fn: () => Promise<string>) => {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* acting on 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.
|
||||||
|
*/
|
||||||
|
export async function accountIdForUsername(input: string): Promise<number> {
|
||||||
|
const name = input.trim().replace(/^@/, '')
|
||||||
|
if (name === '') throw new Error('Enter a username.')
|
||||||
|
const matches = await call<Array<{ accountId?: number; username?: string }>>(
|
||||||
|
`${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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A player as `accounts` serves them publicly — what a bulk lookup hands back. */
|
||||||
|
export interface PublicAccount {
|
||||||
|
accountId: number
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names for a set of account ids, through `accounts`' bulk lookup — the same endpoint the
|
||||||
|
* game uses to put names on a friends list.
|
||||||
|
*
|
||||||
|
* The staff endpoints deliberately return ids only: a report row stores ids, and joining
|
||||||
|
* names onto it server-side would couple `www`'s moderation reads to a table `auth` owns.
|
||||||
|
* So the page asks the real service instead, in one request for the whole page of results.
|
||||||
|
*
|
||||||
|
* Every requested id comes back — `accounts` synthesizes a default row for one it has
|
||||||
|
* never seen — so a lookup can't silently drop a player and leave a blank cell. A failed
|
||||||
|
* request resolves EMPTY rather than throwing: names are decoration on a moderation table
|
||||||
|
* whose ids are the real content, and losing them must not blank the table.
|
||||||
|
*/
|
||||||
|
export async function usernamesFor(ids: number[]): Promise<Map<number, PublicAccount>> {
|
||||||
|
const unique = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))]
|
||||||
|
if (unique.length === 0) return new Map()
|
||||||
|
const accounts = await call<PublicAccount[]>(
|
||||||
|
`${where().accounts}/account/bulk?id=${unique.join(',')}`
|
||||||
|
).catch(() => [])
|
||||||
|
return new Map(Array.isArray(accounts) ? accounts.map((a) => [a.accountId, a]) : [])
|
||||||
|
}
|
||||||
@@ -959,6 +959,7 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -999,6 +1000,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
|
select:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 0;
|
outline-offset: 0;
|
||||||
@@ -1150,3 +1152,151 @@ button[type='submit']:disabled {
|
|||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Moderation panel --------------------------------------------------- */
|
||||||
|
/*
|
||||||
|
* The staff surface at /moderation (see Moderation.tsx). Deliberately plainer than the
|
||||||
|
* rest of the site: this is a working tool read for minutes at a time, so it trades the
|
||||||
|
* poster's generous spacing for density, and spends no orange except on the actions it
|
||||||
|
* shares with every other form. Nothing here is warmer than the tables it holds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* The panel needs the width — six or seven columns don't fit the 880px the account
|
||||||
|
dashboard is sized for, and a moderator is at a desk. */
|
||||||
|
.shell.mod {
|
||||||
|
max-width: 1180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A row of filter controls that wraps instead of stacking: each one is short, and
|
||||||
|
stacked they push the results below the fold. */
|
||||||
|
.mod-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 16px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-filters label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
min-width: 150px;
|
||||||
|
flex: 1 1 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-filter-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-filter-actions button {
|
||||||
|
margin: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables are the one thing allowed to be wider than the page — a report row has more in
|
||||||
|
it than a phone is wide, and wrapping the cells would make it unscannable. */
|
||||||
|
.mod-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0 -22px;
|
||||||
|
padding: 0 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0 12px 8px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-table td {
|
||||||
|
padding: 10px 12px 10px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-table button.linkish {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A name and the id under it. The id is always shown: rows carry ids, names are a
|
||||||
|
lookup on top, and a moderator acting on the wrong account is the failure to avoid. */
|
||||||
|
.mod-player {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The reporter's own words — the one cell allowed to wrap, clamped to a sensible width
|
||||||
|
so a long description doesn't push the actions off the table. Full text in the title. */
|
||||||
|
.mod-details {
|
||||||
|
white-space: normal;
|
||||||
|
max-width: 320px;
|
||||||
|
min-width: 180px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Red, unlike every other badge: a ban is the one state on these tables that someone
|
||||||
|
needs to notice without reading. */
|
||||||
|
.badge.mod-banned {
|
||||||
|
color: var(--error);
|
||||||
|
border-color: color-mix(in srgb, var(--error) 45%, transparent);
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-pager button {
|
||||||
|
margin: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The evasion preview. A list rather than a table: each line is one account and one
|
||||||
|
reason, and it is read to decide whether to go ahead, not compared column by column. */
|
||||||
|
.mod-linked {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The ban form, pinned under the tables rather than floating over them: it carries the
|
||||||
|
evasion preview, which can run to several lines, and a modal that scrolls is worse
|
||||||
|
than a panel that is simply below. The accent border is what marks it as the thing
|
||||||
|
waiting on you. */
|
||||||
|
.mod-dialog {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 50%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-dialog .card {
|
||||||
|
background: var(--surface-hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.mod-filters label {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+35
-6
@@ -1,5 +1,6 @@
|
|||||||
import type { HonoApp } from '@repo/hono-helpers'
|
import type { HonoApp } from '@repo/hono-helpers'
|
||||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||||
|
import type { NotificationsHub } from '../../notify/src/notifications-hub'
|
||||||
|
|
||||||
export type Env = SharedHonoEnv & {
|
export type Env = SharedHonoEnv & {
|
||||||
/** Base domain the auth/accounts hosts are derived from (see wrangler.jsonc). */
|
/** Base domain the auth/accounts hosts are derived from (see wrangler.jsonc). */
|
||||||
@@ -7,13 +8,34 @@ export type Env = SharedHonoEnv & {
|
|||||||
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
|
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
|
||||||
ASSETS: Fetcher
|
ASSETS: Fetcher
|
||||||
/**
|
/**
|
||||||
* The shared `recflare` D1. www asks it two things: the live presence head-count
|
* The shared `recflare` D1. www asks it three things: the live presence head-count
|
||||||
* behind `/server-status`, and the caller's `account` row on the benefits claim —
|
* behind `/server-status`, the caller's `account` row on the benefits claim — which
|
||||||
* which is also the one place www WRITES (the `hasPlus`/`discordUserId` pair, through
|
* WRITES the `hasPlus`/`discordUserId` pair, through `@repo/domain`'s `updateAccount`,
|
||||||
* `@repo/domain`'s `updateAccount`, so the blob's shape stays in one module). Every
|
* so the blob's shape stays in one module — and the `report`/`warning` tables behind
|
||||||
* table it can see is owned (and migrated) by another worker; www never migrates.
|
* the staff moderation panel (`/api/staff/*`), which writes reports and bans through
|
||||||
|
* `api`'s reports-db for the same reason. Every table it can see is owned (and
|
||||||
|
* migrated) by another worker; www never migrates.
|
||||||
*/
|
*/
|
||||||
DB: D1Database
|
DB: D1Database
|
||||||
|
/**
|
||||||
|
* SignalR notifications hub (DO owned by the `notify` worker). Bound for ONE thing: a
|
||||||
|
* ban handed down from the staff panel ejects the player from the instance they are
|
||||||
|
* standing in, which needs a `ModerationKick` frame pushed to them (see src/staff.ts).
|
||||||
|
* Without it a ban would only bite on their next matchmake.
|
||||||
|
*/
|
||||||
|
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||||
|
/**
|
||||||
|
* Which ban-EVASION arms the operator enforces — the same knob `match` and `auth`
|
||||||
|
* read, parsed by the same `banEvasionMatch` (in `api`'s bans-db, which owns the
|
||||||
|
* policy). www reads it so the staff panel's "who else would this ban reach" preview
|
||||||
|
* shows what would ACTUALLY happen rather than every possible match.
|
||||||
|
*
|
||||||
|
* It must carry the same value as those workers'. An operator who narrows it on
|
||||||
|
* `match` but not here gets a preview that overstates the blast radius — alarming
|
||||||
|
* rather than dangerous, but wrong. Unset means both arms, as it does everywhere else.
|
||||||
|
* Undeclared in wrangler.jsonc, as it is there: an operator who wants it sets it.
|
||||||
|
*/
|
||||||
|
BAN_EVASION_MATCH?: string
|
||||||
/**
|
/**
|
||||||
* Service binding to the `auth` worker — how the BFF reaches it, so the browser's real
|
* Service binding to the `auth` worker — how the BFF reaches it, so the browser's real
|
||||||
* IP survives the hop (see wrangler.jsonc and src/upstream.ts `postAuthForm`).
|
* IP survives the hop (see wrangler.jsonc and src/upstream.ts `postAuthForm`).
|
||||||
@@ -86,7 +108,14 @@ export type Env = SharedHonoEnv & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Variables can be extended */
|
/** Variables can be extended */
|
||||||
export type Variables = SharedHonoVariables
|
export type Variables = SharedHonoVariables & {
|
||||||
|
/**
|
||||||
|
* The acting moderator's account id on a `/api/staff/*` request, set by `requireStaff`
|
||||||
|
* once it has validated the token. Stashed so a handler reads it without verifying the
|
||||||
|
* same token a second time — and so a handler can only get at it behind that gate.
|
||||||
|
*/
|
||||||
|
staffId: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface App extends HonoApp {
|
export interface App extends HonoApp {
|
||||||
Bindings: Env
|
Bindings: Env
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import { deletePresence, getPresences, refreshInstanceFullness } from '@repo/domain'
|
||||||
|
import { logger } from '@repo/hono-helpers'
|
||||||
|
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||||
|
|
||||||
|
// The report table and the ban policy over it, owned (and migrated) by the `api` worker.
|
||||||
|
// Imported rather than reimplemented: the SQL belongs with the table, and `banFromReport`
|
||||||
|
// is the same write `api` would do. www owns the ENDPOINTS, not the storage — see the
|
||||||
|
// module comment below.
|
||||||
|
import { banEvasionMatch, linkedAccounts } from '../../api/src/bans-db'
|
||||||
|
import {
|
||||||
|
banFromReport,
|
||||||
|
createReport,
|
||||||
|
getActiveBan,
|
||||||
|
getBansInForce,
|
||||||
|
getReportById,
|
||||||
|
getReportsAgainst,
|
||||||
|
getTopReported,
|
||||||
|
searchReports,
|
||||||
|
} from '../../api/src/reports-db'
|
||||||
|
import { getWarningsAgainst } from '../../api/src/warnings-db'
|
||||||
|
// The notification ids and the kick frame's recovered shape, owned by `notify`. Both are
|
||||||
|
// imported as values/types with no runtime dependencies.
|
||||||
|
import { KickReportCategory } from '../../notify/src/notification-payloads'
|
||||||
|
import { NotificationType } from '../../notify/src/notification-types'
|
||||||
|
|
||||||
|
import type { Context, MiddlewareHandler } from 'hono'
|
||||||
|
import type { ReportSearch } from '../../api/src/reports-db'
|
||||||
|
import type { ModerationKickPayload } from '../../notify/src/notification-payloads'
|
||||||
|
import type { App, Env } from './context'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The staff moderation surface — the endpoints behind the `/moderation` panel in the SPA.
|
||||||
|
*
|
||||||
|
* These live on `www` rather than on `api` (which owns the `report` table) on purpose:
|
||||||
|
* every other worker reimplements an endpoint the Rec Room client actually calls, and a
|
||||||
|
* staff panel has no counterpart in the real service. Keeping recflare's own additions
|
||||||
|
* here leaves the game-facing workers a faithful surface, with nothing in them the client
|
||||||
|
* never asked for. So the SQL stays in `api`'s reports-db/bans-db beside the table, and
|
||||||
|
* only the HTTP lives here.
|
||||||
|
*
|
||||||
|
* Where a real game service already answers the question, the SPA asks IT rather than
|
||||||
|
* having www proxy: usernames for the ids in a report come from `accounts`'
|
||||||
|
* `POST /account/bulk`, the same lookup the game does. This module serves only what no
|
||||||
|
* existing endpoint does.
|
||||||
|
*
|
||||||
|
* Every route is gated by {@link requireStaff} — a valid token carrying `moderator` or
|
||||||
|
* `developer`, the operator-granted roles `auth` stamps from an account's
|
||||||
|
* isModerator/isDeveloper flags (see the admin CLI's `grant-moderator` /
|
||||||
|
* `grant-developer`). The SPA's `isAdmin()` gate only decides what to SHOW; this is the
|
||||||
|
* one that decides anything.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roles allowed through. The same set `api`'s warning write and `notify`'s internal
|
||||||
|
* endpoints gate on — staff hold both roles, and moderation is not developer-only.
|
||||||
|
*/
|
||||||
|
const STAFF_ROLES = new Set(['moderator', 'developer'])
|
||||||
|
|
||||||
|
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||||
|
const HUB_INSTANCE = 'global'
|
||||||
|
|
||||||
|
/** Hard cap on a page of search results, whatever the query string asks for. */
|
||||||
|
const MAX_TAKE = 200
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gates a route on a staff token: 401 for a missing or invalid one, 403 for a valid token
|
||||||
|
* without a staff role. The acting moderator is stashed on the context so a handler reads
|
||||||
|
* it without validating the token a second time.
|
||||||
|
*
|
||||||
|
* Two answers, not one, because they mean different things to the page: a 401 is a session
|
||||||
|
* that has expired (the SPA's `call` drops the token and sends them to sign in), while a
|
||||||
|
* 403 is a signed-in player who is not staff — and the panel says so rather than looping
|
||||||
|
* them through a sign-in that would change nothing.
|
||||||
|
*/
|
||||||
|
export const requireStaff: MiddlewareHandler<App> = async (c, next) => {
|
||||||
|
const secret = await c.env.JWT_SECRET.get()
|
||||||
|
const roles = await validateAndGetRoles(c.req.raw, secret)
|
||||||
|
if (roles === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||||
|
if (!roles.some((role) => STAFF_ROLES.has(role))) return c.json({ error: 'Forbidden' }, 403)
|
||||||
|
|
||||||
|
const accountId = await validateAndGetAccountId(c.req.raw, secret)
|
||||||
|
// A token that carries a staff role but no integer `sub` can't be attributed to a
|
||||||
|
// moderator, and every action here is recorded against one.
|
||||||
|
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||||
|
c.set('staffId', accountId)
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The acting moderator, set by {@link requireStaff}. Only valid behind that middleware. */
|
||||||
|
const staffId = (c: Context<App>): number => c.get('staffId')
|
||||||
|
|
||||||
|
/** Read an integer query param, or null when absent or unparseable. */
|
||||||
|
function intQuery(c: Context<App>, name: string): number | null {
|
||||||
|
const raw = c.req.query(name)
|
||||||
|
if (raw === undefined || raw === '') return null
|
||||||
|
const n = Number.parseInt(raw, 10)
|
||||||
|
return Number.isNaN(n) ? null : n
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a tri-state boolean query param: `true`/`false` narrow, absent means "don't
|
||||||
|
* filter". Distinct from a plain `=== 'true'`, which would read an absent param as an
|
||||||
|
* explicit `false` and hide every banned row from an unfiltered search.
|
||||||
|
*/
|
||||||
|
function boolQuery(c: Context<App>, name: string): boolean | null {
|
||||||
|
const raw = c.req.query(name)
|
||||||
|
if (raw === undefined || raw === '') return null
|
||||||
|
return raw === 'true' || raw === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read an ISO-8601 query param, or null when absent or not a date we can parse. */
|
||||||
|
function dateQuery(c: Context<App>, name: string): string | null {
|
||||||
|
const raw = c.req.query(name)
|
||||||
|
if (raw === undefined || raw === '') return null
|
||||||
|
const parsed = Date.parse(raw)
|
||||||
|
return Number.isNaN(parsed) ? null : new Date(parsed).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a ban handed down now should lift, from what the panel posted.
|
||||||
|
*
|
||||||
|
* Accepts a DURATION (`days` and/or `hours`) or an explicit ISO `expires`, because
|
||||||
|
* moderators think in "7 days" and audit trails are in timestamps. `permanent` (or a
|
||||||
|
* body with neither) means null — a ban that never lifts, which is what `ban_expires`
|
||||||
|
* NULL records.
|
||||||
|
*
|
||||||
|
* Returns `undefined` for an expiry that was asked for but can't be honoured (an
|
||||||
|
* unparseable date, or a duration that lands in the past), so the caller refuses the
|
||||||
|
* request rather than quietly making the ban permanent — the one mistake here that
|
||||||
|
* cannot be walked back by waiting.
|
||||||
|
*/
|
||||||
|
function banExpiry(
|
||||||
|
body: { permanent?: unknown; expires?: unknown; days?: unknown; hours?: unknown },
|
||||||
|
now: Date
|
||||||
|
): string | null | undefined {
|
||||||
|
if (body.permanent === true) return null
|
||||||
|
|
||||||
|
if (typeof body.expires === 'string' && body.expires !== '') {
|
||||||
|
const parsed = Date.parse(body.expires)
|
||||||
|
if (Number.isNaN(parsed) || parsed <= now.getTime()) return undefined
|
||||||
|
return new Date(parsed).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Number(body.days ?? 0)
|
||||||
|
const hours = Number(body.hours ?? 0)
|
||||||
|
if (!Number.isFinite(days) || !Number.isFinite(hours)) return undefined
|
||||||
|
const ms = days * 86_400_000 + hours * 3_600_000
|
||||||
|
// No duration at all is a permanent ban; a negative one is a mistake, not a lift.
|
||||||
|
if (ms === 0) return null
|
||||||
|
if (ms < 0) return undefined
|
||||||
|
return new Date(now.getTime() + ms).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw the banned player out of whatever instance they are standing in.
|
||||||
|
*
|
||||||
|
* Without this a ban only takes effect on the player's NEXT matchmake: `match` refuses a
|
||||||
|
* banned player, but nothing revisits a session already in progress, so someone banned
|
||||||
|
* mid-session keeps playing until they leave on their own.
|
||||||
|
*
|
||||||
|
* Mirrors `api`'s instant kick — presence row deleted (they read offline at once and the
|
||||||
|
* instance frees a slot), instance fullness recomputed, and a `ModerationKick` frame (id
|
||||||
|
* 22) pushed EPHEMERALLY. `IsBan` is true here, which is what makes the client's screen
|
||||||
|
* name it as a ban rather than a kick, and an offline player needs no frame: they meet the
|
||||||
|
* ban at `moderationBlockDetails` when they next sign in.
|
||||||
|
*
|
||||||
|
* Entirely best-effort. The ban row is already committed by the time this runs; a hub
|
||||||
|
* hiccup or a missing presence must not fail a ban that has been handed down.
|
||||||
|
*/
|
||||||
|
async function kickBannedPlayer(c: Context<App>, playerId: number, moderatorId: number) {
|
||||||
|
try {
|
||||||
|
const presence = (await getPresences<{ roomInstanceId?: number }>(c.env.DB, [playerId])).get(
|
||||||
|
playerId
|
||||||
|
)
|
||||||
|
const gameSessionId = presence?.roomInstance?.roomInstanceId
|
||||||
|
// Not online, or online but not in an instance (the lobby) — nothing to eject them
|
||||||
|
// from. Their presence row still goes, so they read offline immediately.
|
||||||
|
if (presence) await deletePresence(c.env.DB, playerId)
|
||||||
|
if (gameSessionId === undefined) return
|
||||||
|
|
||||||
|
await refreshInstanceFullness(c.env.DB, gameSessionId)
|
||||||
|
|
||||||
|
const frame: ModerationKickPayload = {
|
||||||
|
ReportCategory: KickReportCategory.Moderator,
|
||||||
|
Duration: 0,
|
||||||
|
GameSessionId: gameSessionId,
|
||||||
|
IsHostKick: false,
|
||||||
|
Message: 'You have been banned.',
|
||||||
|
PlayerIdReporter: null,
|
||||||
|
IsBan: true,
|
||||||
|
IsVoiceModAutoban: false,
|
||||||
|
IsWarning: false,
|
||||||
|
VoteKickReason: '',
|
||||||
|
TimeoutStartedAt: null,
|
||||||
|
}
|
||||||
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
|
||||||
|
[playerId],
|
||||||
|
NotificationType.ModerationKick,
|
||||||
|
{ ...frame }
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('failed to kick a banned player out of their instance', {
|
||||||
|
playerId,
|
||||||
|
moderatorId,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The evasion arms in force, read from the same knob `api` and `match` read. */
|
||||||
|
const armsFor = (env: Env) => banEvasionMatch(env.BAN_EVASION_MATCH)
|
||||||
|
|
||||||
|
// ---- Handlers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page of reports matching the panel's filters, newest first.
|
||||||
|
*
|
||||||
|
* Returns ids only — no usernames. The page resolves those itself through `accounts`'
|
||||||
|
* bulk lookup, the same endpoint the game uses, so this stays one query against one table.
|
||||||
|
*/
|
||||||
|
export async function searchReportsHandler(c: Context<App>) {
|
||||||
|
const filters: ReportSearch = {
|
||||||
|
reportedPlayerId: intQuery(c, 'reportedPlayerId'),
|
||||||
|
reporterPlayerId: intQuery(c, 'reporterPlayerId'),
|
||||||
|
reportCategory: intQuery(c, 'reportCategory'),
|
||||||
|
banned: boolQuery(c, 'banned'),
|
||||||
|
from: dateQuery(c, 'from'),
|
||||||
|
to: dateQuery(c, 'to'),
|
||||||
|
}
|
||||||
|
const page = await searchReports(c.env.DB, filters, {
|
||||||
|
skip: intQuery(c, 'skip') ?? 0,
|
||||||
|
take: Math.min(intQuery(c, 'take') ?? 50, MAX_TAKE),
|
||||||
|
})
|
||||||
|
return c.json(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One report by id — the detail behind a search row. */
|
||||||
|
export async function getReportHandler(c: Context<App>) {
|
||||||
|
const id = Number.parseInt(c.req.param('id') ?? '', 10)
|
||||||
|
if (Number.isNaN(id)) return c.json({ error: 'A numeric report id is required' }, 400)
|
||||||
|
const report = await getReportById(c.env.DB, id)
|
||||||
|
if (report === null) return c.json({ error: 'No such report' }, 404)
|
||||||
|
return c.json(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File a minimal report as the acting moderator — the row a ban needs something to hang
|
||||||
|
* on when there is no player report to act on.
|
||||||
|
*
|
||||||
|
* A ban lives ON a report (see reports-db), so moderating something nobody happened to
|
||||||
|
* report — found in a log, seen first-hand, escalated from elsewhere — needs a row first.
|
||||||
|
* The reporter is the moderator, from the token, never the body: that IS the record of who
|
||||||
|
* raised it, and it is why nothing marks these rows as staff-created.
|
||||||
|
*
|
||||||
|
* Deliberately minimal. The heights the game measures, the instance type and the
|
||||||
|
* event/invention/item ids all describe a client-side moment that did not happen here, so
|
||||||
|
* they are not accepted — a hand-written report carries only who, what kind, why, and
|
||||||
|
* optionally where.
|
||||||
|
*/
|
||||||
|
export async function createReportHandler(c: Context<App>) {
|
||||||
|
const moderatorId = staffId(c)
|
||||||
|
const body = (await c.req.json().catch(() => null)) as {
|
||||||
|
reportedPlayerId?: unknown
|
||||||
|
reportCategory?: unknown
|
||||||
|
details?: unknown
|
||||||
|
roomId?: unknown
|
||||||
|
} | null
|
||||||
|
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||||||
|
|
||||||
|
const reportedPlayerId = Number(body.reportedPlayerId)
|
||||||
|
if (!Number.isInteger(reportedPlayerId) || reportedPlayerId <= 0) {
|
||||||
|
return c.json({ error: 'reportedPlayerId is required' }, 400)
|
||||||
|
}
|
||||||
|
// A moderator filing a report against themselves is a mistake every time, and the ban
|
||||||
|
// it would justify locks the panel's own operator out of the game.
|
||||||
|
if (reportedPlayerId === moderatorId) {
|
||||||
|
return c.json({ error: 'You cannot file a report against yourself' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = Number(body.roomId ?? 0)
|
||||||
|
const report = await createReport(c.env.DB, {
|
||||||
|
reporterPlayerId: moderatorId,
|
||||||
|
reportedPlayerId,
|
||||||
|
reportCategory: Number.isInteger(Number(body.reportCategory)) ? Number(body.reportCategory) : 0,
|
||||||
|
details: typeof body.details === 'string' && body.details !== '' ? body.details : null,
|
||||||
|
roomId: Number.isInteger(roomId) && roomId > 0 ? roomId : null,
|
||||||
|
})
|
||||||
|
logger.info('a moderator filed a report by hand', {
|
||||||
|
reportId: report.id,
|
||||||
|
moderatorId,
|
||||||
|
reportedPlayerId,
|
||||||
|
})
|
||||||
|
return c.json(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply or lift a ban on the report with this id.
|
||||||
|
*
|
||||||
|
* `banned: false` lifts it, clearing the expiry and the audit columns and leaving the
|
||||||
|
* report itself intact — the panel needs to be able to undo a ban, and a lifted ban has to
|
||||||
|
* be distinguishable from an expired one (`banned = 0` versus a past `ban_expires`).
|
||||||
|
*
|
||||||
|
* Applying one also EJECTS the player from any instance they are in (see
|
||||||
|
* {@link kickBannedPlayer}), best-effort and after the row is committed. The response
|
||||||
|
* reports whether the frame went out, so the panel can say "banned, but they were offline"
|
||||||
|
* rather than implying a kick that never happened.
|
||||||
|
*/
|
||||||
|
export async function banReportHandler(c: Context<App>) {
|
||||||
|
const moderatorId = staffId(c)
|
||||||
|
const id = Number.parseInt(c.req.param('id') ?? '', 10)
|
||||||
|
if (Number.isNaN(id)) return c.json({ error: 'A numeric report id is required' }, 400)
|
||||||
|
|
||||||
|
const body = (await c.req.json().catch(() => ({}))) as {
|
||||||
|
banned?: unknown
|
||||||
|
permanent?: unknown
|
||||||
|
expires?: unknown
|
||||||
|
days?: unknown
|
||||||
|
hours?: unknown
|
||||||
|
}
|
||||||
|
// Absent means ban: the endpoint's purpose is to hand one down, and a lift is the
|
||||||
|
// explicit case.
|
||||||
|
const banned = body.banned !== false
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const expires = banned ? banExpiry(body, now) : null
|
||||||
|
if (expires === undefined) {
|
||||||
|
return c.json({ error: 'That ban duration is not a time in the future' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read first so a ban on a report that doesn't exist is a 404 rather than a silent
|
||||||
|
// no-op, and so the response can name who it reached.
|
||||||
|
const existing = await getReportById(c.env.DB, id)
|
||||||
|
if (existing === null) return c.json({ error: 'No such report' }, 404)
|
||||||
|
|
||||||
|
const report = await banFromReport(c.env.DB, id, {
|
||||||
|
banned,
|
||||||
|
banExpires: expires,
|
||||||
|
bannedBy: moderatorId,
|
||||||
|
})
|
||||||
|
if (report === null) return c.json({ error: 'No such report' }, 404)
|
||||||
|
|
||||||
|
logger.info(banned ? 'a moderator banned a player' : 'a moderator lifted a ban', {
|
||||||
|
reportId: id,
|
||||||
|
moderatorId,
|
||||||
|
bannedPlayerId: report.reported_player_id,
|
||||||
|
banExpires: report.ban_expires,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (banned) await kickBannedPlayer(c, report.reported_player_id, moderatorId)
|
||||||
|
return c.json(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The players with the most reports against them — the panel's triage list.
|
||||||
|
*
|
||||||
|
* Windowed to the last 30 days by default, so the list is who is a problem NOW rather
|
||||||
|
* than whoever has ever accumulated the most reports. `sinceDays=0` reads as all time.
|
||||||
|
*/
|
||||||
|
export async function topReportedHandler(c: Context<App>) {
|
||||||
|
const sinceDays = intQuery(c, 'sinceDays')
|
||||||
|
const players = await getTopReported(c.env.DB, {
|
||||||
|
// 0 is the panel's "all time", which the query expresses as null.
|
||||||
|
sinceDays: sinceDays === 0 ? null : (sinceDays ?? 30),
|
||||||
|
minReports: intQuery(c, 'minReports') ?? 3,
|
||||||
|
take: Math.min(intQuery(c, 'take') ?? 50, MAX_TAKE),
|
||||||
|
})
|
||||||
|
return c.json(players)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every ban in force right now — the standing-bans list. */
|
||||||
|
export async function bansInForceHandler(c: Context<App>) {
|
||||||
|
return c.json(await getBansInForce(c.env.DB))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything on file about one player: every report against them, every warning handed
|
||||||
|
* down, and the ban in force if there is one.
|
||||||
|
*
|
||||||
|
* One call because it is one screen — a moderator deciding what to do about an account
|
||||||
|
* reads all three together, and three round trips would let the page render a decision
|
||||||
|
* out of a partially-loaded history.
|
||||||
|
*/
|
||||||
|
export async function playerHistoryHandler(c: Context<App>) {
|
||||||
|
const playerId = Number.parseInt(c.req.param('id') ?? '', 10)
|
||||||
|
if (Number.isNaN(playerId)) return c.json({ error: 'A numeric player id is required' }, 400)
|
||||||
|
|
||||||
|
const [reports, warnings, activeBan] = await Promise.all([
|
||||||
|
getReportsAgainst(c.env.DB, playerId),
|
||||||
|
getWarningsAgainst(c.env.DB, playerId),
|
||||||
|
getActiveBan(c.env.DB, playerId),
|
||||||
|
])
|
||||||
|
return c.json({ playerId, reports, warnings, activeBan })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which other accounts a ban on this player would ALSO block — shown before a moderator
|
||||||
|
* confirms one.
|
||||||
|
*
|
||||||
|
* The IP arm is coarse by design (see bans-db): households, NAT and campus networks put
|
||||||
|
* unrelated players behind one address, so a ban can reach people who did nothing. That is
|
||||||
|
* the operator's trade to make, but it should be made with the list in front of them rather
|
||||||
|
* than discovered from a support ticket, which is the whole reason this endpoint exists.
|
||||||
|
*
|
||||||
|
* `arms` echoes the operator's `BAN_EVASION_MATCH` so the page can say which arms are live
|
||||||
|
* — an empty list under `off` means the ban reaches exactly the one account.
|
||||||
|
*/
|
||||||
|
export async function linkedAccountsHandler(c: Context<App>) {
|
||||||
|
const playerId = Number.parseInt(c.req.param('id') ?? '', 10)
|
||||||
|
if (Number.isNaN(playerId)) return c.json({ error: 'A numeric player id is required' }, 400)
|
||||||
|
|
||||||
|
const arms = armsFor(c.env)
|
||||||
|
return c.json({ playerId, arms, linked: await linkedAccounts(c.env.DB, playerId, arms) })
|
||||||
|
}
|
||||||
@@ -3,9 +3,20 @@ import { beforeAll, expect, it } from 'vitest'
|
|||||||
|
|
||||||
import { SCHEMA_DDL as ACCOUNT_SCHEMA_DDL, updateAccount } from '@repo/domain/src/accounts-db'
|
import { SCHEMA_DDL as ACCOUNT_SCHEMA_DDL, updateAccount } from '@repo/domain/src/accounts-db'
|
||||||
import { PlatformType } from '@repo/domain/src/enums'
|
import { PlatformType } from '@repo/domain/src/enums'
|
||||||
import { PRESENCE_SCHEMA_DDL, PRESENCE_TTL_SECONDS } from '@repo/domain/src/presence-db'
|
import {
|
||||||
|
PRESENCE_SCHEMA_DDL,
|
||||||
|
PRESENCE_TTL_SECONDS,
|
||||||
|
setPresence,
|
||||||
|
} from '@repo/domain/src/presence-db'
|
||||||
|
import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain/src/room-instance-db'
|
||||||
import { generateToken } from '@repo/jwt'
|
import { generateToken } from '@repo/jwt'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createReport,
|
||||||
|
getReportById,
|
||||||
|
SCHEMA_DDL as REPORT_SCHEMA_DDL,
|
||||||
|
} from '../../../../api/src/reports-db'
|
||||||
|
import { createWarning, SCHEMA_DDL as WARNING_SCHEMA_DDL } from '../../../../api/src/warnings-db'
|
||||||
import {
|
import {
|
||||||
CACHED_LOGIN_PLATFORMS,
|
CACHED_LOGIN_PLATFORMS,
|
||||||
countAccountsForPlatformIdentity,
|
countAccountsForPlatformIdentity,
|
||||||
@@ -38,8 +49,15 @@ const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA'
|
|||||||
const TEST_JWT_SECRET = 'test-jwt-secret'
|
const TEST_JWT_SECRET = 'test-jwt-secret'
|
||||||
|
|
||||||
/** A bearer token for `accountId`, signed the way `auth` signs one. */
|
/** A bearer token for `accountId`, signed the way `auth` signs one. */
|
||||||
const tokenFor = (accountId: number): Promise<string> =>
|
const tokenFor = (accountId: number, roles: string[] = []): Promise<string> =>
|
||||||
generateToken(String(accountId), '', 4, TEST_JWT_SECRET)
|
generateToken(String(accountId), '', 4, TEST_JWT_SECRET, roles)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A STAFF token — one carrying the `moderator` role, as `auth` stamps it from an
|
||||||
|
* account's isModerator flag. The whole moderation surface is gated on this and nothing
|
||||||
|
* else, so a test that forgets it is testing the 403.
|
||||||
|
*/
|
||||||
|
const staffToken = (accountId: number): Promise<string> => tokenFor(accountId, ['moderator'])
|
||||||
|
|
||||||
// A Discord app that is HALF configured: credentials seeded below, but wrangler.jsonc
|
// A Discord app that is HALF configured: credentials seeded below, but wrangler.jsonc
|
||||||
// leaves DISCORD_GUILD_ID / DISCORD_BENEFITS_ROLE_IDS empty. This is deliberately the most
|
// leaves DISCORD_GUILD_ID / DISCORD_BENEFITS_ROLE_IDS empty. This is deliberately the most
|
||||||
@@ -63,6 +81,12 @@ beforeAll(async () => {
|
|||||||
for (const stmt of ACCOUNT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of ACCOUNT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
// And `platform_account`, where a claimed Discord identity is linked.
|
// And `platform_account`, where a claimed Discord identity is linked.
|
||||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
// `report` and `warning` are owned (and migrated) by `api`; www serves the staff panel
|
||||||
|
// over them, so the tables have to exist here too. `room_instance` comes with them
|
||||||
|
// because a ban ejects the banned player from the instance they're standing in.
|
||||||
|
for (const stmt of REPORT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
for (const stmt of WARNING_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
|
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
|
||||||
@@ -563,3 +587,443 @@ it('serves the privacy policy as real server-rendered HTML', async () => {
|
|||||||
expect(html).toContain(ISSUES_URL)
|
expect(html).toContain(ISSUES_URL)
|
||||||
if (PRIVACY_EMAIL) expect(html).toContain(`mailto:${PRIVACY_EMAIL}`)
|
if (PRIVACY_EMAIL) expect(html).toContain(`mailto:${PRIVACY_EMAIL}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- Staff moderation panel -------------------------------------------------
|
||||||
|
//
|
||||||
|
// The `/api/staff/*` endpoints behind the `/moderation` page (see src/staff.ts). These
|
||||||
|
// are recflare's own surface — no Rec Room client calls them — which is why they live on
|
||||||
|
// `www` rather than on the workers that reimplement the game's API.
|
||||||
|
//
|
||||||
|
// The gate is the whole security model here: every route reads and writes moderation
|
||||||
|
// state for any account, so the tests below pin BOTH refusals (no token, and a valid
|
||||||
|
// token without a staff role) as carefully as they pin the happy paths.
|
||||||
|
|
||||||
|
/** GET a staff endpoint as `accountId`, with or without the staff role. */
|
||||||
|
async function staffGet(path: string, accountId: number, roles: string[] = ['moderator']) {
|
||||||
|
return SELF.fetch(`https://example.com${path}`, {
|
||||||
|
headers: { authorization: `Bearer ${await tokenFor(accountId, roles)}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST a JSON body to a staff endpoint as a moderator. */
|
||||||
|
async function staffPost(path: string, accountId: number, body: unknown) {
|
||||||
|
return SELF.fetch(`https://example.com${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${await staffToken(accountId)}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two refusals, not one. A 401 is an expired session — the SPA drops the token and sends
|
||||||
|
// the player to sign in — while a 403 is a signed-in player who simply isn't staff, and
|
||||||
|
// looping THEM through a sign-in would change nothing. Every route is checked, because
|
||||||
|
// the gate is middleware and a route registered outside it would be wide open.
|
||||||
|
it('refuses every staff endpoint without a token, and without a staff role', async () => {
|
||||||
|
const paths = [
|
||||||
|
'/api/staff/reports',
|
||||||
|
'/api/staff/reports/top-reported',
|
||||||
|
'/api/staff/reports/1',
|
||||||
|
'/api/staff/bans',
|
||||||
|
'/api/staff/players/1',
|
||||||
|
'/api/staff/players/1/linked',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
expect((await SELF.fetch(`https://example.com${path}`)).status).toBe(401)
|
||||||
|
// A valid token whose `role` claim is a plain player's.
|
||||||
|
expect((await staffGet(path, 8101, ['gameClient'])).status).toBe(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The writes too, on the same terms.
|
||||||
|
const unauthed = await SELF.fetch('https://example.com/api/staff/reports', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reportedPlayerId: 8102 }),
|
||||||
|
})
|
||||||
|
expect(unauthed.status).toBe(401)
|
||||||
|
|
||||||
|
const player = await SELF.fetch('https://example.com/api/staff/reports/1/ban', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${await tokenFor(8101, ['gameClient'])}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ banned: true, days: 1 }),
|
||||||
|
})
|
||||||
|
expect(player.status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The reporter is the CALLER, never a body field — that is the record of who raised a
|
||||||
|
// hand-written report, and it is why nothing marks these rows as staff-created.
|
||||||
|
it('files a minimal report as the acting moderator', async () => {
|
||||||
|
const res = await staffPost('/api/staff/reports', 8110, {
|
||||||
|
reportedPlayerId: 8111,
|
||||||
|
reportCategory: 102,
|
||||||
|
details: 'Seen in a log',
|
||||||
|
roomId: 991,
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const report = (await res.json()) as Record<string, unknown>
|
||||||
|
expect(report.reporter_player_id).toBe(8110)
|
||||||
|
expect(report.reported_player_id).toBe(8111)
|
||||||
|
expect(report.report_category).toBe(102)
|
||||||
|
expect(report.details).toBe('Seen in a log')
|
||||||
|
expect(report.room_id).toBe(991)
|
||||||
|
// Filed unbanned: a report is not a ban, and the ban is a separate decision.
|
||||||
|
expect(report.banned).toBe(0)
|
||||||
|
expect(report.banned_at).toBeNull()
|
||||||
|
expect(report.banned_by_player_id).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a report with no player, and one a moderator files against themselves', async () => {
|
||||||
|
expect((await staffPost('/api/staff/reports', 8110, {})).status).toBe(400)
|
||||||
|
// The self-report is a mistake every time, and the ban it would justify locks the
|
||||||
|
// panel's own operator out of the game.
|
||||||
|
expect((await staffPost('/api/staff/reports', 8110, { reportedPlayerId: 8110 })).status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The search is the panel's way into the log, so each filter is pinned separately: a
|
||||||
|
// filter that quietly doesn't apply looks like "no reports of that kind" rather than an
|
||||||
|
// error, which is the failure a moderator would act on.
|
||||||
|
it('searches reports by player, by reporter and by ban state', async () => {
|
||||||
|
const target = 8120
|
||||||
|
await createReport(env.DB, {
|
||||||
|
reporterPlayerId: 8121,
|
||||||
|
reportedPlayerId: target,
|
||||||
|
reportCategory: 101,
|
||||||
|
details: 'first',
|
||||||
|
})
|
||||||
|
await createReport(env.DB, {
|
||||||
|
reporterPlayerId: 8122,
|
||||||
|
reportedPlayerId: target,
|
||||||
|
reportCategory: 102,
|
||||||
|
details: 'second',
|
||||||
|
})
|
||||||
|
await createReport(env.DB, { reporterPlayerId: 8121, reportedPlayerId: 8123, details: 'other' })
|
||||||
|
|
||||||
|
const byPlayer = (await (
|
||||||
|
await staffGet(`/api/staff/reports?reportedPlayerId=${target}`, 8110)
|
||||||
|
).json()) as { reports: Array<{ details: string }>; total: number }
|
||||||
|
// Newest first, and only the two against this player.
|
||||||
|
expect(byPlayer.total).toBe(2)
|
||||||
|
expect(byPlayer.reports.map((r) => r.details)).toEqual(['second', 'first'])
|
||||||
|
|
||||||
|
const byReporter = (await (
|
||||||
|
await staffGet('/api/staff/reports?reporterPlayerId=8122', 8110)
|
||||||
|
).json()) as { total: number }
|
||||||
|
expect(byReporter.total).toBe(1)
|
||||||
|
|
||||||
|
const byCategory = (await (
|
||||||
|
await staffGet(`/api/staff/reports?reportedPlayerId=${target}&reportCategory=101`, 8110)
|
||||||
|
).json()) as { reports: Array<{ details: string }> }
|
||||||
|
expect(byCategory.reports.map((r) => r.details)).toEqual(['first'])
|
||||||
|
|
||||||
|
// An ABSENT `banned` param must not read as `false` — that would hide every actioned
|
||||||
|
// report from an unfiltered search, which is most of what a moderator looks for.
|
||||||
|
const unfiltered = (await (
|
||||||
|
await staffGet(`/api/staff/reports?reportedPlayerId=${target}`, 8110)
|
||||||
|
).json()) as { total: number }
|
||||||
|
expect(unfiltered.total).toBe(2)
|
||||||
|
const unbanned = (await (
|
||||||
|
await staffGet(`/api/staff/reports?reportedPlayerId=${target}&banned=false`, 8110)
|
||||||
|
).json()) as { total: number }
|
||||||
|
expect(unbanned.total).toBe(2)
|
||||||
|
const banned = (await (
|
||||||
|
await staffGet(`/api/staff/reports?reportedPlayerId=${target}&banned=true`, 8110)
|
||||||
|
).json()) as { total: number }
|
||||||
|
expect(banned.total).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// `total` is the unpaged count of the same predicate, so the pager can say "26–50 of 120"
|
||||||
|
// without re-deriving it — and a page smaller than the count is not the end of the list.
|
||||||
|
it('pages the search, reporting the full match count alongside the page', async () => {
|
||||||
|
const target = 8130
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await createReport(env.DB, {
|
||||||
|
reporterPlayerId: 8131,
|
||||||
|
reportedPlayerId: target,
|
||||||
|
details: `r${i}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = (await (
|
||||||
|
await staffGet(`/api/staff/reports?reportedPlayerId=${target}&take=2`, 8110)
|
||||||
|
).json()) as { reports: Array<{ details: string }>; total: number }
|
||||||
|
expect(first.total).toBe(5)
|
||||||
|
expect(first.reports.map((r) => r.details)).toEqual(['r4', 'r3'])
|
||||||
|
|
||||||
|
const second = (await (
|
||||||
|
await staffGet(`/api/staff/reports?reportedPlayerId=${target}&take=2&skip=2`, 8110)
|
||||||
|
).json()) as { reports: Array<{ details: string }>; total: number }
|
||||||
|
expect(second.total).toBe(5)
|
||||||
|
expect(second.reports.map((r) => r.details)).toEqual(['r2', 'r1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ranked by DISTINCT reporters ahead of raw count. One player filing twenty reports
|
||||||
|
// against someone they're feuding with is a different thing from twenty players filing
|
||||||
|
// one each, and a queue that can't tell them apart puts the feud at the top.
|
||||||
|
it('ranks the report queue by distinct reporters, not by raw count', async () => {
|
||||||
|
const feud = 8140
|
||||||
|
const real = 8141
|
||||||
|
// Four reports, all from the same person.
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
await createReport(env.DB, { reporterPlayerId: 8142, reportedPlayerId: feud })
|
||||||
|
}
|
||||||
|
// Three reports, three different people.
|
||||||
|
for (const reporter of [8143, 8144, 8145]) {
|
||||||
|
await createReport(env.DB, { reporterPlayerId: reporter, reportedPlayerId: real })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (await (
|
||||||
|
await staffGet('/api/staff/reports/top-reported?minReports=3', 8110)
|
||||||
|
).json()) as Array<{ playerId: number; reports: number; distinctReporters: number }>
|
||||||
|
|
||||||
|
const feudRow = rows.find((r) => r.playerId === feud)
|
||||||
|
const realRow = rows.find((r) => r.playerId === real)
|
||||||
|
expect(feudRow).toMatchObject({ reports: 4, distinctReporters: 1 })
|
||||||
|
expect(realRow).toMatchObject({ reports: 3, distinctReporters: 3 })
|
||||||
|
// Fewer reports, more reporters — and so ahead of the feud in the list.
|
||||||
|
expect(rows.indexOf(realRow!)).toBeLessThan(rows.indexOf(feudRow!))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the long tail of single reports out of the queue', async () => {
|
||||||
|
await createReport(env.DB, { reporterPlayerId: 8151, reportedPlayerId: 8150 })
|
||||||
|
const rows = (await (
|
||||||
|
await staffGet('/api/staff/reports/top-reported?minReports=3', 8110)
|
||||||
|
).json()) as Array<{ playerId: number }>
|
||||||
|
expect(rows.map((r) => r.playerId)).not.toContain(8150)
|
||||||
|
|
||||||
|
// …but it is a threshold, not a rule: a moderator can lower it.
|
||||||
|
const all = (await (
|
||||||
|
await staffGet('/api/staff/reports/top-reported?minReports=1', 8110)
|
||||||
|
).json()) as Array<{ playerId: number }>
|
||||||
|
expect(all.map((r) => r.playerId)).toContain(8150)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The ban: a duration in, an expiry stored, and the audit trail that 0020 added. The
|
||||||
|
// duration matters because `banned_at` is what the client's block screen counts the ban
|
||||||
|
// from — see `banBlockDetails`.
|
||||||
|
it('bans from a report, recording who did it and when', async () => {
|
||||||
|
const report = await createReport(env.DB, {
|
||||||
|
reporterPlayerId: 8161,
|
||||||
|
reportedPlayerId: 8160,
|
||||||
|
reportCategory: 102,
|
||||||
|
})
|
||||||
|
const before = Date.now()
|
||||||
|
|
||||||
|
const res = await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, {
|
||||||
|
banned: true,
|
||||||
|
days: 7,
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const banned = (await res.json()) as Record<string, unknown>
|
||||||
|
|
||||||
|
expect(banned.banned).toBe(1)
|
||||||
|
expect(banned.banned_by_player_id).toBe(8110)
|
||||||
|
expect(Date.parse(banned.banned_at as string)).toBeGreaterThanOrEqual(before)
|
||||||
|
// Seven days out, give or take the test's own runtime.
|
||||||
|
const expires = Date.parse(banned.ban_expires as string)
|
||||||
|
expect(expires - Date.parse(banned.banned_at as string)).toBeCloseTo(7 * 86_400_000, -4)
|
||||||
|
|
||||||
|
// And it shows up as a ban in force, which is a different question from `banned = 1`.
|
||||||
|
const bans = (await (await staffGet('/api/staff/bans', 8110)).json()) as Array<{ id: number }>
|
||||||
|
expect(bans.map((b) => b.id)).toContain(report.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bans permanently when asked, and refuses a duration in the past', async () => {
|
||||||
|
const report = await createReport(env.DB, { reporterPlayerId: 8171, reportedPlayerId: 8170 })
|
||||||
|
|
||||||
|
const permanent = await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, {
|
||||||
|
banned: true,
|
||||||
|
permanent: true,
|
||||||
|
})
|
||||||
|
expect(permanent.status).toBe(200)
|
||||||
|
// NULL is what records "never lifts" — not a far-future date.
|
||||||
|
expect(((await permanent.json()) as Record<string, unknown>).ban_expires).toBeNull()
|
||||||
|
|
||||||
|
// An expiry that was ASKED for but can't be honoured is refused rather than quietly
|
||||||
|
// becoming a permanent ban — the one mistake here that waiting cannot undo.
|
||||||
|
const past = await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, {
|
||||||
|
banned: true,
|
||||||
|
expires: '2001-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
expect(past.status).toBe(400)
|
||||||
|
const nonsense = await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, {
|
||||||
|
banned: true,
|
||||||
|
expires: 'next tuesday',
|
||||||
|
})
|
||||||
|
expect(nonsense.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A lift has to leave the row distinguishable from an EXPIRED ban (banned = 0 versus a
|
||||||
|
// past ban_expires), and must not leave an audit trail saying a ban runs from somewhere.
|
||||||
|
it('lifts a ban, clearing the expiry and the audit columns but keeping the report', async () => {
|
||||||
|
const report = await createReport(env.DB, {
|
||||||
|
reporterPlayerId: 8181,
|
||||||
|
reportedPlayerId: 8180,
|
||||||
|
details: 'still on file',
|
||||||
|
})
|
||||||
|
expect((await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, { days: 1 })).status).toBe(
|
||||||
|
200
|
||||||
|
)
|
||||||
|
|
||||||
|
const lifted = await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, { banned: false })
|
||||||
|
expect(lifted.status).toBe(200)
|
||||||
|
const row = (await lifted.json()) as Record<string, unknown>
|
||||||
|
expect(row.banned).toBe(0)
|
||||||
|
expect(row.ban_expires).toBeNull()
|
||||||
|
expect(row.banned_at).toBeNull()
|
||||||
|
expect(row.banned_by_player_id).toBeNull()
|
||||||
|
// The report itself survives — it is the record of what was reported, not of the ban.
|
||||||
|
expect(row.details).toBe('still on file')
|
||||||
|
|
||||||
|
const bans = (await (await staffGet('/api/staff/bans', 8110)).json()) as Array<{ id: number }>
|
||||||
|
expect(bans.map((b) => b.id)).not.toContain(report.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers a ban on a report that does not exist with a 404', async () => {
|
||||||
|
expect((await staffPost('/api/staff/reports/99999/ban', 8110, { days: 1 })).status).toBe(404)
|
||||||
|
expect((await staffGet('/api/staff/reports/99999', 8110)).status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Without this a ban only bites on the player's NEXT matchmake: `match` refuses a banned
|
||||||
|
// player, but nothing revisits a session already in progress, so someone banned
|
||||||
|
// mid-session keeps playing until they leave on their own.
|
||||||
|
it('throws a banned player out of the instance they are standing in', async () => {
|
||||||
|
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||||
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
|
|
||||||
|
await setPresence(env.DB, {
|
||||||
|
accountId: 8190,
|
||||||
|
roomInstance: { roomInstanceId: 77001 },
|
||||||
|
statusVisibility: 0,
|
||||||
|
deviceClass: 0,
|
||||||
|
vrMovementMode: 0,
|
||||||
|
platform: 4,
|
||||||
|
appVersion: 'test',
|
||||||
|
})
|
||||||
|
const report = await createReport(env.DB, { reporterPlayerId: 8191, reportedPlayerId: 8190 })
|
||||||
|
expect((await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, { days: 3 })).status).toBe(
|
||||||
|
200
|
||||||
|
)
|
||||||
|
|
||||||
|
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
|
||||||
|
playerIds?: number[]
|
||||||
|
ephemeral?: boolean
|
||||||
|
notificationType: number | string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
}>
|
||||||
|
expect(frames).toHaveLength(1)
|
||||||
|
expect(frames[0].playerIds).toEqual([8190])
|
||||||
|
// EPHEMERAL: a kick is true of the moment it happened. Queued and delivered on a later
|
||||||
|
// connect it would eject them from an unrelated session.
|
||||||
|
expect(frames[0].ephemeral).toBe(true)
|
||||||
|
// `IsBan` is what makes the client's screen name this a ban rather than a kick.
|
||||||
|
expect(frames[0].data.IsBan).toBe(true)
|
||||||
|
expect(frames[0].data.GameSessionId).toBe(77001)
|
||||||
|
|
||||||
|
// And their presence row is gone, so they read offline at once and the instance frees
|
||||||
|
// a slot.
|
||||||
|
const presence = await env.DB.prepare(
|
||||||
|
"SELECT COUNT(*) AS n FROM presence WHERE json_extract(data, '$.accountId') = 8190"
|
||||||
|
).first<{ n: number }>()
|
||||||
|
expect(presence?.n).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The ban row is committed before the kick is attempted, so a player who is offline (or
|
||||||
|
// a hub that can't be reached) must not fail a ban that has been handed down.
|
||||||
|
it('bans an offline player without a kick to push', async () => {
|
||||||
|
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||||
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
|
|
||||||
|
const report = await createReport(env.DB, { reporterPlayerId: 8201, reportedPlayerId: 8200 })
|
||||||
|
expect((await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, { days: 1 })).status).toBe(
|
||||||
|
200
|
||||||
|
)
|
||||||
|
await expect(getReportById(env.DB, report.id)).resolves.toMatchObject({ banned: 1 })
|
||||||
|
|
||||||
|
const frames = (await (await hub().fetch('http://do/all')).json()) as unknown[]
|
||||||
|
expect(frames).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// One screen, one call: a moderator deciding what to do about an account reads the
|
||||||
|
// reports, the warnings and the ban together, and three round trips would let the page
|
||||||
|
// render a decision out of a half-loaded history.
|
||||||
|
it('serves a player’s reports, warnings and active ban together', async () => {
|
||||||
|
const player = 8210
|
||||||
|
await createReport(env.DB, {
|
||||||
|
reporterPlayerId: 8211,
|
||||||
|
reportedPlayerId: player,
|
||||||
|
details: 'reported',
|
||||||
|
})
|
||||||
|
const actioned = await createReport(env.DB, {
|
||||||
|
reporterPlayerId: 8212,
|
||||||
|
reportedPlayerId: player,
|
||||||
|
reportCategory: 101,
|
||||||
|
})
|
||||||
|
await createWarning(env.DB, {
|
||||||
|
moderatorPlayerId: 8110,
|
||||||
|
warnedPlayerId: player,
|
||||||
|
reportCategory: 101,
|
||||||
|
displayReason: 'Told once',
|
||||||
|
moderatorNote: 'internal',
|
||||||
|
})
|
||||||
|
expect((await staffPost(`/api/staff/reports/${actioned.id}/ban`, 8110, { days: 2 })).status).toBe(
|
||||||
|
200
|
||||||
|
)
|
||||||
|
|
||||||
|
const history = (await (await staffGet(`/api/staff/players/${player}`, 8110)).json()) as {
|
||||||
|
playerId: number
|
||||||
|
reports: Array<{ id: number }>
|
||||||
|
warnings: Array<{ display_reason: string; moderator_note: string }>
|
||||||
|
activeBan: { id: number } | null
|
||||||
|
}
|
||||||
|
expect(history.playerId).toBe(player)
|
||||||
|
expect(history.reports).toHaveLength(2)
|
||||||
|
// The ban in force is the one the panel headlines, and it names the report behind it.
|
||||||
|
expect(history.activeBan?.id).toBe(actioned.id)
|
||||||
|
expect(history.warnings).toHaveLength(1)
|
||||||
|
// Both halves of a warning are here: the panel is the internal view, so the note a
|
||||||
|
// player never sees is shown to staff.
|
||||||
|
expect(history.warnings[0]).toMatchObject({
|
||||||
|
display_reason: 'Told once',
|
||||||
|
moderator_note: 'internal',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// The evasion preview. The IP arm is coarse by design — households and shared networks
|
||||||
|
// look identical to it — so the panel shows the blast radius BEFORE a ban lands, and
|
||||||
|
// echoes which arms the operator has enabled so an empty list can be read correctly.
|
||||||
|
it('previews which other accounts a ban would reach', async () => {
|
||||||
|
// `account_id` is GENERATED from the blob, so only `data` is written — the same way
|
||||||
|
// `auth` writes an account row.
|
||||||
|
const seedAccount = (data: Record<string, unknown>) =>
|
||||||
|
env.DB.prepare('INSERT OR REPLACE INTO account (data) VALUES (?1)')
|
||||||
|
.bind(JSON.stringify(data))
|
||||||
|
.run()
|
||||||
|
await seedAccount({ accountId: 8220, username: 'Evader', lastLoginIp: '203.0.113.9' })
|
||||||
|
await seedAccount({ accountId: 8221, username: 'Housemate', signupIp: '203.0.113.9' })
|
||||||
|
await linkPlatformIdentity(env.DB, 8220, PlatformType.Steam, 'steam-8220')
|
||||||
|
await linkPlatformIdentity(env.DB, 8222, PlatformType.Steam, 'steam-8220')
|
||||||
|
|
||||||
|
const preview = (await (await staffGet('/api/staff/players/8220/linked', 8110)).json()) as {
|
||||||
|
arms: { ip: boolean; platform: boolean }
|
||||||
|
linked: Array<{ accountId: number; username: string | null; via: string }>
|
||||||
|
}
|
||||||
|
// BAN_EVASION_MATCH is unset in the test config, so both arms are live — the default
|
||||||
|
// an operator who sets nothing gets.
|
||||||
|
expect(preview.arms).toEqual({ ip: true, platform: true })
|
||||||
|
|
||||||
|
// The platform match is the sharp one (a proven identity) and sorts first; the IP one
|
||||||
|
// is the coarse arm that would catch the housemate.
|
||||||
|
expect(preview.linked.find((l) => l.accountId === 8222)?.via).toBe('platform')
|
||||||
|
expect(preview.linked.find((l) => l.accountId === 8221)).toMatchObject({
|
||||||
|
via: 'ip',
|
||||||
|
username: 'Housemate',
|
||||||
|
})
|
||||||
|
// Never the account asked about: the question is who ELSE.
|
||||||
|
expect(preview.linked.map((l) => l.accountId)).not.toContain(8220)
|
||||||
|
})
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ import {
|
|||||||
} from './discord'
|
} from './discord'
|
||||||
import { docsPage, fetchSpec } from './docs'
|
import { docsPage, fetchSpec } from './docs'
|
||||||
import { privacyPage } from './privacy'
|
import { privacyPage } from './privacy'
|
||||||
|
import {
|
||||||
|
banReportHandler,
|
||||||
|
bansInForceHandler,
|
||||||
|
createReportHandler,
|
||||||
|
getReportHandler,
|
||||||
|
linkedAccountsHandler,
|
||||||
|
playerHistoryHandler,
|
||||||
|
requireStaff,
|
||||||
|
searchReportsHandler,
|
||||||
|
topReportedHandler,
|
||||||
|
} from './staff'
|
||||||
import { turnstileKeys, verifyTurnstile } from './turnstile'
|
import { turnstileKeys, verifyTurnstile } from './turnstile'
|
||||||
import {
|
import {
|
||||||
accountsBase,
|
accountsBase,
|
||||||
@@ -374,6 +385,30 @@ const app = new Hono<App>()
|
|||||||
return c.json({ hasPlus: true, discordUsername: membership.username })
|
return c.json({ hasPlus: true, discordUsername: membership.username })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- Staff moderation panel ---------------------------------------------
|
||||||
|
// The endpoints behind `/moderation` in the SPA. They live here rather than on `api`
|
||||||
|
// (which owns the `report` table) because they are a recflare addition with no
|
||||||
|
// counterpart in the real service, and the game-facing workers only reimplement what
|
||||||
|
// the client actually calls — see src/staff.ts. The SQL stays with the table.
|
||||||
|
//
|
||||||
|
// Ordered before the ASSETS catch-all like everything else, and covered by the
|
||||||
|
// existing `/api/*` entry in wrangler.jsonc's `run_worker_first`, so no routing change
|
||||||
|
// was needed. `/moderation` itself is deliberately NOT in that allowlist: it is a
|
||||||
|
// client-side route, so it must fall through to the SPA shell.
|
||||||
|
.use('/api/staff/*', requireStaff)
|
||||||
|
|
||||||
|
// Registered before `/api/staff/reports/:id` so the literal path isn't captured as an
|
||||||
|
// id — `top-reported` is not a number, but the param route would still match it and
|
||||||
|
// answer a 400 instead of the list.
|
||||||
|
.get('/api/staff/reports/top-reported', topReportedHandler)
|
||||||
|
.get('/api/staff/reports', searchReportsHandler)
|
||||||
|
.post('/api/staff/reports', createReportHandler)
|
||||||
|
.get('/api/staff/reports/:id', getReportHandler)
|
||||||
|
.post('/api/staff/reports/:id/ban', banReportHandler)
|
||||||
|
.get('/api/staff/bans', bansInForceHandler)
|
||||||
|
.get('/api/staff/players/:id', playerHistoryHandler)
|
||||||
|
.get('/api/staff/players/:id/linked', linkedAccountsHandler)
|
||||||
|
|
||||||
// ---- Privacy policy -----------------------------------------------------
|
// ---- Privacy policy -----------------------------------------------------
|
||||||
// Server-rendered rather than a SPA route so the page has real text without
|
// Server-rendered rather than a SPA route so the page has real text without
|
||||||
// JavaScript: the Meta Horizon Store re-fetches this URL to check the policy is
|
// JavaScript: the Meta Horizon Store re-fetches this URL to check the policy is
|
||||||
|
|||||||
@@ -29,6 +29,49 @@ export default defineConfig({
|
|||||||
// src/test/integration/api.test.ts). A plain binding of the same name would
|
// src/test/integration/api.test.ts). A plain binding of the same name would
|
||||||
// shadow the store binding with a string.
|
// shadow the store binding with a string.
|
||||||
},
|
},
|
||||||
|
// The worker's RECFLARE_NOTIFICATIONS_HUB binding points at the `notify`
|
||||||
|
// worker's DO (script_name: "notify"), which isn't part of this isolated test —
|
||||||
|
// without an override the runtime refuses to start, exactly as it does for the
|
||||||
|
// AUTH service binding above. The same stub the `api` worker's tests use: it
|
||||||
|
// records each send so a test can assert the ModerationKick frame a ban pushed,
|
||||||
|
// and answers a fetch with it (DELETE resets).
|
||||||
|
workers: [
|
||||||
|
{
|
||||||
|
name: 'notify',
|
||||||
|
modules: true,
|
||||||
|
compatibilityDate: '2026-06-16',
|
||||||
|
compatibilityFlags: ['nodejs_compat'],
|
||||||
|
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
|
||||||
|
script: `
|
||||||
|
import { DurableObject } from 'cloudflare:workers'
|
||||||
|
export class NotificationsHub extends DurableObject {
|
||||||
|
sent = []
|
||||||
|
async notifyPlayer(playerId, notificationType, data) {
|
||||||
|
this.sent.push({ playerId, notificationType, data })
|
||||||
|
return { delivered: 0, queued: true }
|
||||||
|
}
|
||||||
|
async notifyPlayerEphemeral(playerId, notificationType, data) {
|
||||||
|
this.sent.push({ playerId, ephemeral: true, notificationType, data })
|
||||||
|
return { delivered: 0 }
|
||||||
|
}
|
||||||
|
async notifyPlayersEphemeral(playerIds, notificationType, data) {
|
||||||
|
this.sent.push({ playerIds, ephemeral: true, notificationType, data })
|
||||||
|
return { delivered: 0 }
|
||||||
|
}
|
||||||
|
async broadcast() { return { delivered: 0 } }
|
||||||
|
async fetch(request) {
|
||||||
|
if (request.method === 'DELETE') {
|
||||||
|
this.sent = []
|
||||||
|
return new Response(null, { status: 204 })
|
||||||
|
}
|
||||||
|
if (new URL(request.url).pathname === '/all') return Response.json(this.sent)
|
||||||
|
return Response.json(this.sent.at(-1) ?? null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default { fetch() { return new Response('ok') } }
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
+21
-5
@@ -5,11 +5,13 @@
|
|||||||
"compatibility_date": "2026-06-16",
|
"compatibility_date": "2026-06-16",
|
||||||
"compatibility_flags": ["nodejs_compat"],
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
"routes": [],
|
"routes": [],
|
||||||
// The shared `recflare` D1 — read-only here, and only for the public
|
// The shared `recflare` D1. Read for the public `/server-status` head-count and the
|
||||||
// `/server-status` head-count (see src/www.app.ts). The schema is owned by the
|
// benefits claim; WRITTEN by the claim (`hasPlus`) and by the staff moderation panel,
|
||||||
// workers that write it (presence lives in apps/rooms/migrations), so no
|
// which files reports and hands down bans on the `report` table (see src/staff.ts).
|
||||||
// migrations_dir: www never migrates. The "local" placeholder is spliced with
|
// The schema is owned by the workers that write it (presence lives in
|
||||||
// RECFLARE_D1 at deploy time, as it is for every other worker.
|
// apps/rooms/migrations, `report` in apps/api/migrations), so no migrations_dir: www
|
||||||
|
// never migrates. The "local" placeholder is spliced with RECFLARE_D1 at deploy time,
|
||||||
|
// as it is for every other worker.
|
||||||
"d1_databases": [
|
"d1_databases": [
|
||||||
{
|
{
|
||||||
"binding": "DB",
|
"binding": "DB",
|
||||||
@@ -113,6 +115,20 @@
|
|||||||
// auth/accounts/api/notify (as rec.net's own site did), where the edge sets the real
|
// auth/accounts/api/notify (as rec.net's own site did), where the edge sets the real
|
||||||
// client IP for free.
|
// client IP for free.
|
||||||
"services": [{ "binding": "AUTH", "service": "auth" }],
|
"services": [{ "binding": "AUTH", "service": "auth" }],
|
||||||
|
// The notifications hub, owned by the `notify` worker. Bound for ONE action: a ban
|
||||||
|
// handed down from the staff panel ejects the player from the instance they are
|
||||||
|
// standing in, which needs a `ModerationKick` frame pushed to their client (see
|
||||||
|
// src/staff.ts). Without it a ban would not bite until their next matchmake, so
|
||||||
|
// someone banned mid-session keeps playing.
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "RECFLARE_NOTIFICATIONS_HUB",
|
||||||
|
"class_name": "NotificationsHub",
|
||||||
|
"script_name": "notify"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"upload_source_maps": true,
|
"upload_source_maps": true,
|
||||||
"observability": {
|
"observability": {
|
||||||
"logs": {
|
"logs": {
|
||||||
|
|||||||
Reference in New Issue
Block a user