Add moderation permissions and durable player records
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { db } from '../db/index.js';
|
||||
import { getUser, AuthError } from '../auth/index.js';
|
||||
import { canModerate, canReadModeration, type AuthUser } from '../../auth.js';
|
||||
import { moderationGuid, type ModerationGame } from '../../moderation.js';
|
||||
|
||||
export const moderationNow = () => new Date().toISOString();
|
||||
export function moderationActor(id: string, write = false): AuthUser {
|
||||
const user = getUser(id);
|
||||
if (
|
||||
!user ||
|
||||
user.disabled ||
|
||||
user.mustChangePassword ||
|
||||
!(write ? canModerate(user.role) : canReadModeration(user.role))
|
||||
)
|
||||
throw new AuthError('Moderation access is required.', 403);
|
||||
return user;
|
||||
}
|
||||
export function textField(value: unknown, label: string, maximum = 500, optional = false): string {
|
||||
if (optional && (value === undefined || value === null || value === '')) return '';
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!value.trim() ||
|
||||
value.length > maximum ||
|
||||
// eslint-disable-next-line no-control-regex -- Bound all user-controlled moderation text.
|
||||
/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)
|
||||
)
|
||||
throw new AuthError(`${label} must contain 1–${maximum} printable characters.`);
|
||||
return value.trim();
|
||||
}
|
||||
export function subject(input: Record<string, unknown>): { game: ModerationGame; guid: string } {
|
||||
if (input.game !== 't5' && input.game !== 't6')
|
||||
throw new AuthError('Choose Black Ops I or Black Ops II.');
|
||||
const raw = textField(input.guid, 'Player GUID', 128);
|
||||
if (!/^[A-Za-z0-9:_-]+$/.test(raw)) throw new AuthError('Invalid player GUID.');
|
||||
return { game: input.game, guid: moderationGuid(input.game, raw) };
|
||||
}
|
||||
export function observeModerationPlayers(
|
||||
handle: Database.Database,
|
||||
game: ModerationGame,
|
||||
players: { guid: string; name: string }[],
|
||||
now: string
|
||||
): void {
|
||||
const upsert = handle.prepare(`INSERT INTO moderation_players VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(game,guid) DO UPDATE SET name=CASE WHEN excluded.last_seen_at>=last_seen_at THEN excluded.name ELSE name END,
|
||||
first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
|
||||
const aliases = handle.prepare(`INSERT INTO moderation_aliases VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(game,guid,name) DO UPDATE SET first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
|
||||
for (const player of players) {
|
||||
const values = [game, moderationGuid(game, player.guid), player.name, now, now];
|
||||
upsert.run(...values);
|
||||
aliases.run(...values);
|
||||
}
|
||||
}
|
||||
export function ensureSubject(game: ModerationGame, guid: string, now: string): void {
|
||||
db()
|
||||
.prepare('INSERT INTO moderation_players VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING')
|
||||
.run(game, guid, guid, now, now);
|
||||
}
|
||||
export function moderationEvent(
|
||||
actor: Pick<AuthUser, 'id' | 'username'> | null,
|
||||
action: string,
|
||||
target: { game?: string | null; guid?: string | null },
|
||||
subjectId: string | null,
|
||||
detail: string,
|
||||
now = moderationNow()
|
||||
): void {
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO moderation_events(game,guid,actor_id,actor,action,subject_id,detail,created_at) VALUES(?,?,?,?,?,?,?,?)`
|
||||
)
|
||||
.run(
|
||||
target.game ?? null,
|
||||
target.guid ?? null,
|
||||
actor?.id ?? null,
|
||||
actor?.username ?? 'Game server',
|
||||
action,
|
||||
subjectId,
|
||||
detail,
|
||||
now
|
||||
);
|
||||
}
|
||||
export function policyRevision(): number {
|
||||
return (
|
||||
db().prepare('SELECT revision FROM moderation_policy WHERE id=1').get() as {
|
||||
revision: number;
|
||||
}
|
||||
).revision;
|
||||
}
|
||||
export function advancePolicy(): void {
|
||||
db().prepare('UPDATE moderation_policy SET revision=revision+1 WHERE id=1').run();
|
||||
}
|
||||
/** The request and its durable result commit atomically, including repeated browser submissions. */
|
||||
export function moderationMutation(
|
||||
actorId: string,
|
||||
input: Record<string, unknown>,
|
||||
work: (actor: AuthUser, now: string) => { id: string }
|
||||
): { ok: true; id: string } {
|
||||
const key = textField(input.idempotencyKey, 'Request identifier', 128);
|
||||
const canonical = JSON.stringify(
|
||||
Object.fromEntries(Object.entries(input).sort(([a], [b]) => a.localeCompare(b)))
|
||||
);
|
||||
const fingerprint = createHash('sha256').update(canonical).digest('hex');
|
||||
return db()
|
||||
.transaction(() => {
|
||||
const actor = moderationActor(actorId, true);
|
||||
const prior = db()
|
||||
.prepare(
|
||||
'SELECT fingerprint,result FROM moderation_requests WHERE actor_id=? AND request_key=?'
|
||||
)
|
||||
.get(actor.id, key) as { fingerprint: string; result: string } | undefined;
|
||||
if (prior) {
|
||||
if (prior.fingerprint !== fingerprint)
|
||||
throw new AuthError(
|
||||
'This request identifier was already used for a different action.',
|
||||
409
|
||||
);
|
||||
return JSON.parse(prior.result) as { ok: true; id: string };
|
||||
}
|
||||
const now = moderationNow();
|
||||
const result = { ok: true as const, ...work(actor, now) };
|
||||
db()
|
||||
.prepare('INSERT INTO moderation_requests VALUES(?,?,?,?,?)')
|
||||
.run(actor.id, key, fingerprint, JSON.stringify(result), now);
|
||||
return result;
|
||||
})
|
||||
.immediate();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { moderationGuid, type ModerationGame } from '../../moderation.js';
|
||||
|
||||
export function migrateModeration(handle: Database.Database): void {
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS moderation_players (
|
||||
game TEXT NOT NULL CHECK(game IN ('t5','t6')), guid TEXT NOT NULL,
|
||||
name TEXT NOT NULL, first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL,
|
||||
PRIMARY KEY(game,guid)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS moderation_aliases (
|
||||
game TEXT NOT NULL, guid TEXT NOT NULL, name TEXT NOT NULL,
|
||||
first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL,
|
||||
PRIMARY KEY(game,guid,name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS moderation_alias_name ON moderation_aliases(name COLLATE NOCASE);
|
||||
CREATE TABLE IF NOT EXISTS moderation_policy (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL);
|
||||
INSERT OR IGNORE INTO moderation_policy VALUES(1,1);
|
||||
CREATE TABLE IF NOT EXISTS moderation_sanctions (
|
||||
id TEXT PRIMARY KEY, game TEXT NOT NULL CHECK(game IN ('t5','t6')), guid TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('ban','mute')),
|
||||
scope TEXT NOT NULL CHECK(scope IN ('server','game','fleet')),
|
||||
server TEXT, server_created_at TEXT, reason TEXT NOT NULL, note TEXT NOT NULL,
|
||||
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, expires_at TEXT, revoked_at TEXT,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
CHECK((scope='server' AND server IS NOT NULL AND server_created_at IS NOT NULL)
|
||||
OR (scope!='server' AND server IS NULL AND server_created_at IS NULL))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS moderation_sanction_subject ON moderation_sanctions(guid,game,revoked_at,expires_at);
|
||||
CREATE TABLE IF NOT EXISTS moderation_actions (
|
||||
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT, server TEXT NOT NULL,
|
||||
instance_id TEXT NOT NULL, generation INTEGER NOT NULL,
|
||||
session TEXT NOT NULL, connection TEXT,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('warn','message','kick','announce')),
|
||||
reason TEXT NOT NULL, actor_id TEXT NOT NULL, actor TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, expires_at TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK(state IN ('queued','delivered','confirmed','failed','expired','unknown')),
|
||||
detail TEXT, delivered_at TEXT, finished_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS moderation_action_delivery ON moderation_actions(instance_id,generation,state,expires_at);
|
||||
CREATE TABLE IF NOT EXISTS moderation_cases (
|
||||
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('open','resolved','dismissed')),
|
||||
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS moderation_evidence (
|
||||
id TEXT PRIMARY KEY, case_id TEXT NOT NULL REFERENCES moderation_cases(id),
|
||||
text TEXT NOT NULL, source TEXT NOT NULL, snapshot TEXT,
|
||||
actor_id TEXT NOT NULL, actor TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS moderation_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, game TEXT, guid TEXT,
|
||||
actor_id TEXT, actor TEXT NOT NULL, action TEXT NOT NULL,
|
||||
subject_id TEXT, detail TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS moderation_event_subject ON moderation_events(game,guid,id);
|
||||
CREATE TABLE IF NOT EXISTS moderation_requests (
|
||||
actor_id TEXT NOT NULL, request_key TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL, result TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(actor_id,request_key)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS moderation_credentials (
|
||||
instance_id TEXT NOT NULL, generation INTEGER NOT NULL,
|
||||
token TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(instance_id,generation)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS moderation_runtimes (
|
||||
instance_id TEXT PRIMARY KEY, generation INTEGER NOT NULL,
|
||||
session TEXT NOT NULL, capabilities TEXT NOT NULL, players TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 0, connected_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
const player = handle.prepare(`INSERT INTO moderation_players VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(game,guid) DO UPDATE SET
|
||||
name=CASE WHEN excluded.last_seen_at>=last_seen_at THEN excluded.name ELSE name END,
|
||||
first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
|
||||
const alias = handle.prepare(`INSERT INTO moderation_aliases VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(game,guid,name) DO UPDATE SET
|
||||
first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
|
||||
for (const row of handle
|
||||
.prepare("SELECT * FROM leaderboard_players WHERE game IN ('t5','t6')")
|
||||
.iterate() as Iterable<{
|
||||
game: ModerationGame;
|
||||
guid: string;
|
||||
name: string;
|
||||
first_seen_at: string;
|
||||
last_seen_at: string;
|
||||
}>) {
|
||||
const values = [
|
||||
row.game,
|
||||
moderationGuid(row.game, row.guid),
|
||||
row.name,
|
||||
row.first_seen_at,
|
||||
row.last_seen_at
|
||||
];
|
||||
player.run(...values);
|
||||
alias.run(...values);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user