mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 16:08:58 -07:00
[www] extend audit log to ban/unban
This commit is contained in:
+51
-1
@@ -1,4 +1,4 @@
|
|||||||
import { getPresences, movePlayerToDorm } from '@repo/domain'
|
import { getPresences, movePlayerToDorm, writeAuditLog } from '@repo/domain'
|
||||||
import { logger } from '@repo/hono-helpers'
|
import { logger } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||||
|
|
||||||
@@ -224,6 +224,54 @@ async function kickBannedPlayer(c: Context<App>, report: ReportRow, moderatorId:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a ban or a lift on the `audit_log` table, against the moderator who made it.
|
||||||
|
*
|
||||||
|
* The report row only ever holds the ban's CURRENT state: a lift clears `banned_by` and
|
||||||
|
* `banned_at`, and a re-ban overwrites them, so without this row nothing says who lifted a
|
||||||
|
* ban, or that one was ever handed down on a report that is clean today. `ban` and `unban`
|
||||||
|
* are separate actions because they are the two questions asked of this log ("who banned
|
||||||
|
* X", "who let X back in"); the target and the report go in `data`, per the table's rule
|
||||||
|
* that `player_id` is the actor. `previous` is the row's ban state before this call, which
|
||||||
|
* is the only place a lifted ban's original moderator and expiry survive.
|
||||||
|
*
|
||||||
|
* Written after the row is committed and never throws — the ban has already happened, and a
|
||||||
|
* failed audit insert must not turn it into a 500 the panel would read as refused.
|
||||||
|
*/
|
||||||
|
async function recordBanAudit(
|
||||||
|
c: Context<App>,
|
||||||
|
moderatorId: number,
|
||||||
|
before: ReportRow,
|
||||||
|
after: ReportRow
|
||||||
|
) {
|
||||||
|
const action = after.banned ? 'ban' : 'unban'
|
||||||
|
try {
|
||||||
|
await writeAuditLog(c.env.DB, {
|
||||||
|
playerId: moderatorId,
|
||||||
|
action,
|
||||||
|
data: {
|
||||||
|
reportId: after.id,
|
||||||
|
playerId: after.reported_player_id,
|
||||||
|
reportCategory: after.report_category,
|
||||||
|
banExpires: after.ban_expires,
|
||||||
|
previous: {
|
||||||
|
banned: before.banned === 1,
|
||||||
|
banExpires: before.ban_expires,
|
||||||
|
bannedBy: before.banned_by_player_id,
|
||||||
|
bannedAt: before.banned_at,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('could not write an audit log row', {
|
||||||
|
action,
|
||||||
|
moderatorId,
|
||||||
|
reportId: after.id,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The evasion arms in force, read from the same knob `api` and `match` read. */
|
/** The evasion arms in force, read from the same knob `api` and `match` read. */
|
||||||
const armsFor = (env: Env) => banEvasionMatch(env.BAN_EVASION_MATCH)
|
const armsFor = (env: Env) => banEvasionMatch(env.BAN_EVASION_MATCH)
|
||||||
|
|
||||||
@@ -363,6 +411,8 @@ export async function banReportHandler(c: Context<App>) {
|
|||||||
banExpires: report.ban_expires,
|
banExpires: report.ban_expires,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await recordBanAudit(c, moderatorId, existing, report)
|
||||||
|
|
||||||
if (banned) await kickBannedPlayer(c, report, moderatorId)
|
if (banned) await kickBannedPlayer(c, report, moderatorId)
|
||||||
return c.json(report)
|
return c.json(report)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
|||||||
import { beforeAll, expect, it } from 'vitest'
|
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 { AUDIT_LOG_SCHEMA_DDL } from '@repo/domain/src/audit-db'
|
||||||
import { PlatformType } from '@repo/domain/src/enums'
|
import { PlatformType } from '@repo/domain/src/enums'
|
||||||
import {
|
import {
|
||||||
PRESENCE_SCHEMA_DDL,
|
PRESENCE_SCHEMA_DDL,
|
||||||
@@ -95,6 +96,8 @@ beforeAll(async () => {
|
|||||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
// `stat` is written by the `match` presence cron; www only reads it, for `/stats`.
|
// `stat` is written by the `match` presence cron; www only reads it, for `/stats`.
|
||||||
for (const stmt of STAT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of STAT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
// `audit_log` is owned by `api`; www files a row there for every ban and lift.
|
||||||
|
for (const stmt of AUDIT_LOG_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:
|
||||||
@@ -948,6 +951,48 @@ it('lifts a ban, clearing the expiry and the audit columns but keeping the repor
|
|||||||
expect(bans.map((b) => b.id)).not.toContain(report.id)
|
expect(bans.map((b) => b.id)).not.toContain(report.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The report row keeps only the ban's CURRENT state — a lift wipes who banned and when —
|
||||||
|
// so the audit log is the one place a ban and its lift are both still on record.
|
||||||
|
it('records a ban and its lift on the audit log, against the moderator', async () => {
|
||||||
|
const report = await createReport(env.DB, {
|
||||||
|
reporterPlayerId: 8186,
|
||||||
|
reportedPlayerId: 8185,
|
||||||
|
reportCategory: 102,
|
||||||
|
})
|
||||||
|
expect((await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, { days: 2 })).status).toBe(
|
||||||
|
200
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
(await staffPost(`/api/staff/reports/${report.id}/ban`, 8111, { banned: false })).status
|
||||||
|
).toBe(200)
|
||||||
|
|
||||||
|
const { results } = await env.DB.prepare(
|
||||||
|
`SELECT player_id, action, data FROM audit_log
|
||||||
|
WHERE action IN ('ban', 'unban') AND json_extract(data, '$.reportId') = ?1
|
||||||
|
ORDER BY audit_log_id`
|
||||||
|
)
|
||||||
|
.bind(report.id)
|
||||||
|
.all<{ player_id: number; action: string; data: string }>()
|
||||||
|
expect(results.map((r) => [r.player_id, r.action])).toEqual([
|
||||||
|
[8110, 'ban'],
|
||||||
|
[8111, 'unban'],
|
||||||
|
])
|
||||||
|
|
||||||
|
const ban = JSON.parse(results[0].data)
|
||||||
|
expect(ban).toMatchObject({ reportId: report.id, playerId: 8185, reportCategory: 102 })
|
||||||
|
expect(ban.banExpires).toEqual(expect.any(String))
|
||||||
|
expect(ban.previous).toMatchObject({ banned: false, bannedBy: null })
|
||||||
|
|
||||||
|
// The lift names who it undid: the report row itself no longer can.
|
||||||
|
const unban = JSON.parse(results[1].data)
|
||||||
|
expect(unban).toMatchObject({ playerId: 8185, banExpires: null })
|
||||||
|
expect(unban.previous).toMatchObject({
|
||||||
|
banned: true,
|
||||||
|
bannedBy: 8110,
|
||||||
|
banExpires: ban.banExpires,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('answers a ban on a report that does not exist with a 404', async () => {
|
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 staffPost('/api/staff/reports/99999/ban', 8110, { days: 1 })).status).toBe(404)
|
||||||
expect((await staffGet('/api/staff/reports/99999', 8110)).status).toBe(404)
|
expect((await staffGet('/api/staff/reports/99999', 8110)).status).toBe(404)
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
"routes": [],
|
"routes": [],
|
||||||
// The shared `recflare` D1. Read for the public `/server-status` head-count and the
|
// The shared `recflare` D1. Read for the public `/server-status` head-count and the
|
||||||
// benefits claim; WRITTEN by the claim (`hasPlus`) and by the staff moderation panel,
|
// benefits claim; WRITTEN by the claim (`hasPlus`) and by the staff moderation panel,
|
||||||
// which files reports and hands down bans on the `report` table (see src/staff.ts).
|
// which files reports and hands down bans on the `report` table (see src/staff.ts) and
|
||||||
|
// records every ban and lift on `audit_log`.
|
||||||
// The schema is owned by the workers that write it (presence lives in
|
// The schema is owned by the workers that write it (presence lives in
|
||||||
// apps/rooms/migrations, `report` in apps/api/migrations), so no migrations_dir: www
|
// apps/rooms/migrations, `report` and `audit_log` in apps/api/migrations), so no migrations_dir: www
|
||||||
// never migrates. The "local" placeholder is spliced with RECFLARE_D1 at deploy time,
|
// never migrates. The "local" placeholder is spliced with RECFLARE_D1 at deploy time,
|
||||||
// as it is for every other worker.
|
// as it is for every other worker.
|
||||||
"d1_databases": [
|
"d1_databases": [
|
||||||
|
|||||||
Reference in New Issue
Block a user