This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { AuthError } from '../auth/index.js';
|
||||
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
|
||||
import {
|
||||
canRevokeModerationRestriction,
|
||||
moderationGuid,
|
||||
type ModerationGame,
|
||||
type ModerationScope
|
||||
} from '../../moderation.js';
|
||||
import {
|
||||
advancePolicy,
|
||||
ensureSubject,
|
||||
moderationEvent,
|
||||
moderationMutation,
|
||||
subject,
|
||||
textField
|
||||
} from './core.js';
|
||||
|
||||
export const runtimeFreshMs = 15_000;
|
||||
function gameText(value: unknown, label: string): string {
|
||||
const text = textField(value, label, 240);
|
||||
if (Buffer.byteLength(text, 'utf8') > 240)
|
||||
throw new AuthError(`${label} must fit within 240 UTF-8 bytes for the game server.`);
|
||||
return text;
|
||||
}
|
||||
type SanctionRow = {
|
||||
id: string;
|
||||
game: ModerationGame;
|
||||
guid: string;
|
||||
actor_id: string;
|
||||
scope: ModerationScope;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
revision: number;
|
||||
revoked_at: string | null;
|
||||
};
|
||||
type CaseRow = { id: string; game: ModerationGame; guid: string; revision: number; status: string };
|
||||
|
||||
function serverTarget(name: unknown, game?: ModerationGame) {
|
||||
const server = textField(name, 'Server', 255);
|
||||
const row = db()
|
||||
.prepare(
|
||||
`SELECT s.name,s.game,s.created_at,n.id AS instance_id,n.generation
|
||||
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL
|
||||
WHERE s.name=? AND s.destroyed_at IS NULL`
|
||||
)
|
||||
.get(server) as
|
||||
| {
|
||||
name: string;
|
||||
game: ModerationGame;
|
||||
created_at: string;
|
||||
instance_id: string | null;
|
||||
generation: number | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!row || (game && row.game !== game))
|
||||
throw new AuthError('Choose an existing server for this game.', 404);
|
||||
return row;
|
||||
}
|
||||
|
||||
function createSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
||||
const target = subject(input);
|
||||
if (input.kind !== 'ban' && input.kind !== 'mute') throw new AuthError('Choose ban or mute.');
|
||||
if (typeof input.scope !== 'string' || !['server', 'game', 'fleet'].includes(input.scope))
|
||||
throw new AuthError('Choose a restriction scope.');
|
||||
const duration = input.durationMinutes;
|
||||
if (
|
||||
duration !== null &&
|
||||
(!Number.isSafeInteger(duration) ||
|
||||
(duration as number) < 1 ||
|
||||
(duration as number) > 5256000)
|
||||
)
|
||||
throw new AuthError('Choose a duration between 1 minute and 10 years, or permanent.');
|
||||
if (
|
||||
actor.role !== 'admin' &&
|
||||
(input.scope === 'fleet' ||
|
||||
duration === null ||
|
||||
(duration as number) * 60_000 > moderatorMaximumRestrictionMs)
|
||||
)
|
||||
throw new AuthError(
|
||||
'Moderators can apply server or game restrictions lasting up to 7 days.',
|
||||
403
|
||||
);
|
||||
// Fleet identity is only shared where both games have reported the same numeric account ID.
|
||||
if (
|
||||
input.scope === 'fleet' &&
|
||||
(!/^\d+$/.test(target.guid) ||
|
||||
(
|
||||
db()
|
||||
.prepare(
|
||||
'SELECT COUNT(DISTINCT game) AS count FROM moderation_aliases WHERE guid=?'
|
||||
)
|
||||
.get(target.guid) as { count: number }
|
||||
).count !== 2)
|
||||
)
|
||||
throw new AuthError(
|
||||
'Fleet restrictions require this account ID to have been observed in both games. Use game scope until its identity is established.'
|
||||
);
|
||||
const reason = gameText(input.reason, 'Player-visible reason');
|
||||
const note = textField(input.note, 'Private note', 4000, true);
|
||||
const server = input.scope === 'server' ? serverTarget(input.server, target.game) : null;
|
||||
const activeCount = (
|
||||
db()
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM moderation_sanctions WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)'
|
||||
)
|
||||
.get(now) as { count: number }
|
||||
).count;
|
||||
if (activeCount >= 1000)
|
||||
throw new AuthError('The limit of 1,000 active restrictions has been reached.', 409);
|
||||
const id = randomUUID();
|
||||
const expires =
|
||||
duration === null
|
||||
? null
|
||||
: new Date(Date.parse(now) + (duration as number) * 60_000).toISOString();
|
||||
ensureSubject(target.game, target.guid);
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,server,server_created_at,reason,note,actor_id,actor,created_at,expires_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
target.game,
|
||||
target.guid,
|
||||
input.kind,
|
||||
input.scope,
|
||||
server?.name ?? null,
|
||||
server?.created_at ?? null,
|
||||
reason,
|
||||
note,
|
||||
actor.id,
|
||||
actor.username,
|
||||
now,
|
||||
expires
|
||||
);
|
||||
advancePolicy();
|
||||
moderationEvent(
|
||||
actor,
|
||||
'restriction.created',
|
||||
target,
|
||||
id,
|
||||
JSON.stringify({
|
||||
kind: input.kind,
|
||||
scope: input.scope,
|
||||
server: server?.name ?? null,
|
||||
reason,
|
||||
expiresAt: expires,
|
||||
note
|
||||
}),
|
||||
now
|
||||
);
|
||||
return { id };
|
||||
}
|
||||
function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
||||
const id = textField(input.id, 'Restriction ID', 128);
|
||||
const row = db().prepare('SELECT * FROM moderation_sanctions WHERE id=?').get(id) as
|
||||
SanctionRow | undefined;
|
||||
if (!row) throw new AuthError('Restriction not found.', 404);
|
||||
if (actor.role !== 'admin' && row.actor_id !== actor.id)
|
||||
throw new AuthError(
|
||||
'Only an administrator can revoke another moderator’s restriction.',
|
||||
403
|
||||
);
|
||||
if (
|
||||
!canRevokeModerationRestriction(actor, {
|
||||
actorId: row.actor_id,
|
||||
scope: row.scope,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at
|
||||
})
|
||||
)
|
||||
throw new AuthError(
|
||||
'Moderators can only revoke their own server or game restrictions originally lasting up to 7 days.',
|
||||
403
|
||||
);
|
||||
if (row.revoked_at || row.revision !== input.revision)
|
||||
throw new AuthError('This restriction changed. Refresh before trying again.', 409);
|
||||
const reason = textField(input.reason, 'Revocation reason', 1000);
|
||||
db()
|
||||
.prepare('UPDATE moderation_sanctions SET revoked_at=?,revision=revision+1 WHERE id=?')
|
||||
.run(now, id);
|
||||
advancePolicy();
|
||||
moderationEvent(actor, 'restriction.revoked', row, id, reason, now);
|
||||
return { id };
|
||||
}
|
||||
function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
||||
if (
|
||||
typeof input.kind !== 'string' ||
|
||||
!['warn', 'message', 'kick', 'announce'].includes(input.kind)
|
||||
)
|
||||
throw new AuthError('Choose a supported live action.');
|
||||
const target = input.kind === 'announce' ? null : subject(input);
|
||||
const server = serverTarget(input.server, target?.game);
|
||||
const runtime = db()
|
||||
.prepare('SELECT * FROM moderation_runtimes WHERE instance_id=? AND generation=?')
|
||||
.get(server.instance_id, server.generation) as
|
||||
| {
|
||||
session: string;
|
||||
players: string;
|
||||
capabilities: string;
|
||||
connected_at: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!runtime || Date.parse(now) - Date.parse(runtime.connected_at) > runtimeFreshMs)
|
||||
throw new AuthError(
|
||||
'The moderation integration is not connected to this server. Wait for a fresh connection.',
|
||||
409
|
||||
);
|
||||
if (!(JSON.parse(runtime.capabilities) as string[]).includes(String(input.kind)))
|
||||
throw new AuthError('This server does not support that action.', 409);
|
||||
const matches = (JSON.parse(runtime.players) as { guid: string; connection: string }[]).filter(
|
||||
(p) => p.guid === target?.guid
|
||||
);
|
||||
if (target && matches.length !== 1)
|
||||
throw new AuthError(
|
||||
'This player’s current connection could not be identified. Refresh the roster.',
|
||||
409
|
||||
);
|
||||
const recent = (
|
||||
db()
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM moderation_actions WHERE actor_id=? AND created_at>?'
|
||||
)
|
||||
.get(actor.id, new Date(Date.parse(now) - 60_000).toISOString()) as { count: number }
|
||||
).count;
|
||||
if (recent >= 30)
|
||||
throw new AuthError('Too many moderation actions. Wait a minute before continuing.', 429);
|
||||
const reason = gameText(input.reason, input.kind === 'kick' ? 'Kick reason' : 'Message');
|
||||
const id = randomUUID();
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO moderation_actions(id,game,guid,server,instance_id,generation,session,connection,kind,reason,actor_id,actor,created_at,expires_at,state)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'queued')`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
server.game,
|
||||
target?.guid ?? null,
|
||||
server.name,
|
||||
server.instance_id,
|
||||
server.generation,
|
||||
runtime.session,
|
||||
target ? matches[0].connection : null,
|
||||
input.kind,
|
||||
reason,
|
||||
actor.id,
|
||||
actor.username,
|
||||
now,
|
||||
new Date(Date.parse(now) + 30_000).toISOString()
|
||||
);
|
||||
moderationEvent(
|
||||
actor,
|
||||
'action.queued',
|
||||
{ game: server.game, guid: target?.guid },
|
||||
id,
|
||||
JSON.stringify({ kind: input.kind, server: server.name, reason }),
|
||||
now
|
||||
);
|
||||
return { id };
|
||||
}
|
||||
function caseRow(value: unknown): CaseRow {
|
||||
const row = db()
|
||||
.prepare('SELECT * FROM moderation_cases WHERE id=?')
|
||||
.get(textField(value, 'Case ID', 128)) as CaseRow | undefined;
|
||||
if (!row) throw new AuthError('Case not found.', 404);
|
||||
return row;
|
||||
}
|
||||
function evidence(
|
||||
actor: AuthUser,
|
||||
id: string,
|
||||
text: string,
|
||||
source: string,
|
||||
snapshot: string | null,
|
||||
now: string
|
||||
) {
|
||||
if (
|
||||
(
|
||||
db()
|
||||
.prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE case_id=?')
|
||||
.get(id) as { count: number }
|
||||
).count >= 100
|
||||
)
|
||||
throw new AuthError('This case already contains 100 evidence entries.', 409);
|
||||
db()
|
||||
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
|
||||
.run(randomUUID(), id, text, source, snapshot, actor.id, actor.username, now);
|
||||
}
|
||||
function createCase(actor: AuthUser, input: Record<string, unknown>, now: string) {
|
||||
const target = subject(input);
|
||||
const title = textField(input.title, 'Case title', 160);
|
||||
const note = textField(input.note, 'Case note', 4000, true);
|
||||
const id = randomUUID();
|
||||
ensureSubject(target.game, target.guid);
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at) VALUES(?,?,?,?,'open',?,?,?,?)`
|
||||
)
|
||||
.run(id, target.game, target.guid, title, actor.id, actor.username, now, now);
|
||||
if (note) evidence(actor, id, note, 'Staff note', null, now);
|
||||
if (input.evidence !== undefined) {
|
||||
const requested = input.evidence as { matchId: unknown; message: unknown };
|
||||
if (!requested || !Number.isSafeInteger(requested.matchId))
|
||||
throw new AuthError('Choose a recorded chat message.');
|
||||
const message = textField(requested.message, 'Chat message', 4096);
|
||||
const match = db()
|
||||
.prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?')
|
||||
.get(requested.matchId) as
|
||||
{ game: string; server: string; started_at: string } | undefined;
|
||||
if (!match || match.game !== target.game)
|
||||
throw new AuthError('The source match is unavailable.', 404);
|
||||
const entries = db()
|
||||
.prepare('SELECT entry FROM match_chat WHERE match_id=?')
|
||||
.all(requested.matchId) as { entry: string }[];
|
||||
const found = entries
|
||||
.filter((row) => {
|
||||
const entry = JSON.parse(row.entry);
|
||||
return (
|
||||
entry.type === 'message' &&
|
||||
moderationGuid(target.game, entry.message.guid) === target.guid &&
|
||||
entry.message.message === message
|
||||
);
|
||||
})
|
||||
.slice(0, 10);
|
||||
if (!found.length)
|
||||
throw new AuthError(
|
||||
'The original chat message could not be verified in the archive.',
|
||||
409
|
||||
);
|
||||
for (const row of found)
|
||||
evidence(
|
||||
actor,
|
||||
id,
|
||||
message,
|
||||
`Archived chat · ${match.server} · match ${requested.matchId}`,
|
||||
JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }),
|
||||
now
|
||||
);
|
||||
}
|
||||
moderationEvent(actor, 'case.opened', target, id, title, now);
|
||||
return { id };
|
||||
}
|
||||
export function mutateModeration(actorId: string, input: Record<string, unknown>) {
|
||||
return moderationMutation(actorId, input, (actor, now) => {
|
||||
if (input.op === 'create-sanction') return createSanction(actor, input, now);
|
||||
if (input.op === 'revoke-sanction') return revokeSanction(actor, input, now);
|
||||
if (input.op === 'action') return liveAction(actor, input, now);
|
||||
if (input.op === 'create-case') return createCase(actor, input, now);
|
||||
if (input.op === 'case-note') {
|
||||
const row = caseRow(input.id);
|
||||
const text = textField(input.text, 'Note', 4000);
|
||||
evidence(actor, row.id, text, 'Staff note', null, now);
|
||||
db()
|
||||
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?')
|
||||
.run(now, row.id);
|
||||
moderationEvent(actor, 'case.note', row, row.id, text, now);
|
||||
return { id: row.id };
|
||||
}
|
||||
if (input.op === 'resolve-case') {
|
||||
const row = caseRow(input.id);
|
||||
if (row.revision !== input.revision || row.status !== 'open')
|
||||
throw new AuthError('This case changed. Refresh before resolving it.', 409);
|
||||
if (input.status !== 'resolved' && input.status !== 'dismissed')
|
||||
throw new AuthError('Choose resolved or dismissed.');
|
||||
const reason = textField(input.reason, 'Decision reason', 4000);
|
||||
evidence(
|
||||
actor,
|
||||
row.id,
|
||||
reason,
|
||||
input.status === 'resolved' ? 'Resolution' : 'Dismissal',
|
||||
null,
|
||||
now
|
||||
);
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
|
||||
)
|
||||
.run(input.status, now, row.id);
|
||||
moderationEvent(actor, `case.${input.status}`, row, row.id, reason, now);
|
||||
return { id: row.id };
|
||||
}
|
||||
if (input.op === 'note') {
|
||||
const target = subject(input);
|
||||
const text = textField(input.text, 'Private note', 4000);
|
||||
ensureSubject(target.game, target.guid);
|
||||
moderationEvent(actor, 'player.note', target, null, text, now);
|
||||
return {
|
||||
id: String(
|
||||
(db().prepare('SELECT last_insert_rowid() AS id').get() as { id: number }).id
|
||||
)
|
||||
};
|
||||
}
|
||||
throw new AuthError('Unknown moderation action.');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user