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.');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { config } from '../config.js';
|
||||
import { MODERATION_CONFIG_FILE } from '../fleet/private-config.js';
|
||||
import { AuthError } from '../auth/index.js';
|
||||
|
||||
export const moderationConfigureCapability = 'moderation.configure';
|
||||
export interface ModerationConfiguration {
|
||||
token: string;
|
||||
configPath: string;
|
||||
url: string;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
/** The game connects to the public controller, never an agent's private enrollment URL. */
|
||||
export function moderationRuntimeUrl(): string {
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(config.publicUrl);
|
||||
} catch {
|
||||
throw new AuthError(
|
||||
'Set EC_PUBLIC_URL to the public HTTPS controller URL before configuring moderation.',
|
||||
409
|
||||
);
|
||||
}
|
||||
if (
|
||||
base.protocol !== 'https:' ||
|
||||
base.username ||
|
||||
base.password ||
|
||||
base.search ||
|
||||
base.hash ||
|
||||
base.pathname !== '/'
|
||||
)
|
||||
throw new AuthError(
|
||||
'EC_PUBLIC_URL must be a public HTTPS origin without a path, credentials, query or fragment.',
|
||||
409
|
||||
);
|
||||
return new URL('/api/moderation/runtime', base).href;
|
||||
}
|
||||
|
||||
export function moderationConfiguration(
|
||||
token: string,
|
||||
generation: number
|
||||
): ModerationConfiguration {
|
||||
return {
|
||||
token,
|
||||
generation,
|
||||
configPath: `configs/${MODERATION_CONFIG_FILE}`,
|
||||
url: moderationRuntimeUrl()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { AuthError } from '../auth/index.js';
|
||||
import { nativeInstance } from '../backends/native.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { getHost, queueHostOperation } from '../hosts/index.js';
|
||||
import { moderationActor, moderationEvent } from './core.js';
|
||||
import { moderationCredential } from './credentials.js';
|
||||
import { moderationConfiguration, moderationConfigureCapability } from './configuration.js';
|
||||
|
||||
/** Persist a private, generation-bound bootstrap without exposing the credential to the browser. */
|
||||
export function configureModerationServer(actorId: string, server: string): { id: string } {
|
||||
return db()
|
||||
.transaction(() => {
|
||||
const actor = moderationActor(actorId, true);
|
||||
if (actor.role !== 'admin')
|
||||
throw new AuthError(
|
||||
'Administrator access is required to configure game moderation.',
|
||||
403
|
||||
);
|
||||
const instance = nativeInstance(server);
|
||||
const live = db()
|
||||
.prepare('SELECT destroyed_at FROM servers WHERE name=?')
|
||||
.get(server) as { destroyed_at: string | null } | undefined;
|
||||
if (!instance || !live || live.destroyed_at)
|
||||
throw new AuthError('Choose an active native server.', 404);
|
||||
const host = getHost(instance.host_id);
|
||||
if (!host?.inventory.capabilities?.includes(moderationConfigureCapability))
|
||||
throw new AuthError(
|
||||
'Update the host agent before configuring game moderation.',
|
||||
409
|
||||
);
|
||||
const moderation = moderationConfiguration(
|
||||
moderationCredential(instance.id, instance.generation),
|
||||
instance.generation
|
||||
);
|
||||
const operation = queueHostOperation(
|
||||
instance.host_id,
|
||||
'instance.configure-moderation',
|
||||
{ moderation },
|
||||
{
|
||||
instanceId: instance.id,
|
||||
generation: instance.generation
|
||||
}
|
||||
);
|
||||
moderationEvent(
|
||||
actor,
|
||||
'runtime.configure',
|
||||
{},
|
||||
operation.id,
|
||||
`Configure game moderation on ${server}.`
|
||||
);
|
||||
return { id: operation.id };
|
||||
})
|
||||
.immediate();
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export function observeModerationPlayers(
|
||||
): 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)`);
|
||||
first_seen_at=CASE WHEN first_seen_at='' THEN excluded.first_seen_at ELSE MIN(first_seen_at,excluded.first_seen_at) END,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) {
|
||||
@@ -53,10 +53,10 @@ export function observeModerationPlayers(
|
||||
aliases.run(...values);
|
||||
}
|
||||
}
|
||||
export function ensureSubject(game: ModerationGame, guid: string, now: string): void {
|
||||
export function ensureSubject(game: ModerationGame, guid: string): void {
|
||||
db()
|
||||
.prepare('INSERT INTO moderation_players VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING')
|
||||
.run(game, guid, guid, now, now);
|
||||
.run(game, guid, guid, '', '');
|
||||
}
|
||||
export function moderationEvent(
|
||||
actor: Pick<AuthUser, 'id' | 'username'> | null,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import type { ModerationGame } from '../../moderation.js';
|
||||
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
export function moderationCredential(instanceId: string, generation: number): string {
|
||||
return db()
|
||||
.transaction(() => {
|
||||
if (
|
||||
!Number.isSafeInteger(generation) ||
|
||||
!db()
|
||||
.prepare(
|
||||
'SELECT 1 FROM native_instances WHERE id=? AND generation IN (?,?) AND retired_at IS NULL'
|
||||
)
|
||||
.get(instanceId, generation, generation - 1)
|
||||
)
|
||||
throw new Error('Moderation credentials require an active instance generation.');
|
||||
const existing = db()
|
||||
.prepare(
|
||||
'SELECT token FROM moderation_credentials WHERE instance_id=? AND generation=?'
|
||||
)
|
||||
.get(instanceId, generation) as { token: string } | undefined;
|
||||
if (existing) return existing.token;
|
||||
const token = 'ec_moderation_' + randomBytes(32).toString('base64url');
|
||||
db()
|
||||
.prepare('INSERT INTO moderation_credentials VALUES(?,?,?,?,?)')
|
||||
.run(instanceId, generation, token, digest(token), new Date().toISOString());
|
||||
return token;
|
||||
})
|
||||
.immediate();
|
||||
}
|
||||
export interface ModerationRuntimeIdentity {
|
||||
instanceId: string;
|
||||
generation: number;
|
||||
server: string;
|
||||
game: ModerationGame;
|
||||
serverCreatedAt: string;
|
||||
credentialHash: string;
|
||||
}
|
||||
export function authenticateModerationRuntime(request: Request): ModerationRuntimeIdentity | null {
|
||||
const token = request.headers
|
||||
.get('authorization')
|
||||
?.match(/^Bearer (ec_moderation_[A-Za-z0-9_-]{43})$/)?.[1];
|
||||
if (!token) return null;
|
||||
return (
|
||||
(db()
|
||||
.prepare(
|
||||
`SELECT n.id AS instanceId,n.generation,n.server,s.game,s.created_at AS serverCreatedAt,c.token_hash AS credentialHash
|
||||
FROM moderation_credentials c JOIN native_instances n ON n.id=c.instance_id AND n.generation=c.generation
|
||||
JOIN servers s ON s.name=n.server
|
||||
WHERE c.token_hash=? AND n.retired_at IS NULL AND s.destroyed_at IS NULL AND s.game IN ('t5','t6')`
|
||||
)
|
||||
.get(digest(token)) as ModerationRuntimeIdentity | undefined) ?? null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { moderationActor, moderationNow, policyRevision } from './core.js';
|
||||
import { runtimeFreshMs } from './actions.js';
|
||||
import { refreshActionStates } from './runtime.js';
|
||||
import {
|
||||
moderationGuid,
|
||||
type ModerationOverview,
|
||||
type ModerationQuery,
|
||||
type ModerationPlayer,
|
||||
type ModerationSanction,
|
||||
type ModerationCase,
|
||||
type ModerationEvent,
|
||||
type ModerationAction,
|
||||
type ModerationCoverage
|
||||
} from '../../moderation.js';
|
||||
|
||||
export function moderationQuery(params: URLSearchParams): ModerationQuery {
|
||||
const view = params.get('view');
|
||||
const game = params.get('game');
|
||||
const guid = (params.get('guid') ?? '').slice(0, 128);
|
||||
const offset = Number(params.get('offset') ?? 0);
|
||||
return {
|
||||
view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players',
|
||||
q: (params.get('q') ?? '').trim().slice(0, 200),
|
||||
game: game === 't5' || game === 't6' ? game : '',
|
||||
guid: game === 't5' || game === 't6' ? moderationGuid(game, guid) : guid,
|
||||
offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0
|
||||
};
|
||||
}
|
||||
export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview {
|
||||
moderationActor(actorId);
|
||||
return db().transaction(() => {
|
||||
const handle = db(),
|
||||
now = moderationNow(),
|
||||
limit = 25;
|
||||
refreshActionStates(now);
|
||||
const revision = policyRevision();
|
||||
const runtimeRows = handle
|
||||
.prepare(
|
||||
`SELECT s.name AS server,s.game,n.id,r.connected_at AS connectedAt,r.revision,r.capabilities,r.players
|
||||
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL
|
||||
LEFT JOIN moderation_runtimes r ON r.instance_id=n.id AND r.generation=n.generation
|
||||
WHERE s.destroyed_at IS NULL AND s.game IN ('t5','t6') ORDER BY s.name`
|
||||
)
|
||||
.all() as {
|
||||
server: string;
|
||||
game: 't5' | 't6';
|
||||
id: string | null;
|
||||
connectedAt: string | null;
|
||||
revision: number | null;
|
||||
capabilities: string | null;
|
||||
players: string | null;
|
||||
}[];
|
||||
const coverage: ModerationCoverage[] = runtimeRows.map((row) => ({
|
||||
server: row.server,
|
||||
game: row.game,
|
||||
connectedAt: row.connectedAt,
|
||||
revision: row.revision,
|
||||
desiredRevision: revision,
|
||||
state: !row.connectedAt
|
||||
? 'unavailable'
|
||||
: Date.parse(now) - Date.parse(row.connectedAt) > runtimeFreshMs
|
||||
? 'offline'
|
||||
: row.revision === revision &&
|
||||
['ban', 'mute'].every((capability) =>
|
||||
(JSON.parse(row.capabilities ?? '[]') as string[]).includes(capability)
|
||||
)
|
||||
? 'current'
|
||||
: 'pending',
|
||||
capabilities: row.capabilities ? JSON.parse(row.capabilities) : []
|
||||
}));
|
||||
const result: ModerationOverview = {
|
||||
query: { ...query },
|
||||
players: [],
|
||||
sanctions: [],
|
||||
cases: [],
|
||||
events: [],
|
||||
actions: [],
|
||||
coverage,
|
||||
total: 0,
|
||||
limit,
|
||||
checkedAt: now
|
||||
};
|
||||
const where: string[] = [],
|
||||
values: (string | number)[] = [];
|
||||
if (query.game) {
|
||||
where.push(
|
||||
query.view === 'restrictions' ? "(p.game=? OR p.scope='fleet')" : 'p.game=?'
|
||||
);
|
||||
values.push(query.game);
|
||||
}
|
||||
if (query.guid) {
|
||||
where.push('p.guid=?');
|
||||
values.push(query.guid);
|
||||
}
|
||||
if (query.q) {
|
||||
const extra =
|
||||
query.view === 'restrictions'
|
||||
? 'p.reason'
|
||||
: query.view === 'cases'
|
||||
? 'p.title'
|
||||
: query.view === 'history'
|
||||
? 'p.detail'
|
||||
: 'p.name';
|
||||
where.push(
|
||||
`(instr(lower(p.guid),lower(?))>0 OR instr(lower(${extra}),lower(?))>0 OR EXISTS (SELECT 1 FROM moderation_aliases a WHERE a.game=p.game AND a.guid=p.guid AND instr(lower(a.name),lower(?))>0))`
|
||||
);
|
||||
values.push(query.q, query.q, query.q);
|
||||
}
|
||||
const clause = where.length ? ' WHERE ' + where.join(' AND ') : '';
|
||||
function rows<T>(table: string, select: string, order: string): T[] {
|
||||
result.total = (
|
||||
handle
|
||||
.prepare(`SELECT COUNT(*) AS count FROM ${table} p${clause}`)
|
||||
.get(...values) as { count: number }
|
||||
).count;
|
||||
result.query.offset = Math.min(
|
||||
query.offset,
|
||||
Math.max(0, Math.floor((result.total - 1) / limit) * limit)
|
||||
);
|
||||
return handle
|
||||
.prepare(
|
||||
`SELECT ${select} FROM ${table} p${clause} ORDER BY ${order} LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(...values, limit, result.query.offset) as T[];
|
||||
}
|
||||
if (query.view === 'players') {
|
||||
result.players = rows<Omit<ModerationPlayer, 'aliases' | 'servers' | 'restrictions'>>(
|
||||
'moderation_players',
|
||||
"p.game,p.guid,p.name,NULLIF(p.first_seen_at,'') AS firstSeen,NULLIF(p.last_seen_at,'') AS lastSeen",
|
||||
'p.last_seen_at DESC,p.game,p.guid'
|
||||
).map((player) => ({
|
||||
...player,
|
||||
aliases: (
|
||||
handle
|
||||
.prepare(
|
||||
'SELECT name FROM moderation_aliases WHERE game=? AND guid=? ORDER BY last_seen_at DESC LIMIT 30'
|
||||
)
|
||||
.all(player.game, player.guid) as { name: string }[]
|
||||
).map((a) => a.name),
|
||||
servers: runtimeRows
|
||||
.filter(
|
||||
(r) =>
|
||||
r.game === player.game &&
|
||||
r.connectedAt &&
|
||||
Date.parse(now) - Date.parse(r.connectedAt) <= runtimeFreshMs &&
|
||||
r.players &&
|
||||
(JSON.parse(r.players) as { guid: string }[]).some(
|
||||
(p) => p.guid === player.guid
|
||||
)
|
||||
)
|
||||
.map((r) => r.server),
|
||||
restrictions: (
|
||||
handle
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS count FROM moderation_sanctions WHERE guid=? AND (game=? OR scope='fleet') AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)"
|
||||
)
|
||||
.get(player.guid, player.game, now) as { count: number }
|
||||
).count
|
||||
}));
|
||||
} else if (query.view === 'restrictions') {
|
||||
result.sanctions = rows<Omit<ModerationSanction, 'active'>>(
|
||||
'moderation_sanctions',
|
||||
'p.id,p.game,p.guid,p.kind,p.scope,p.server,p.reason,p.note,p.created_at AS createdAt,p.expires_at AS expiresAt,p.revoked_at AS revokedAt,p.actor_id AS actorId,p.actor,p.revision',
|
||||
'p.created_at DESC,p.id'
|
||||
).map((s) => ({ ...s, active: !s.revokedAt && (!s.expiresAt || s.expiresAt > now) }));
|
||||
} else if (query.view === 'cases') {
|
||||
result.cases = rows<Omit<ModerationCase, 'evidence'>>(
|
||||
'moderation_cases',
|
||||
'p.id,p.game,p.guid,p.title,p.status,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision',
|
||||
'p.updated_at DESC,p.id'
|
||||
).map((c) => ({
|
||||
...c,
|
||||
evidence: handle
|
||||
.prepare(
|
||||
'SELECT id,text,source,created_at AS createdAt,actor FROM moderation_evidence WHERE case_id=? ORDER BY created_at,id'
|
||||
)
|
||||
.all(c.id) as ModerationCase['evidence']
|
||||
}));
|
||||
} else {
|
||||
result.events = rows<ModerationEvent>(
|
||||
'moderation_events',
|
||||
'p.id,p.game,p.guid,p.action,p.subject_id AS subjectId,p.actor,p.detail,p.created_at AS createdAt',
|
||||
'p.id DESC'
|
||||
);
|
||||
}
|
||||
const actionWhere: string[] = [],
|
||||
actionValues: string[] = [];
|
||||
if (query.game) {
|
||||
actionWhere.push('game=?');
|
||||
actionValues.push(query.game);
|
||||
}
|
||||
if (query.guid) {
|
||||
actionWhere.push('guid=?');
|
||||
actionValues.push(query.guid);
|
||||
}
|
||||
if (query.q) {
|
||||
actionWhere.push(
|
||||
'(instr(lower(guid),lower(?))>0 OR instr(lower(reason),lower(?))>0 OR EXISTS (SELECT 1 FROM moderation_aliases a WHERE a.game=moderation_actions.game AND a.guid=moderation_actions.guid AND instr(lower(a.name),lower(?))>0))'
|
||||
);
|
||||
actionValues.push(query.q, query.q, query.q);
|
||||
}
|
||||
result.actions = handle
|
||||
.prepare(
|
||||
`SELECT id,game,guid,server,kind,reason,actor,created_at AS createdAt,expires_at AS expiresAt,state,detail FROM moderation_actions${actionWhere.length ? ' WHERE ' + actionWhere.join(' AND ') : ''} ORDER BY created_at DESC,id LIMIT 25`
|
||||
)
|
||||
.all(...actionValues) as ModerationAction[];
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { AuthError, getUser } from '../auth/index.js';
|
||||
import { canModerate } from '../../auth.js';
|
||||
import { moderationGuid } from '../../moderation.js';
|
||||
import {
|
||||
moderationEvent,
|
||||
moderationNow,
|
||||
observeModerationPlayers,
|
||||
policyRevision,
|
||||
textField
|
||||
} from './core.js';
|
||||
import type { ModerationRuntimeIdentity } from './credentials.js';
|
||||
export { moderationCredential } from './credentials.js';
|
||||
|
||||
const capabilities = ['warn', 'message', 'kick', 'announce', 'ban', 'mute'];
|
||||
export function refreshActionStates(now = moderationNow()): void {
|
||||
const rows = db()
|
||||
.prepare(
|
||||
"SELECT id,game,guid,state FROM moderation_actions WHERE state IN ('queued','delivered') AND expires_at<=?"
|
||||
)
|
||||
.all(now) as { id: string; game: string; guid: string | null; state: string }[];
|
||||
for (const row of rows) {
|
||||
const state = row.state === 'queued' ? 'expired' : 'unknown';
|
||||
const detail =
|
||||
state === 'expired'
|
||||
? 'The action expired before delivery.'
|
||||
: 'The game did not acknowledge the delivered action before its deadline.';
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=? AND state=?'
|
||||
)
|
||||
.run(state, detail, now, row.id, row.state);
|
||||
moderationEvent(null, `action.${state}`, row, row.id, detail, now);
|
||||
}
|
||||
}
|
||||
function token(value: unknown, label: string): string {
|
||||
const result = textField(value, label, 128);
|
||||
if (!/^[A-Za-z0-9:_-]+$/.test(result)) throw new AuthError(`Invalid ${label.toLowerCase()}.`);
|
||||
return result;
|
||||
}
|
||||
export function syncModerationRuntime(
|
||||
identity: ModerationRuntimeIdentity,
|
||||
input: Record<string, unknown>
|
||||
) {
|
||||
if (
|
||||
input.protocol !== 1 ||
|
||||
input.server !== identity.server ||
|
||||
input.generation !== identity.generation
|
||||
)
|
||||
throw new AuthError('The runtime identity does not match this credential.', 403);
|
||||
const session = token(input.session, 'Runtime session');
|
||||
if (!Number.isSafeInteger(input.sessionSequence) || (input.sessionSequence as number) < 1)
|
||||
throw new AuthError('A positive runtime session sequence is required.');
|
||||
if (
|
||||
!Array.isArray(input.players) ||
|
||||
input.players.length > 64 ||
|
||||
!Array.isArray(input.receipts) ||
|
||||
input.receipts.length > 256 ||
|
||||
!Array.isArray(input.capabilities) ||
|
||||
input.capabilities.length > capabilities.length ||
|
||||
!Number.isSafeInteger(input.appliedRevision) ||
|
||||
(input.appliedRevision as number) < 0
|
||||
)
|
||||
throw new AuthError('Invalid moderation synchronization request.');
|
||||
const supported = [...new Set(input.capabilities)];
|
||||
if (supported.some((c) => typeof c !== 'string' || !capabilities.includes(c)))
|
||||
throw new AuthError('Unknown moderation capability.');
|
||||
const players = input.players.map((value) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new AuthError('Invalid player.');
|
||||
const raw = token(value.guid, 'Player GUID');
|
||||
const name = textField(value.name, 'Player name', 255);
|
||||
if (Buffer.byteLength(name, 'utf8') > 255)
|
||||
throw new AuthError('Player names must fit within 255 UTF-8 bytes.');
|
||||
return {
|
||||
guid: moderationGuid(identity.game, raw),
|
||||
name,
|
||||
connection: token(value.connection, 'Player connection')
|
||||
};
|
||||
});
|
||||
if (
|
||||
new Set(players.map((p) => p.connection)).size !== players.length ||
|
||||
new Set(players.map((p) => p.guid)).size !== players.length
|
||||
)
|
||||
throw new AuthError('Player identities and connections must be unique.');
|
||||
const receipts = input.receipts.map((value) => {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!['confirmed', 'failed', 'unknown', 'expired'].includes(value.state)
|
||||
)
|
||||
throw new AuthError('Invalid action receipt.');
|
||||
return {
|
||||
id: token(value.id, 'Action ID'),
|
||||
state: String(value.state),
|
||||
detail: textField(value.detail, 'Receipt detail', 500, true)
|
||||
};
|
||||
});
|
||||
return db()
|
||||
.transaction(() => {
|
||||
// Reading a request body yields: retirement/rebuild/credential replacement may
|
||||
// have completed since the HTTP handler first authenticated this request.
|
||||
const current = db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM native_instances n
|
||||
JOIN servers s ON s.name=n.server
|
||||
JOIN moderation_credentials c ON c.instance_id=n.id AND c.generation=n.generation
|
||||
WHERE n.id=? AND n.generation=? AND n.retired_at IS NULL AND n.server=?
|
||||
AND s.destroyed_at IS NULL AND s.game=? AND s.created_at=? AND c.token_hash=?`
|
||||
)
|
||||
.get(
|
||||
identity.instanceId,
|
||||
identity.generation,
|
||||
identity.server,
|
||||
identity.game,
|
||||
identity.serverCreatedAt,
|
||||
identity.credentialHash
|
||||
);
|
||||
if (!current)
|
||||
throw new AuthError('This moderation runtime credential is no longer active.', 403);
|
||||
const previous = db()
|
||||
.prepare(
|
||||
'SELECT session,session_sequence FROM moderation_runtimes WHERE instance_id=? AND generation=?'
|
||||
)
|
||||
.get(identity.instanceId, identity.generation) as
|
||||
{ session: string; session_sequence: number } | undefined;
|
||||
if (
|
||||
previous &&
|
||||
((input.sessionSequence as number) < previous.session_sequence ||
|
||||
(input.sessionSequence === previous.session_sequence &&
|
||||
session !== previous.session))
|
||||
)
|
||||
throw new AuthError('This runtime session has been superseded.', 409);
|
||||
const now = moderationNow();
|
||||
const revision = policyRevision();
|
||||
if ((input.appliedRevision as number) > revision)
|
||||
throw new AuthError(
|
||||
'The runtime has a newer policy revision than this controller. Review the controller restore before reconfiguring this integration.',
|
||||
409
|
||||
);
|
||||
refreshActionStates(now);
|
||||
for (const receipt of receipts) {
|
||||
const action = db()
|
||||
.prepare(
|
||||
'SELECT * FROM moderation_actions WHERE id=? AND instance_id=? AND generation=?'
|
||||
)
|
||||
.get(receipt.id, identity.instanceId, identity.generation) as
|
||||
| {
|
||||
id: string;
|
||||
game: string;
|
||||
guid: string | null;
|
||||
session: string;
|
||||
state: string;
|
||||
delivered_at: string | null;
|
||||
detail: string | null;
|
||||
}
|
||||
| undefined;
|
||||
// Old journal receipts can outlive controller retention/restores, but cannot target other instances.
|
||||
if (!action) continue;
|
||||
if (!action.delivered_at)
|
||||
throw new AuthError('An undelivered action cannot be acknowledged.', 409);
|
||||
if (['confirmed', 'failed', 'expired'].includes(action.state)) continue;
|
||||
// Durable native receipts may be resubmitted after a game process restart.
|
||||
if (action.state === receipt.state && action.detail === receipt.detail) continue;
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
|
||||
)
|
||||
.run(receipt.state, receipt.detail || null, now, action.id);
|
||||
moderationEvent(
|
||||
null,
|
||||
`action.${receipt.state}`,
|
||||
action,
|
||||
action.id,
|
||||
receipt.detail || receipt.state,
|
||||
now
|
||||
);
|
||||
}
|
||||
if (previous && previous.session !== session) {
|
||||
const abandoned = db()
|
||||
.prepare(
|
||||
"SELECT id,game,guid,state FROM moderation_actions WHERE instance_id=? AND generation=? AND session<>? AND state IN ('queued','delivered')"
|
||||
)
|
||||
.all(identity.instanceId, identity.generation, session) as {
|
||||
id: string;
|
||||
game: string;
|
||||
guid: string | null;
|
||||
state: string;
|
||||
}[];
|
||||
for (const action of abandoned) {
|
||||
const state = action.state === 'queued' ? 'expired' : 'unknown';
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
|
||||
)
|
||||
.run(state, 'The game runtime changed before completion.', now, action.id);
|
||||
moderationEvent(
|
||||
null,
|
||||
`action.${state}`,
|
||||
action,
|
||||
action.id,
|
||||
'The game runtime changed before completion.',
|
||||
now
|
||||
);
|
||||
}
|
||||
}
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO moderation_runtimes(instance_id,generation,session,capabilities,players,revision,connected_at,session_sequence) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(instance_id) DO UPDATE SET
|
||||
generation=excluded.generation,session=excluded.session,capabilities=excluded.capabilities,players=excluded.players,
|
||||
revision=excluded.revision,connected_at=excluded.connected_at,session_sequence=excluded.session_sequence`
|
||||
)
|
||||
.run(
|
||||
identity.instanceId,
|
||||
identity.generation,
|
||||
session,
|
||||
JSON.stringify(supported),
|
||||
JSON.stringify(players),
|
||||
input.appliedRevision,
|
||||
now,
|
||||
input.sessionSequence
|
||||
);
|
||||
observeModerationPlayers(db(), identity.game, players, now);
|
||||
const sanctions = db()
|
||||
.prepare(
|
||||
`SELECT id,revision,guid,kind,reason,expires_at AS expiresAt FROM moderation_sanctions
|
||||
WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?) AND
|
||||
((scope='fleet') OR (game=? AND (scope='game' OR (scope='server' AND server=? AND server_created_at=?)))) ORDER BY id LIMIT 1001`
|
||||
)
|
||||
.all(now, identity.game, identity.server, identity.serverCreatedAt);
|
||||
if (sanctions.length > 1000)
|
||||
throw new AuthError(
|
||||
'The moderation policy exceeds the runtime’s supported size.',
|
||||
503
|
||||
);
|
||||
const pending = db()
|
||||
.prepare(
|
||||
`SELECT * FROM moderation_actions WHERE instance_id=? AND generation=? AND session=?
|
||||
AND state IN ('queued','delivered') AND expires_at>? ORDER BY created_at,id LIMIT 100`
|
||||
)
|
||||
.all(identity.instanceId, identity.generation, session, now) as {
|
||||
id: string;
|
||||
guid: string | null;
|
||||
connection: string | null;
|
||||
kind: string;
|
||||
reason: string;
|
||||
expires_at: string;
|
||||
actor_id: string;
|
||||
game: string;
|
||||
state: string;
|
||||
}[];
|
||||
const actions = [];
|
||||
for (const action of pending) {
|
||||
const actor = getUser(action.actor_id);
|
||||
let failure: string | null = null;
|
||||
if (
|
||||
!actor ||
|
||||
actor.disabled ||
|
||||
actor.mustChangePassword ||
|
||||
!canModerate(actor.role)
|
||||
)
|
||||
failure = 'The requesting moderator no longer has access.';
|
||||
else if (!supported.includes(action.kind))
|
||||
failure = 'The runtime no longer supports this action.';
|
||||
else if (
|
||||
action.guid &&
|
||||
!players.some(
|
||||
(p) => p.guid === action.guid && p.connection === action.connection
|
||||
)
|
||||
)
|
||||
failure = 'The targeted player connection has ended.';
|
||||
if (failure) {
|
||||
const state = action.state === 'delivered' ? 'unknown' : 'failed';
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
|
||||
)
|
||||
.run(state, failure, now, action.id);
|
||||
moderationEvent(null, `action.${state}`, action, action.id, failure, now);
|
||||
continue;
|
||||
}
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE moderation_actions SET state='delivered',delivered_at=COALESCE(delivered_at,?) WHERE id=?"
|
||||
)
|
||||
.run(now, action.id);
|
||||
actions.push({
|
||||
id: action.id,
|
||||
guid: action.guid,
|
||||
connection: action.connection,
|
||||
kind: action.kind,
|
||||
reason: action.reason,
|
||||
expiresAt: action.expires_at
|
||||
});
|
||||
}
|
||||
const response = {
|
||||
ok: true,
|
||||
revision,
|
||||
serverTime: now,
|
||||
sanctions,
|
||||
actions,
|
||||
pollAfterMs: 3000
|
||||
};
|
||||
if (Buffer.byteLength(JSON.stringify(response)) > 1024 * 1024)
|
||||
throw new AuthError(
|
||||
'The moderation response exceeds the runtime’s supported size.',
|
||||
503
|
||||
);
|
||||
return response;
|
||||
})
|
||||
.immediate();
|
||||
}
|
||||
@@ -79,23 +79,33 @@ export function migrateModeration(handle: Database.Database): void {
|
||||
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<{
|
||||
type Player = {
|
||||
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);
|
||||
};
|
||||
const read = handle.prepare(`SELECT * FROM leaderboard_players
|
||||
WHERE game IN ('t5','t6') AND (game,guid)>(?,?) ORDER BY game,guid LIMIT 1000`);
|
||||
let cursor = ['', ''];
|
||||
for (;;) {
|
||||
// Finish each read before writing on this connection, without loading
|
||||
// the full historical player directory into memory during an upgrade.
|
||||
const rows = read.all(...cursor) as Player[];
|
||||
if (!rows.length) break;
|
||||
for (const row of rows) {
|
||||
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);
|
||||
}
|
||||
const last = rows[rows.length - 1];
|
||||
cursor = [last.game, last.guid];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user