This commit is contained in:
+21
-2
@@ -1,3 +1,5 @@
|
||||
import { moderatorMaximumRestrictionMs, type AuthUser } from './auth.js';
|
||||
|
||||
export type ModerationGame = 't5' | 't6';
|
||||
export type ModerationScope = 'server' | 'game' | 'fleet';
|
||||
export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce';
|
||||
@@ -9,8 +11,8 @@ export interface ModerationPlayer {
|
||||
guid: string;
|
||||
name: string;
|
||||
aliases: string[];
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
firstSeen: string | null;
|
||||
lastSeen: string | null;
|
||||
servers: string[];
|
||||
restrictions: number;
|
||||
}
|
||||
@@ -31,6 +33,23 @@ export interface ModerationSanction {
|
||||
revision: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function canRevokeModerationRestriction(
|
||||
actor: Pick<AuthUser, 'id' | 'role'> | null | undefined,
|
||||
restriction: Pick<ModerationSanction, 'actorId' | 'scope' | 'createdAt' | 'expiresAt'>
|
||||
): boolean {
|
||||
if (actor?.role === 'admin') return true;
|
||||
if (
|
||||
actor?.role !== 'moderator' ||
|
||||
actor.id !== restriction.actorId ||
|
||||
(restriction.scope !== 'server' && restriction.scope !== 'game') ||
|
||||
!restriction.expiresAt
|
||||
)
|
||||
return false;
|
||||
const duration = Date.parse(restriction.expiresAt) - Date.parse(restriction.createdAt);
|
||||
return duration > 0 && duration <= moderatorMaximumRestrictionMs;
|
||||
}
|
||||
|
||||
export interface ModerationAction {
|
||||
id: string;
|
||||
game: ModerationGame;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { canReadAdmin, type UserRole } from './auth.js';
|
||||
import { canReadAdmin, canReadModeration, type UserRole } from './auth.js';
|
||||
|
||||
export interface NavigationItem {
|
||||
label: string;
|
||||
href: string;
|
||||
shortcut?: string;
|
||||
moderation?: boolean;
|
||||
matches?: (path: string) => boolean;
|
||||
}
|
||||
export interface NavigationGroup {
|
||||
@@ -71,6 +72,7 @@ export const navigationGroups: NavigationGroup[] = [
|
||||
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
|
||||
{ label: 'Matches', href: '/matches' },
|
||||
{ label: 'Bank', href: '/bank' },
|
||||
{ label: 'Moderation', href: '/moderation', moderation: true },
|
||||
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
|
||||
]
|
||||
},
|
||||
@@ -92,7 +94,12 @@ export const navigationGroups: NavigationGroup[] = [
|
||||
];
|
||||
|
||||
export function visibleNavigation(role: UserRole): NavigationGroup[] {
|
||||
return navigationGroups.filter((group) => !group.admin || canReadAdmin(role));
|
||||
return navigationGroups
|
||||
.filter((group) => !group.admin || canReadAdmin(role))
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => !item.moderation || canReadModeration(role))
|
||||
}));
|
||||
}
|
||||
|
||||
export function navigationItemActive(item: NavigationItem, path: string): boolean {
|
||||
|
||||
@@ -41,6 +41,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
||||
if (within(routeId, '/api/agent')) return 'machine';
|
||||
if (routeId === '/api/bank/[action]' && method === 'POST') return 'machine';
|
||||
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
|
||||
if (routeId === '/api/moderation/runtime' && method === 'POST') return 'machine';
|
||||
if (routeId === '/api/leaderboard' && reading) return 'public';
|
||||
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
|
||||
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST')
|
||||
|
||||
@@ -16,6 +16,7 @@ function num(name: string, fallback: number): number {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
publicUrl: env.EC_PUBLIC_URL ?? '',
|
||||
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'),
|
||||
t6ServersRoot:
|
||||
env.T6_SERVERS_ROOT ?? join(dirname(req('SERVERS_ROOT', '/root/zombies/t5')), 't6'),
|
||||
|
||||
@@ -143,7 +143,24 @@ export const migrations: readonly Migration[] = [
|
||||
mode: 'foreign-keys-off',
|
||||
run: migrateModeratorRole
|
||||
},
|
||||
{ version: 26, name: 'player moderation records and enforcement', run: migrateModeration }
|
||||
{ version: 26, name: 'player moderation records and enforcement', run: migrateModeration },
|
||||
{
|
||||
version: 27,
|
||||
name: 'moderation runtime sessions and evidence lookup',
|
||||
run: (handle) => {
|
||||
handle.exec(
|
||||
'CREATE INDEX IF NOT EXISTS moderation_evidence_case ON moderation_evidence(case_id)'
|
||||
);
|
||||
if (
|
||||
!(handle.pragma('table_info(moderation_runtimes)') as { name: string }[]).some(
|
||||
(c) => c.name === 'session_sequence'
|
||||
)
|
||||
)
|
||||
handle.exec(
|
||||
'ALTER TABLE moderation_runtimes ADD COLUMN session_sequence INTEGER NOT NULL DEFAULT 0'
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export const databaseVersion = migrations.length;
|
||||
|
||||
@@ -2,11 +2,18 @@ import { posix } from 'node:path';
|
||||
import { gameOfServer, relDestForKind, type ArtifactKind } from '../config.js';
|
||||
|
||||
export const BANK_CONFIG_FILE = 'electric_cherry_bank.cfg';
|
||||
export const MODERATION_CONFIG_FILE = 'electric_cherry_moderation.cfg';
|
||||
export const PRIVATE_CONFIG_FILES = [BANK_CONFIG_FILE, MODERATION_CONFIG_FILE] as const;
|
||||
|
||||
export function isPrivateConfigPath(path: string, game = 't5'): boolean {
|
||||
return (
|
||||
posix.normalize(path.replaceAll('\\', '/')) ===
|
||||
posix.join(relDestForKind('config', game), BANK_CONFIG_FILE)
|
||||
// Moderation bootstrap has one fixed runtime location, even when shared
|
||||
// artifact destinations were customized for an older installation.
|
||||
if (posix.normalize(path.replaceAll('\\', '/')) === `configs/${MODERATION_CONFIG_FILE}`)
|
||||
return true;
|
||||
return PRIVATE_CONFIG_FILES.some(
|
||||
(filename) =>
|
||||
posix.normalize(path.replaceAll('\\', '/')) ===
|
||||
posix.join(relDestForKind('config', game), filename)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,26 +30,31 @@ export function isPrivateConfigTarget(server: string, kind: string, path: string
|
||||
export function assertPublicFileTarget(server: string, kind: ArtifactKind, path: string): void {
|
||||
if (isPrivateConfigTarget(server, kind, path))
|
||||
throw new Error(
|
||||
'Private bank configuration cannot be captured or changed by a file campaign.'
|
||||
'Private bank configuration and moderation configuration cannot be captured or changed by a file campaign.'
|
||||
);
|
||||
}
|
||||
|
||||
export function isReservedConfigFilename(path: string): boolean {
|
||||
return posix.basename(path.replaceAll('\\', '/')).toLowerCase() === BANK_CONFIG_FILE;
|
||||
return PRIVATE_CONFIG_FILES.some(
|
||||
(filename) => posix.basename(path.replaceAll('\\', '/')).toLowerCase() === filename
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy campaign evidence omits the kind and stores the path relative to its destination.
|
||||
export function isPrivateConfigEvidencePath(path: string): boolean {
|
||||
return (
|
||||
posix.normalize(path.replaceAll('\\', '/')) === BANK_CONFIG_FILE ||
|
||||
isPrivateConfigPath(path)
|
||||
PRIVATE_CONFIG_FILES.some(
|
||||
(filename) => posix.normalize(path.replaceAll('\\', '/')) === filename
|
||||
) || isPrivateConfigPath(path)
|
||||
);
|
||||
}
|
||||
|
||||
/** Credential injection uses bank.configPath, never a shared file's path field. */
|
||||
/** Credential injection uses a private configPath, never a shared file's path field. */
|
||||
export class PrivateFileOperationError extends Error {
|
||||
constructor() {
|
||||
super('Private bank configuration cannot be read or changed by file operations.');
|
||||
super(
|
||||
'Private bank configuration and moderation configuration cannot be read or changed by file operations.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ export function createScript(input: CreateScriptInput): {
|
||||
if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty');
|
||||
if (isReservedConfigFilename(targetRelPath))
|
||||
throw new ScriptError(
|
||||
'electric_cherry_bank.cfg is reserved for instance credentials.',
|
||||
`${targetRelPath} is reserved for instance credentials.`,
|
||||
'path-reserved'
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
assertPublicFilePayload,
|
||||
BANK_CONFIG_FILE,
|
||||
PRIVATE_CONFIG_FILES,
|
||||
PrivateFileOperationError,
|
||||
isPrivateConfigPath
|
||||
} from '../fleet/private-config.js';
|
||||
@@ -1454,7 +1454,7 @@ export function getHostOperationPayload(hostId: string, id: string): Buffer {
|
||||
.get(id, hostId) as { payload: Buffer } | undefined;
|
||||
if (!row) throw new Error('Host operation was not found for this host.');
|
||||
// Block old private-file commands before delivery to agents predating the guard.
|
||||
if (row.payload.includes(BANK_CONFIG_FILE))
|
||||
if (PRIVATE_CONFIG_FILES.some((filename) => row.payload.includes(filename)))
|
||||
assertPublicFilePayload(JSON.parse(row.payload.toString()));
|
||||
if (
|
||||
!row.payload.includes('"contentSha256"') ||
|
||||
@@ -1485,10 +1485,15 @@ export function reportHostOperationStatus(
|
||||
if (['completed', 'failed'].includes(operation.state)) return operation;
|
||||
const state = input.state as HostOperationState;
|
||||
if (operation.state === 'running' && state === 'accepted') return operation;
|
||||
const error =
|
||||
let error =
|
||||
input.error === undefined || input.error === null
|
||||
? null
|
||||
: String(input.error).slice(0, 4000);
|
||||
if (operation.kind === 'instance.configure-moderation' && error)
|
||||
error = error.replace(
|
||||
/ec_(?:moderation|bank)_[A-Za-z0-9_-]+/g,
|
||||
'[redacted credential]'
|
||||
);
|
||||
let result = input.result === undefined ? null : JSON.stringify(input.result);
|
||||
if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024)
|
||||
throw new Error('Operation result exceeds the limit.');
|
||||
@@ -1504,7 +1509,9 @@ export function reportHostOperationStatus(
|
||||
? { redacted: true }
|
||||
: operation.kind === 'logs.read'
|
||||
? logResultMetadata(input.result)
|
||||
: input.result
|
||||
: operation.kind === 'instance.configure-moderation'
|
||||
? moderationConfigurationReceipt(input.result)
|
||||
: input.result
|
||||
);
|
||||
result = JSON.stringify(packed.value);
|
||||
referenceBlobs('operation-result', id, packed.hashes);
|
||||
@@ -1568,6 +1575,22 @@ export function reportHostOperationStatus(
|
||||
queueMicrotask(() => workEvents.emit(`operation:${id}`));
|
||||
return operation;
|
||||
}
|
||||
|
||||
/** Configuration replies contain status only, never echoed dvars, file bodies or credentials. */
|
||||
function moderationConfigurationReceipt(value: unknown): Record<string, unknown> {
|
||||
const result =
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
configured: result.configured === true,
|
||||
...(Number.isSafeInteger(result.generation) ? { generation: result.generation } : {}),
|
||||
liveLoad: ['sent', 'unconfirmed', 'deferred'].includes(result.liveLoad as string)
|
||||
? result.liveLoad
|
||||
: 'unconfirmed',
|
||||
...(result.reconciled === true ? { reconciled: true } : {})
|
||||
};
|
||||
}
|
||||
export function reportHostOperation(
|
||||
hostId: string,
|
||||
id: string,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { db } from '../db/index.js';
|
||||
import type { NativeInstance } from '../backends/native.js';
|
||||
import type { LaunchRecipePlan } from '../fleet/launch-recipes.js';
|
||||
import type { PlacementOptions } from '../hosts/capacity.js';
|
||||
import type { ModerationConfiguration } from '../moderation/configuration.js';
|
||||
|
||||
export interface NativeLaunchInput {
|
||||
game: 't5' | 't6';
|
||||
@@ -14,6 +15,7 @@ export interface NativeLaunchInput {
|
||||
serverKey: string;
|
||||
rconPassword: string;
|
||||
bankToken: string;
|
||||
moderation?: ModerationConfiguration;
|
||||
rebuild: boolean;
|
||||
safetyOverride?: boolean;
|
||||
unhealthyRecovery?: boolean;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { sha256Buffer } from '../fleet/files.js';
|
||||
import type { NativeLaunchInput } from './launch-preparation.js';
|
||||
import { choosePlacement, type PlacementOptions } from '../hosts/capacity.js';
|
||||
import { isPrivateConfigPath } from '../fleet/private-config.js';
|
||||
|
||||
parentPort!.on(
|
||||
'message',
|
||||
@@ -79,12 +80,16 @@ parentPort!.on(
|
||||
'electric_cherry_bank.cfg'
|
||||
);
|
||||
if (
|
||||
recipe.files.some(
|
||||
(file) =>
|
||||
posix.join(relDestForKind(file.kind, recipe.game), file.path) === bankPath
|
||||
recipe.files.some((file) =>
|
||||
isPrivateConfigPath(
|
||||
posix.join(relDestForKind(file.kind, recipe.game), file.path),
|
||||
recipe.game
|
||||
)
|
||||
)
|
||||
)
|
||||
throw new Error('electric_cherry_bank.cfg is reserved for instance credentials.');
|
||||
throw new Error(
|
||||
'Private configuration files are reserved for instance credentials.'
|
||||
);
|
||||
const payload = {
|
||||
...(input.activationId ? { stageId: input.activationId } : {}),
|
||||
name: instance.server,
|
||||
@@ -95,13 +100,14 @@ parentPort!.on(
|
||||
bindAddress: instance.bind_address,
|
||||
rconPassword: password,
|
||||
bank: { token: input.bankToken, configPath: bankPath },
|
||||
...(input.moderation ? { moderation: input.moderation } : {}),
|
||||
env: {
|
||||
PLUTAINER_SERVER_NAME: instance.server,
|
||||
PLUTAINER_PORT: String(instance.port),
|
||||
PLUTAINER_GAME: recipe.game === 't6' ? 't6zm' : 't5sp',
|
||||
PLUTAINER_CONFIG_FILE: recipe.configFile,
|
||||
PLUTAINER_MAP_ROTATE: 'false',
|
||||
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +exec electric_cherry_bank.cfg +set tws_server_id ${instance.server} +map_rotate`,
|
||||
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +exec electric_cherry_bank.cfg${input.moderation ? ' +exec electric_cherry_moderation.cfg' : ''} +set tws_server_id ${instance.server} +map_rotate`,
|
||||
PLUTO_SERVER_KEY: input.serverKey
|
||||
},
|
||||
files: recipe.files.map((file) => ({
|
||||
|
||||
@@ -14,6 +14,11 @@ import { setImmediate as yieldToController } from 'node:timers/promises';
|
||||
import { choosePlacement, availableHostPort, type PlacementOptions } from '../hosts/capacity.js';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { bankCredential } from '../auth/bank.js';
|
||||
import { moderationCredential } from '../moderation/credentials.js';
|
||||
import {
|
||||
moderationConfiguration,
|
||||
moderationConfigureCapability
|
||||
} from '../moderation/configuration.js';
|
||||
import { posix } from 'node:path';
|
||||
import { db } from '../db/index.js';
|
||||
import { unsettledCancelledCommand } from './cancellation.js';
|
||||
@@ -1996,6 +2001,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
||||
let prepared: PreparedNativeLaunch | undefined;
|
||||
let preparedKey: string | null = null;
|
||||
let preparedPassword: string | undefined;
|
||||
let preparedModeration = false;
|
||||
try {
|
||||
if (job.action === 'create' || job.action === 'rebuild') {
|
||||
if (
|
||||
@@ -2027,6 +2033,11 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
||||
(files.length ? runtimePasswordForServer(job.server) : undefined) ||
|
||||
config.rcon.password ||
|
||||
randomBytes(32).toString('hex');
|
||||
const rebuilding = job.action === 'rebuild' && !migration;
|
||||
const generation = instance.generation + (rebuilding ? 1 : 0);
|
||||
preparedModeration =
|
||||
!!config.publicUrl &&
|
||||
!!host.inventory.capabilities?.includes(moderationConfigureCapability);
|
||||
prepared = await prepareNativeLaunch({
|
||||
game: prior.game,
|
||||
mapSlug: prior.mapSlug,
|
||||
@@ -2035,7 +2046,15 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
||||
serverKey: preparedKey,
|
||||
rconPassword: preparedPassword,
|
||||
bankToken: bankCredential(instance.id),
|
||||
rebuild: job.action === 'rebuild' && !migration,
|
||||
...(preparedModeration
|
||||
? {
|
||||
moderation: moderationConfiguration(
|
||||
moderationCredential(instance.id, generation),
|
||||
generation
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
rebuild: rebuilding,
|
||||
safetyOverride: overrideSafety,
|
||||
unhealthyRecovery:
|
||||
!files.length &&
|
||||
@@ -2124,6 +2143,16 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
||||
return;
|
||||
}
|
||||
if (prepared) {
|
||||
if (
|
||||
preparedModeration &&
|
||||
!liveHost.inventory.capabilities?.includes(moderationConfigureCapability)
|
||||
) {
|
||||
hold(
|
||||
job,
|
||||
'Host moderation support changed during preparation; preparing the current selection next pass.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Compare the same runtime config bytes while rechecking current promoted files.
|
||||
const expected =
|
||||
migration && isRebalanceMigration(job.id)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { canReadModeration } from '$lib/auth.js';
|
||||
import { moderationHref } from '$ui/moderation.js';
|
||||
import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js';
|
||||
import { formatDuration } from '$ui/duration.js';
|
||||
import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js';
|
||||
@@ -10,16 +13,19 @@
|
||||
|
||||
let {
|
||||
server,
|
||||
game,
|
||||
history,
|
||||
hostOffline = false,
|
||||
hostName
|
||||
}: {
|
||||
server?: string;
|
||||
game?: string;
|
||||
history?: ContextualChatEntry[];
|
||||
hostOffline?: boolean;
|
||||
hostName?: string;
|
||||
} = $props();
|
||||
let updatedAt = $state<number | null>(null);
|
||||
const moderationGame = $derived(game ?? server?.split('_')[0]);
|
||||
let liveEntries = $state<ContextualChatEntry[]>([]);
|
||||
const entries = $derived(history ?? liveEntries);
|
||||
let timeline = $derived(groupChatTimeline(entries));
|
||||
@@ -242,6 +248,18 @@
|
||||
<div class="message-head">
|
||||
<strong>{item.entry.message.player}</strong
|
||||
>{@render chatContext(item.entry.context)}
|
||||
{#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')}<a
|
||||
class="moderation-link"
|
||||
href={moderationHref({
|
||||
game: moderationGame,
|
||||
q: item.entry.message.guid,
|
||||
server,
|
||||
matchId: item.entry.matchId,
|
||||
message: item.entry.message.message
|
||||
})}
|
||||
aria-label={`Review ${item.entry.message.player} in moderation`}
|
||||
>Moderation</a
|
||||
>{/if}
|
||||
</div>
|
||||
<p>{item.entry.message.message}</p>
|
||||
</div>
|
||||
@@ -371,6 +389,9 @@
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.moderation-link {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.match-events {
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
@@ -463,5 +484,9 @@
|
||||
grid-row: 3;
|
||||
margin-top: 0;
|
||||
}
|
||||
.moderation-link {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { page } from '$app/stores';
|
||||
import { canReadModeration } from '$lib/auth.js';
|
||||
import { moderationHref } from '$ui/moderation.js';
|
||||
import type { GameTelemetry } from '$lib/telemetry.js';
|
||||
import { freshTelemetry } from '$lib/server-observation.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
@@ -164,6 +167,14 @@
|
||||
<a class="player-name" href={playerProfileHref(player.guid)}
|
||||
>{player.name}</a
|
||||
>
|
||||
{#if canReadModeration($page.data?.user?.role)}<a
|
||||
class="player-details"
|
||||
href={moderationHref({
|
||||
game: report.server.split('_')[0],
|
||||
guid: player.guid,
|
||||
server: report.server
|
||||
})}>Moderation</a
|
||||
>{/if}
|
||||
<button
|
||||
class="player-details"
|
||||
aria-label={`Details for ${player.name}`}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { ModerationCoverage, ModerationGame, ModerationScope } from '$lib/moderation.js';
|
||||
import { moderationGameName } from '$ui/moderation.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import Dialog from '../primitives/Dialog.svelte';
|
||||
import Field from '../primitives/Field.svelte';
|
||||
import Input from '../primitives/Input.svelte';
|
||||
import Select from '../primitives/Select.svelte';
|
||||
import Textarea from '../primitives/Textarea.svelte';
|
||||
import Button from '../primitives/Button.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
mode,
|
||||
coverage,
|
||||
admin,
|
||||
seed = {},
|
||||
onsubmitted
|
||||
}: {
|
||||
open?: boolean;
|
||||
mode: 'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve';
|
||||
coverage: ModerationCoverage[];
|
||||
admin: boolean;
|
||||
seed?: {
|
||||
game?: ModerationGame;
|
||||
guid?: string;
|
||||
server?: string;
|
||||
id?: string;
|
||||
revision?: number;
|
||||
matchId?: number;
|
||||
message?: string;
|
||||
};
|
||||
onsubmitted: (target: { game?: ModerationGame; guid?: string | null }) => void;
|
||||
} = $props();
|
||||
let game = $state<ModerationGame | ''>(untrack(() => seed.game ?? ''));
|
||||
let guid = $state(untrack(() => seed.guid ?? ''));
|
||||
let server = $state(untrack(() => seed.server ?? ''));
|
||||
let scope = $state<ModerationScope>(untrack(() => (seed.server ? 'server' : 'game')));
|
||||
let kind = $state('ban');
|
||||
let action = $state('warn');
|
||||
let duration = $state('60');
|
||||
let reason = $state('');
|
||||
let note = $state('');
|
||||
let title = $state('');
|
||||
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
|
||||
const gameText = $derived(mode === 'restriction' || mode === 'action');
|
||||
let resolution = $state('resolved');
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let uncertain = $state(false);
|
||||
let pending: Record<string, unknown> | null = null;
|
||||
const titleText = $derived(
|
||||
{
|
||||
restriction: 'Restrict player',
|
||||
action: 'Live action',
|
||||
case: 'Open moderation case',
|
||||
note: 'Add private note',
|
||||
revoke: 'Revoke restriction',
|
||||
'case-note': 'Add case note',
|
||||
resolve: 'Review case'
|
||||
}[mode]
|
||||
);
|
||||
const targetRequired = $derived(['restriction', 'action', 'case', 'note'].includes(mode));
|
||||
const needsServer = $derived(
|
||||
mode === 'action' || (mode === 'restriction' && scope === 'server')
|
||||
);
|
||||
const serverOptions = $derived([
|
||||
{ value: '', label: 'Select a server' },
|
||||
...coverage
|
||||
.filter((item) => item.game === game)
|
||||
.map((item) => ({ value: item.server, label: serverTitle(item.server) }))
|
||||
]);
|
||||
const selectedServer = $derived(
|
||||
coverage.find((item) => item.server === server && item.game === game)
|
||||
);
|
||||
const actionUnavailable = $derived(
|
||||
mode === 'action' &&
|
||||
(!selectedServer ||
|
||||
['offline', 'unavailable'].includes(selectedServer.state) ||
|
||||
!selectedServer.capabilities.includes(action))
|
||||
);
|
||||
const actionOptions = $derived(
|
||||
[
|
||||
{ value: 'warn', label: 'Warn player' },
|
||||
{ value: 'message', label: 'Private message' },
|
||||
{ value: 'kick', label: 'Kick player' },
|
||||
{ value: 'announce', label: 'Server announcement' }
|
||||
].map((item) => ({ ...item, disabled: !selectedServer?.capabilities.includes(item.value) }))
|
||||
);
|
||||
|
||||
function payload(): Record<string, unknown> {
|
||||
if (mode === 'restriction')
|
||||
return {
|
||||
op: 'create-sanction',
|
||||
game,
|
||||
guid: guid.trim(),
|
||||
kind,
|
||||
scope,
|
||||
server: scope === 'server' ? server : undefined,
|
||||
reason: reason.trim(),
|
||||
note: note.trim(),
|
||||
durationMinutes: duration === 'permanent' ? null : Number(duration)
|
||||
};
|
||||
if (mode === 'action')
|
||||
return {
|
||||
op: 'action',
|
||||
game,
|
||||
guid: action === 'announce' ? null : guid.trim(),
|
||||
server,
|
||||
kind: action,
|
||||
reason: reason.trim()
|
||||
};
|
||||
if (mode === 'case')
|
||||
return {
|
||||
op: 'create-case',
|
||||
game,
|
||||
guid: guid.trim(),
|
||||
title: title.trim(),
|
||||
note: note.trim(),
|
||||
...(seed.matchId && seed.message
|
||||
? { evidence: { matchId: seed.matchId, message: seed.message } }
|
||||
: {})
|
||||
};
|
||||
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
|
||||
if (mode === 'case-note') return { op: 'case-note', id: seed.id, text: note.trim() };
|
||||
if (mode === 'revoke')
|
||||
return {
|
||||
op: 'revoke-sanction',
|
||||
id: seed.id,
|
||||
revision: seed.revision,
|
||||
reason: reason.trim()
|
||||
};
|
||||
return {
|
||||
op: 'resolve-case',
|
||||
id: seed.id,
|
||||
revision: seed.revision,
|
||||
status: resolution,
|
||||
reason: reason.trim()
|
||||
};
|
||||
}
|
||||
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
if (!pending && gameText && reasonBytes > 240) {
|
||||
error = 'The player-visible text is too long. Shorten it to 240 bytes or fewer.';
|
||||
return;
|
||||
}
|
||||
if (!pending && targetRequired && !game) {
|
||||
error = 'Choose the game for this player identity.';
|
||||
return;
|
||||
}
|
||||
if (!pending && actionUnavailable) {
|
||||
error = 'The selected server does not currently support this action.';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
error = '';
|
||||
if (!pending && mode === 'restriction' && (scope === 'fleet' || duration === 'permanent')) {
|
||||
const accepted = await confirmAction({
|
||||
title: 'Confirm restriction',
|
||||
message: `${kind === 'ban' ? 'Ban' : 'Mute'} player ${guid} ${scope === 'fleet' ? 'across the fleet' : scope === 'game' ? `in ${moderationGameName(game as ModerationGame)}` : `on ${serverTitle(server)}`} ${duration === 'permanent' ? 'permanently' : `for ${Number(duration).toLocaleString()} minutes`}. Reason: ${reason}`,
|
||||
confirmLabel: 'Apply restriction',
|
||||
danger: true
|
||||
});
|
||||
if (!accepted) {
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
pending ??= { ...payload(), idempotencyKey: crypto.randomUUID() };
|
||||
try {
|
||||
const response = await fetch('/api/moderation', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(pending)
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok || !result?.ok) {
|
||||
uncertain = response.status >= 500 || response.ok;
|
||||
if (!uncertain) pending = null;
|
||||
throw new Error(result?.error || 'Could not save this moderation action.');
|
||||
}
|
||||
open = false;
|
||||
onsubmitted({
|
||||
game: pending.game as ModerationGame | undefined,
|
||||
guid: pending.guid as string | null | undefined
|
||||
});
|
||||
} catch (cause) {
|
||||
if (pending) uncertain = true;
|
||||
error =
|
||||
cause instanceof Error ? cause.message : 'Could not save this moderation action.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog
|
||||
bind:open
|
||||
title={titleText}
|
||||
{busy}
|
||||
description="Every moderation change is recorded with your identity and reason."
|
||||
>
|
||||
<form onsubmit={submit} class="moderation-form">
|
||||
<fieldset disabled={busy || uncertain}>
|
||||
{#if targetRequired}
|
||||
<Field label="Game" required
|
||||
><Select
|
||||
bind:value={game}
|
||||
required
|
||||
onchange={() => (server = '')}
|
||||
options={[
|
||||
{ value: '', label: 'Select a game' },
|
||||
{ value: 't5', label: 'Black Ops I' },
|
||||
{ value: 't6', label: 'Black Ops II' }
|
||||
]}
|
||||
/></Field
|
||||
>
|
||||
{#if mode === 'action'}
|
||||
<Field label="Action"
|
||||
><Select bind:value={action} options={actionOptions} /></Field
|
||||
>
|
||||
{/if}
|
||||
{#if mode !== 'action' || action !== 'announce'}
|
||||
<Field
|
||||
label="Player GUID"
|
||||
required
|
||||
hint="Use the recorded player identity. Names can change."
|
||||
><Input name="target-guid" bind:value={guid} required mono /></Field
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if mode === 'restriction'}
|
||||
<div class="paired">
|
||||
<Field label="Restriction"
|
||||
><Select
|
||||
bind:value={kind}
|
||||
options={[
|
||||
{ value: 'ban', label: 'Ban' },
|
||||
{ value: 'mute', label: 'Mute' }
|
||||
]}
|
||||
/></Field
|
||||
>
|
||||
<Field label="Scope"
|
||||
><Select
|
||||
bind:value={scope}
|
||||
options={[
|
||||
{ value: 'server', label: 'One server' },
|
||||
{ value: 'game', label: 'This game' },
|
||||
...(admin ? [{ value: 'fleet', label: 'Entire fleet' }] : [])
|
||||
]}
|
||||
/></Field
|
||||
>
|
||||
</div>
|
||||
<Field
|
||||
label="Duration"
|
||||
hint={admin
|
||||
? undefined
|
||||
: 'Moderators can restrict a player for up to seven days.'}
|
||||
>
|
||||
<Select
|
||||
bind:value={duration}
|
||||
options={[
|
||||
{ value: '15', label: '15 minutes' },
|
||||
{ value: '60', label: '1 hour' },
|
||||
{ value: '1440', label: '1 day' },
|
||||
{ value: '10080', label: '7 days' },
|
||||
...(admin
|
||||
? [
|
||||
{ value: '43200', label: '30 days' },
|
||||
{ value: 'permanent', label: 'Permanent' }
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
{#if scope === 'fleet'}<p class="hint">
|
||||
Fleet restrictions require a verified player identity across games.
|
||||
</p>{/if}
|
||||
{/if}
|
||||
{#if needsServer}<Field label="Server" required
|
||||
><Select bind:value={server} options={serverOptions} required /></Field
|
||||
>{/if}
|
||||
{#if actionUnavailable}<p class="hint">
|
||||
Select a connected server with support for this action. Unavailable actions
|
||||
cannot be sent.
|
||||
</p>{/if}
|
||||
{#if mode === 'restriction'}<p class="hint">
|
||||
Restrictions are saved immediately. They take effect only on servers with the
|
||||
required capability and current policy; review enforcement coverage after
|
||||
saving.
|
||||
</p>{/if}
|
||||
{#if mode === 'case'}
|
||||
<Field label="Case title" required
|
||||
><Input bind:value={title} required maxlength={160} /></Field
|
||||
>
|
||||
{#if seed.matchId && seed.message}<div class="evidence">
|
||||
<strong>Selected chat evidence · Match #{seed.matchId}</strong>
|
||||
<p>{seed.message}</p>
|
||||
<small
|
||||
>The original archived message and its context will be preserved when
|
||||
the case is saved.</small
|
||||
>
|
||||
</div>{/if}
|
||||
{/if}
|
||||
{#if mode === 'resolve'}<Field label="Decision"
|
||||
><Select
|
||||
bind:value={resolution}
|
||||
options={[
|
||||
{ value: 'resolved', label: 'Resolved' },
|
||||
{ value: 'dismissed', label: 'Dismissed' }
|
||||
]}
|
||||
/></Field
|
||||
>{/if}
|
||||
{#if ['restriction', 'action', 'revoke', 'resolve'].includes(mode)}
|
||||
<Field
|
||||
label={mode === 'action' && ['message', 'announce'].includes(action)
|
||||
? 'Message'
|
||||
: mode === 'restriction' || mode === 'action'
|
||||
? 'Player-visible reason'
|
||||
: 'Review reason'}
|
||||
hint={gameText
|
||||
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
|
||||
: undefined}
|
||||
required
|
||||
><Textarea
|
||||
bind:value={reason}
|
||||
required
|
||||
rows={3}
|
||||
maxlength={gameText ? 240 : undefined}
|
||||
/></Field
|
||||
>
|
||||
{/if}
|
||||
{#if ['restriction', 'case', 'note', 'case-note'].includes(mode)}
|
||||
<Field label="Private staff note" required={mode === 'note' || mode === 'case-note'}
|
||||
><Textarea
|
||||
bind:value={note}
|
||||
required={mode === 'note' || mode === 'case-note'}
|
||||
rows={4}
|
||||
/></Field
|
||||
>
|
||||
{/if}
|
||||
</fieldset>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if uncertain}<p class="hint" role="status">
|
||||
The outcome is not yet known. Retry sends the same request safely; check History
|
||||
before starting a different action.
|
||||
</p>{/if}
|
||||
<div class="actions">
|
||||
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
||||
type="submit"
|
||||
disabled={!uncertain && actionUnavailable}
|
||||
variant={mode === 'restriction' || mode === 'revoke' ? 'danger' : 'primary'}
|
||||
loading={busy}>{uncertain ? 'Retry same request' : 'Save'}</Button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.moderation-form,
|
||||
fieldset {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.paired {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.hint,
|
||||
.evidence {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.evidence {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.evidence p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.evidence small {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.paired {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
maxlength?: number;
|
||||
mono?: boolean;
|
||||
oninput?: (e: Event) => void;
|
||||
ariaLabel?: string;
|
||||
@@ -25,6 +26,7 @@
|
||||
required = false,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
maxlength,
|
||||
mono = false,
|
||||
oninput,
|
||||
ariaLabel
|
||||
@@ -41,6 +43,7 @@
|
||||
{required}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{maxlength}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : field?.labelId}
|
||||
aria-describedby={field?.messageId}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ModerationGame } from '$lib/moderation.js';
|
||||
import { serverTitle } from './server-labels.js';
|
||||
|
||||
export function moderationHref(context: {
|
||||
game?: string;
|
||||
guid?: string;
|
||||
q?: string;
|
||||
server?: string;
|
||||
matchId?: number;
|
||||
message?: string;
|
||||
}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (context.game === 't5' || context.game === 't6') params.set('game', context.game);
|
||||
if (context.guid) params.set('guid', context.guid);
|
||||
if (context.q) params.set('q', context.q);
|
||||
if (context.server) params.set('server', context.server);
|
||||
if (context.matchId !== undefined && context.message) {
|
||||
params.set('matchId', String(context.matchId));
|
||||
params.set('message', context.message);
|
||||
}
|
||||
return `/moderation?${params}`;
|
||||
}
|
||||
|
||||
export function moderationGameName(game: ModerationGame): string {
|
||||
return game === 't5' ? 'Black Ops I' : 'Black Ops II';
|
||||
}
|
||||
|
||||
export const moderationActionLabels: Record<string, string> = {
|
||||
warn: 'Warning',
|
||||
message: 'Private message',
|
||||
kick: 'Kick',
|
||||
announce: 'Announcement',
|
||||
'create-sanction': 'Restriction created',
|
||||
'revoke-sanction': 'Restriction revoked',
|
||||
'create-case': 'Case opened',
|
||||
'case-note': 'Case note added',
|
||||
'resolve-case': 'Case reviewed',
|
||||
note: 'Private note',
|
||||
'restriction.created': 'Restriction created',
|
||||
'restriction.revoked': 'Restriction revoked',
|
||||
'action.queued': 'Live action requested',
|
||||
'action.confirmed': 'Live action confirmed',
|
||||
'action.failed': 'Live action failed',
|
||||
'action.expired': 'Live action expired',
|
||||
'action.unknown': 'Live action outcome unknown',
|
||||
'case.opened': 'Case opened',
|
||||
'case.note': 'Case note added',
|
||||
'case.resolved': 'Case resolved',
|
||||
'case.dismissed': 'Case dismissed',
|
||||
'player.note': 'Private player note',
|
||||
'runtime.configure': 'Integration configuration requested'
|
||||
};
|
||||
|
||||
export function moderationEventDetail(detail: string, action: string): string {
|
||||
if (!['restriction.created', 'action.queued'].includes(action)) return detail;
|
||||
try {
|
||||
const value: unknown = JSON.parse(detail);
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!('reason' in value) ||
|
||||
typeof value.reason !== 'string'
|
||||
)
|
||||
return detail;
|
||||
const record = value as Record<string, unknown>;
|
||||
const context = [
|
||||
typeof record.kind === 'string'
|
||||
? (moderationActionLabels[record.kind] ??
|
||||
(record.kind === 'ban' ? 'Ban' : record.kind === 'mute' ? 'Mute' : ''))
|
||||
: '',
|
||||
typeof record.server === 'string'
|
||||
? serverTitle(record.server)
|
||||
: record.scope === 'fleet'
|
||||
? 'Entire fleet'
|
||||
: record.scope === 'game'
|
||||
? 'Game scope'
|
||||
: ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return [
|
||||
context,
|
||||
record.reason,
|
||||
typeof record.note === 'string' && record.note ? `Private note: ${record.note}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
} catch {
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,21 @@ export const statusShapes: Record<StatusTone, StatusShape> = {
|
||||
};
|
||||
|
||||
const states: Record<string, [string, StatusTone]> = {
|
||||
'moderation-current': ['Policy current', 'online'],
|
||||
'moderation-pending': ['Policy pending', 'warning'],
|
||||
'moderation-offline': ['Offline', 'danger'],
|
||||
'moderation-unavailable': ['Unavailable', 'warning'],
|
||||
'moderation-active': ['Active', 'warning'],
|
||||
'moderation-revoked': ['Revoked', 'neutral'],
|
||||
'moderation-open': ['Open', 'info'],
|
||||
'moderation-resolved': ['Resolved', 'online'],
|
||||
'moderation-dismissed': ['Dismissed', 'neutral'],
|
||||
'moderation-queued': ['Queued', 'neutral'],
|
||||
'moderation-delivered': ['Delivered; awaiting confirmation', 'info'],
|
||||
'moderation-confirmed': ['Confirmed', 'online'],
|
||||
'moderation-failed': ['Failed', 'danger'],
|
||||
'moderation-expired': ['Expired', 'neutral'],
|
||||
'moderation-unknown': ['Outcome unknown', 'warning'],
|
||||
provisioning: ['Provisioning', 'info'],
|
||||
online: ['Online', 'online'],
|
||||
offline: ['Offline', 'danger'],
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
|
||||
import { moderationOverview, moderationQuery } from '$lib/server/moderation/overview.js';
|
||||
import { mutateModeration } from '$lib/server/moderation/actions.js';
|
||||
import { configureModerationServer } from '$lib/server/moderation/configure.js';
|
||||
import { moderationMutation, textField } from '$lib/server/moderation/core.js';
|
||||
|
||||
const headers = { 'Cache-Control': 'no-store' };
|
||||
export const GET: RequestHandler = ({ locals, url }) => {
|
||||
try {
|
||||
return json(
|
||||
moderationOverview(requireUser(locals.user).id, moderationQuery(url.searchParams)),
|
||||
{ headers }
|
||||
);
|
||||
} catch (cause) {
|
||||
return authError(cause);
|
||||
}
|
||||
};
|
||||
export const POST: RequestHandler = async ({ locals, request }) => {
|
||||
try {
|
||||
const actor = requireUser(locals.user);
|
||||
const body = await authBody(request, 16 * 1024);
|
||||
if (body.op === 'configure')
|
||||
return json(
|
||||
moderationMutation(actor.id, body, () =>
|
||||
configureModerationServer(actor.id, textField(body.server, 'Server', 255))
|
||||
),
|
||||
{ headers }
|
||||
);
|
||||
return json(mutateModeration(actor.id, body), { headers });
|
||||
} catch (cause) {
|
||||
return authError(cause);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { authBody, authError } from '$lib/server/auth/http.js';
|
||||
import { authenticateModerationRuntime } from '$lib/server/moderation/credentials.js';
|
||||
import { syncModerationRuntime } from '$lib/server/moderation/runtime.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const identity = authenticateModerationRuntime(request);
|
||||
if (!identity)
|
||||
return json(
|
||||
{ error: 'Unauthorized moderation runtime.' },
|
||||
{ status: 401, headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
try {
|
||||
return json(syncModerationRuntime(identity, await authBody(request, 256 * 1024)), {
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
});
|
||||
} catch (cause) {
|
||||
return authError(cause);
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,8 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { canReadModeration } from '$lib/auth.js';
|
||||
import { moderationHref } from '$ui/moderation.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import {
|
||||
leaderboardMetrics,
|
||||
@@ -151,7 +153,17 @@
|
||||
fallback={data.returnTo}
|
||||
items={[{ label: 'Leaderboard', href: '/leaderboard' }, { label: profile.name }]}
|
||||
/>{/snippet}
|
||||
{#snippet actions()}<Freshness
|
||||
{#snippet actions()}{#if canReadModeration($page.data?.user?.role)}<Button
|
||||
href={moderationHref({
|
||||
guid: profile.guid,
|
||||
game:
|
||||
scope.game ||
|
||||
(profile.maps.length &&
|
||||
profile.maps.every((map) => map.game === profile.maps[0].game)
|
||||
? profile.maps[0].game
|
||||
: undefined)
|
||||
})}>Moderation</Button
|
||||
>{/if}<Freshness
|
||||
{updatedAt}
|
||||
error={refreshError}
|
||||
intervalLabel="every 30 s"
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<Card padding="lg"><ChatPanel history={match.chat} /></Card>
|
||||
<Card padding="lg"><ChatPanel history={match.chat} game={match.game} /></Card>
|
||||
|
||||
<details class="note">
|
||||
<summary>About these results</summary>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireUser } from '$lib/server/auth/http.js';
|
||||
import { moderationOverview, moderationQuery } from '$lib/server/moderation/overview.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ locals, url }) => ({
|
||||
moderation: moderationOverview(requireUser(locals.user).id, moderationQuery(url.searchParams))
|
||||
});
|
||||
@@ -0,0 +1,821 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import { page, navigating } from '$app/stores';
|
||||
import { canModerate } from '$lib/auth.js';
|
||||
import {
|
||||
canRevokeModerationRestriction,
|
||||
type ModerationOverview,
|
||||
type ModerationQuery,
|
||||
type ModerationGame
|
||||
} from '$lib/moderation.js';
|
||||
import { playerProfileHref } from '$lib/leaderboard.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import {
|
||||
moderationActionLabels,
|
||||
moderationGameName,
|
||||
moderationEventDetail
|
||||
} from '$ui/moderation.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import Freshness from '$ui/components/Freshness.svelte';
|
||||
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte';
|
||||
|
||||
let { data }: { data: { moderation: ModerationOverview } } = $props();
|
||||
let snapshot = $state<ModerationOverview | null>(null);
|
||||
const report = $derived(snapshot ?? data.moderation);
|
||||
const permitted = $derived(canModerate($page.data?.user?.role));
|
||||
const admin = $derived($page.data?.user?.role === 'admin');
|
||||
const busy = $derived(!!$navigating);
|
||||
let filters = $state<ModerationQuery>(untrack(() => ({ ...data.moderation.query })));
|
||||
let tabView = $state<string>(untrack(() => data.moderation.query.view));
|
||||
let refreshing = $state(false);
|
||||
let refreshError = $state('');
|
||||
let navigationError = $state('');
|
||||
let notice = $state('');
|
||||
let configuring = $state('');
|
||||
const configureKeys = new Map<string, string>();
|
||||
let updatedAt = $state<number | null>(null);
|
||||
let controller: AbortController | undefined;
|
||||
let generation = 0;
|
||||
let dialogOpen = $state(false);
|
||||
let dialogKey = $state(0);
|
||||
let mode = $state<
|
||||
'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve'
|
||||
>('restriction');
|
||||
let seed = $state<{
|
||||
game?: ModerationGame;
|
||||
guid?: string;
|
||||
server?: string;
|
||||
id?: string;
|
||||
revision?: number;
|
||||
matchId?: number;
|
||||
message?: string;
|
||||
}>({});
|
||||
const tabs = [
|
||||
{ id: 'players', label: 'Players' },
|
||||
{ id: 'restrictions', label: 'Restrictions' },
|
||||
{ id: 'cases', label: 'Cases' },
|
||||
{ id: 'history', label: 'History' }
|
||||
];
|
||||
const currentCoverage = $derived(
|
||||
report.coverage.filter((item) => item.state === 'current').length
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const initial = data.moderation;
|
||||
untrack(() => {
|
||||
snapshot = null;
|
||||
filters = { ...initial.query };
|
||||
tabView = initial.query.view;
|
||||
refreshError = '';
|
||||
});
|
||||
});
|
||||
function cancelRefresh() {
|
||||
generation++;
|
||||
controller?.abort();
|
||||
refreshing = false;
|
||||
}
|
||||
beforeNavigate(cancelRefresh);
|
||||
async function refresh(signal?: AbortSignal) {
|
||||
if (busy || refreshing) return;
|
||||
const current = ++generation;
|
||||
const request = new AbortController();
|
||||
controller = request;
|
||||
const abort = () => request.abort();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) request.abort();
|
||||
const timeout = setTimeout(abort, 30_000);
|
||||
refreshing = true;
|
||||
try {
|
||||
const response = await fetch(`/api/moderation${$page.url.search}`, {
|
||||
signal: request.signal
|
||||
});
|
||||
if (!response.ok) throw new Error('Refresh failed');
|
||||
const next: ModerationOverview = await response.json();
|
||||
if (current !== generation || request.signal.aborted) return;
|
||||
snapshot = next;
|
||||
updatedAt = Date.now();
|
||||
refreshError = '';
|
||||
} catch {
|
||||
if (current === generation)
|
||||
refreshError = 'Moderation updates paused. Showing the last loaded records.';
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (current === generation) refreshing = false;
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
updatedAt = Date.now();
|
||||
const stop = pollVisible(refresh, 15_000, false);
|
||||
return () => {
|
||||
stop();
|
||||
cancelRefresh();
|
||||
};
|
||||
});
|
||||
async function navigate(changes: Record<string, string | number | null>, refreshData = false) {
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value === null || value === '') params.delete(key);
|
||||
else params.set(key, String(value));
|
||||
}
|
||||
navigationError = '';
|
||||
try {
|
||||
await goto(`/moderation?${params}`, {
|
||||
keepFocus: true,
|
||||
noScroll: true,
|
||||
invalidateAll: refreshData
|
||||
});
|
||||
} catch {
|
||||
tabView = report.query.view;
|
||||
navigationError = 'Could not load this moderation view. Please try again.';
|
||||
}
|
||||
}
|
||||
function openDialog(next: typeof mode, target: typeof seed = {}) {
|
||||
mode = next;
|
||||
const matchId = Number($page.url.searchParams.get('matchId'));
|
||||
seed = {
|
||||
...(report.query.game ? { game: report.query.game } : {}),
|
||||
guid: report.query.guid,
|
||||
server: $page.url.searchParams.get('server') ?? '',
|
||||
...(next === 'case' && matchId > 0 && $page.url.searchParams.get('message')
|
||||
? { matchId, message: $page.url.searchParams.get('message')! }
|
||||
: {}),
|
||||
...target
|
||||
};
|
||||
dialogKey++;
|
||||
dialogOpen = true;
|
||||
}
|
||||
function submitted(target: { game?: ModerationGame; guid?: string | null }) {
|
||||
notice = 'Saved. Enforcement and delivery status are shown below.';
|
||||
cancelRefresh();
|
||||
const view =
|
||||
mode === 'action' || mode === 'note'
|
||||
? 'history'
|
||||
: mode === 'restriction' || mode === 'revoke'
|
||||
? 'restrictions'
|
||||
: 'cases';
|
||||
void navigate(
|
||||
{
|
||||
view,
|
||||
offset: null,
|
||||
q: null,
|
||||
matchId: null,
|
||||
message: null,
|
||||
...(target.game ? { game: target.game } : {}),
|
||||
...(target.guid !== undefined ? { guid: target.guid } : {})
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
async function configure(server: string) {
|
||||
if (configuring) return;
|
||||
if (
|
||||
!(await confirmAction({
|
||||
title: 'Enable moderation integration',
|
||||
message: `Enable moderation on ${server}? Install the matching moderation scripts and plugins first. Capabilities appear only after the game connects successfully.`,
|
||||
confirmLabel: 'Enable integration'
|
||||
}))
|
||||
)
|
||||
return;
|
||||
configuring = server;
|
||||
navigationError = '';
|
||||
const idempotencyKey = configureKeys.get(server) ?? crypto.randomUUID();
|
||||
configureKeys.set(server, idempotencyKey);
|
||||
try {
|
||||
const response = await fetch('/api/moderation', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ op: 'configure', server, idempotencyKey })
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok || !result?.ok)
|
||||
throw new Error(
|
||||
result?.error ||
|
||||
'Could not confirm the integration request. Retry uses the same request.'
|
||||
);
|
||||
configureKeys.delete(server);
|
||||
notice = 'Integration configuration queued. Coverage updates when the game connects.';
|
||||
cancelRefresh();
|
||||
void refresh();
|
||||
} catch (cause) {
|
||||
navigationError =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: 'Could not confirm the integration request.';
|
||||
} finally {
|
||||
configuring = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Moderation · Electric Cherry</title></svelte:head>
|
||||
|
||||
<div class="moderation-page">
|
||||
<SectionHeader
|
||||
title="Moderation"
|
||||
subtitle="Player restrictions, staff cases, and enforcement across your servers."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Freshness
|
||||
{updatedAt}
|
||||
intervalLabel="every 15 s"
|
||||
error={refreshError ? 'Updates paused' : ''}
|
||||
/>
|
||||
<Button onclick={() => refresh()} loading={refreshing}>Refresh</Button>
|
||||
{#if permitted}<Button variant="primary" onclick={() => openDialog('restriction')}
|
||||
>Restrict player</Button
|
||||
><Button onclick={() => openDialog('action')}>Live action</Button>{/if}
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
{#if !permitted}<p class="notice">
|
||||
Read-only moderation access. You can inspect private records and enforcement status.
|
||||
</p>{/if}
|
||||
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
|
||||
{#if refreshError}<div class="ui-error" role="alert">
|
||||
{refreshError}<Button size="sm" onclick={() => refresh()} loading={refreshing}
|
||||
>Retry</Button
|
||||
>
|
||||
</div>{/if}
|
||||
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
|
||||
|
||||
<details class="coverage">
|
||||
<summary
|
||||
>Enforcement coverage · {currentCoverage} of {report.coverage.length} servers current</summary
|
||||
>
|
||||
<p class="hint">
|
||||
Current means the server acknowledged the latest policy. Pending or unavailable servers
|
||||
may not apply a new restriction or revocation yet. Cached restrictions continue during
|
||||
an outage.
|
||||
</p>
|
||||
{#if report.coverage.length}
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Server</th><th>Policy status</th><th>Revision</th><th
|
||||
>Last connected</th
|
||||
><th>Capabilities</th>{#if admin}<th>Setup</th>{/if}</tr
|
||||
></thead
|
||||
><tbody>
|
||||
{#each report.coverage as item (item.server)}<tr>
|
||||
<th
|
||||
><a href={`/servers/${encodeURIComponent(item.server)}`}
|
||||
><ServerIdentity name={item.server} /></a
|
||||
></th
|
||||
>
|
||||
<td><StatusBadge state={`moderation-${item.state}`} /></td>
|
||||
<td>{item.revision ?? '—'} / {item.desiredRevision}</td>
|
||||
<td
|
||||
>{#if item.connectedAt}<RelativeTime
|
||||
value={item.connectedAt}
|
||||
/>{:else}Not recorded{/if}</td
|
||||
>
|
||||
<td
|
||||
>{item.capabilities.length
|
||||
? item.capabilities.join(', ')
|
||||
: 'Not reported'}</td
|
||||
>
|
||||
{#if admin}<td
|
||||
>{#if item.state === 'unavailable'}<Button
|
||||
size="sm"
|
||||
disabled={!!configuring}
|
||||
loading={configuring === item.server}
|
||||
onclick={() => configure(item.server)}
|
||||
>Enable integration</Button
|
||||
>{/if}</td
|
||||
>{/if}
|
||||
</tr>{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}<p class="hint">No server enforcement reports are available yet.</p>{/if}
|
||||
</details>
|
||||
|
||||
<Tabs
|
||||
{tabs}
|
||||
bind:value={tabView}
|
||||
onchange={(view) => {
|
||||
void navigate({ view, offset: null });
|
||||
}}
|
||||
ariaLabel="Moderation views"
|
||||
panelId="moderation-results"
|
||||
idPrefix="moderation-tab"
|
||||
/>
|
||||
<form
|
||||
class="filters"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate({ q: filters.q, game: filters.game, guid: filters.guid, offset: null });
|
||||
}}
|
||||
>
|
||||
<Field label="Search moderation" class="search"
|
||||
><Input
|
||||
name="q"
|
||||
type="search"
|
||||
bind:value={filters.q}
|
||||
placeholder="Name, alias, GUID, or reason"
|
||||
/></Field
|
||||
>
|
||||
<Field label="Game"
|
||||
><Select
|
||||
name="game"
|
||||
bind:value={filters.game}
|
||||
options={[
|
||||
{ value: '', label: 'All games' },
|
||||
{ value: 't5', label: 'Black Ops I' },
|
||||
{ value: 't6', label: 'Black Ops II' }
|
||||
]}
|
||||
/></Field
|
||||
>
|
||||
<Field label="Player GUID"><Input name="guid" bind:value={filters.guid} mono /></Field>
|
||||
<Button type="submit" disabled={busy}>Apply</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onclick={() =>
|
||||
navigate({
|
||||
q: null,
|
||||
game: null,
|
||||
guid: null,
|
||||
offset: null,
|
||||
server: null,
|
||||
message: null,
|
||||
matchId: null
|
||||
})}>Reset</Button
|
||||
>
|
||||
</form>
|
||||
{#if $page.url.searchParams.get('matchId') && $page.url.searchParams.get('message')}<p
|
||||
class="notice"
|
||||
>
|
||||
A chat message is selected as case evidence. Review a player with a verified GUID, then
|
||||
choose Open case. The original message must match that player in the archive.
|
||||
</p>{/if}
|
||||
{#if report.query.guid}
|
||||
<section class="selected-player" aria-label="Selected player">
|
||||
<div>
|
||||
<strong>Selected player</strong><CopyValue
|
||||
value={report.query.guid}
|
||||
label="Player GUID"
|
||||
/><a href={contextHref(playerProfileHref(report.query.guid), $page.url)}
|
||||
>View player profile</a
|
||||
>
|
||||
</div>
|
||||
{#if permitted}<div class="row-actions">
|
||||
<Button size="sm" onclick={() => openDialog('case')}>Open case</Button><Button
|
||||
size="sm"
|
||||
onclick={() => openDialog('note')}>Add private note</Button
|
||||
>
|
||||
</div>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
id="moderation-results"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`moderation-tab-${report.query.view}`}
|
||||
aria-busy={busy}
|
||||
>
|
||||
{#if report.query.view === 'players'}
|
||||
{#if report.players.length}<div class="table-scroll">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Player</th><th>Aliases</th><th>Last seen</th><th>Servers</th
|
||||
><th>Restrictions</th><th>Actions</th></tr
|
||||
></thead
|
||||
><tbody>
|
||||
{#each report.players as player (`${player.game}:${player.guid}`)}<tr>
|
||||
<th
|
||||
><a
|
||||
href={contextHref(
|
||||
playerProfileHref(player.guid),
|
||||
$page.url
|
||||
)}>{player.name}</a
|
||||
><small>{moderationGameName(player.game)}</small><CopyValue
|
||||
value={player.guid}
|
||||
label="Player GUID"
|
||||
/></th
|
||||
>
|
||||
<td>{player.aliases.join(', ') || 'None recorded'}</td>
|
||||
<td
|
||||
><RelativeTime
|
||||
value={player.lastSeen}
|
||||
missing="Not recorded"
|
||||
/></td
|
||||
>
|
||||
<td
|
||||
>{#each player.servers as server}<div>
|
||||
<ServerIdentity name={server} />
|
||||
</div>{:else}Not currently observed{/each}</td
|
||||
>
|
||||
<td>{player.restrictions}</td>
|
||||
<td
|
||||
><div class="row-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
navigate({
|
||||
game: player.game,
|
||||
guid: player.guid,
|
||||
q: null,
|
||||
offset: null,
|
||||
server: null,
|
||||
message:
|
||||
$page.url.searchParams.get('message'),
|
||||
matchId:
|
||||
$page.url.searchParams.get('matchId')
|
||||
})}>Review player</Button
|
||||
>{#if permitted}<Button
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
openDialog('restriction', {
|
||||
game: player.game,
|
||||
guid: player.guid,
|
||||
server: player.servers[0] ?? ''
|
||||
})}>Restrict</Button
|
||||
>{/if}
|
||||
</div></td
|
||||
>
|
||||
</tr>{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>{:else}<EmptyState
|
||||
title="No players found"
|
||||
description="Search another name or GUID. You can restrict a recorded identity even when the player is offline."
|
||||
/>{/if}
|
||||
{:else if report.query.view === 'restrictions'}
|
||||
{#if report.sanctions.length}<div class="record-list">
|
||||
{#each report.sanctions as sanction (sanction.id)}<article class="record">
|
||||
<div class="record-heading">
|
||||
<h2>
|
||||
{sanction.kind === 'ban' ? 'Ban' : 'Mute'} ·
|
||||
<a
|
||||
href={contextHref(
|
||||
playerProfileHref(sanction.guid),
|
||||
$page.url
|
||||
)}>{sanction.guid}</a
|
||||
>
|
||||
</h2>
|
||||
<StatusBadge
|
||||
state={sanction.revokedAt
|
||||
? 'moderation-revoked'
|
||||
: sanction.active
|
||||
? 'moderation-active'
|
||||
: 'expired'}
|
||||
/>
|
||||
</div>
|
||||
<p>{sanction.reason}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Scope</dt>
|
||||
<dd>
|
||||
{#if sanction.server}<ServerIdentity
|
||||
name={sanction.server}
|
||||
/>{:else}{sanction.scope === 'fleet'
|
||||
? 'Entire fleet'
|
||||
: moderationGameName(sanction.game)}{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Expires</dt>
|
||||
<dd>
|
||||
{sanction.expiresAt
|
||||
? $timeFormat.dateTime(sanction.expiresAt)
|
||||
: 'Permanent'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Created by</dt>
|
||||
<dd>
|
||||
{sanction.actor} · <RelativeTime
|
||||
value={sanction.createdAt}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if sanction.note}<p class="private-note">
|
||||
<strong>Private note</strong>
|
||||
{sanction.note}
|
||||
</p>{/if}
|
||||
<div class="record-heading">
|
||||
<CopyValue
|
||||
value={sanction.id}
|
||||
label="Restriction ID"
|
||||
/>{#if sanction.active && canRevokeModerationRestriction($page.data?.user, sanction)}<Button
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
openDialog('revoke', {
|
||||
id: sanction.id,
|
||||
revision: sanction.revision
|
||||
})}>Revoke restriction</Button
|
||||
>{/if}
|
||||
</div>
|
||||
</article>{/each}
|
||||
</div>{:else}<EmptyState
|
||||
title="No restrictions found"
|
||||
description="Restrictions matching these filters will appear here, including expiry and revocation history."
|
||||
/>{/if}
|
||||
{:else if report.query.view === 'cases'}
|
||||
{#if permitted}<div class="view-actions">
|
||||
<Button onclick={() => openDialog('case')}>Open case</Button>
|
||||
</div>{/if}
|
||||
{#if report.cases.length}<div class="record-list">
|
||||
{#each report.cases as item (item.id)}<article class="record">
|
||||
<div class="record-heading">
|
||||
<h2>{item.title}</h2>
|
||||
<StatusBadge state={`moderation-${item.status}`} />
|
||||
</div>
|
||||
<p>
|
||||
<a href={contextHref(playerProfileHref(item.guid), $page.url)}
|
||||
>{item.guid}</a
|
||||
>
|
||||
· {moderationGameName(item.game)} · Opened by {item.actor} · <RelativeTime
|
||||
value={item.createdAt}
|
||||
/>
|
||||
</p>
|
||||
<details>
|
||||
<summary>Evidence and staff notes ({item.evidence.length})</summary>
|
||||
<ol class="evidence-list">
|
||||
{#each item.evidence as evidence (evidence.id)}<li>
|
||||
<p>{evidence.text}</p>
|
||||
<small
|
||||
>{evidence.source} · {evidence.actor} · <RelativeTime
|
||||
value={evidence.createdAt}
|
||||
/></small
|
||||
>
|
||||
</li>{:else}<li>No evidence or notes recorded.</li>{/each}
|
||||
</ol>
|
||||
</details>
|
||||
<div class="record-heading">
|
||||
<CopyValue value={item.id} label="Case ID" />{#if permitted}<div
|
||||
class="row-actions"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => openDialog('case-note', { id: item.id })}
|
||||
>Add case note</Button
|
||||
>{#if item.status === 'open'}<Button
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
openDialog('resolve', {
|
||||
id: item.id,
|
||||
revision: item.revision
|
||||
})}>Review case</Button
|
||||
>{/if}
|
||||
</div>{/if}
|
||||
</div>
|
||||
</article>{/each}
|
||||
</div>{:else}<EmptyState
|
||||
title="No cases found"
|
||||
description="Open a case to preserve evidence, record an appeal, and document a staff decision."
|
||||
/>{/if}
|
||||
{:else}
|
||||
<h2>Recent live actions</h2>
|
||||
<p class="hint">
|
||||
Queued and delivered actions are awaiting confirmation. An unknown outcome requires
|
||||
review before issuing another action.
|
||||
</p>
|
||||
{#if report.actions.length}<div class="table-scroll">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Action</th><th>Target</th><th>Result</th><th
|
||||
>Reason / message</th
|
||||
><th>Moderator</th><th>Requested</th></tr
|
||||
></thead
|
||||
><tbody>
|
||||
{#each report.actions as action (action.id)}<tr
|
||||
><th>{moderationActionLabels[action.kind]}</th><td
|
||||
>{action.guid ?? 'All players'}
|
||||
<div><ServerIdentity name={action.server} /></div></td
|
||||
><td
|
||||
><StatusBadge
|
||||
state={`moderation-${action.state}`}
|
||||
/>{#if action.detail}<small>{action.detail}</small>{/if}</td
|
||||
><td>{action.reason}</td><td>{action.actor}</td><td
|
||||
><RelativeTime value={action.createdAt} /></td
|
||||
></tr
|
||||
>{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>{:else}<p class="hint">No recent live actions match these filters.</p>{/if}
|
||||
<h2>Audit history</h2>
|
||||
{#if report.events.length}<ol class="record-list audit-list">
|
||||
{#each report.events as event (event.id)}<li class="record">
|
||||
<div class="record-heading">
|
||||
<strong
|
||||
>{moderationActionLabels[event.action] ?? event.action}</strong
|
||||
><RelativeTime value={event.createdAt} />
|
||||
</div>
|
||||
<p>{moderationEventDetail(event.detail, event.action)}</p>
|
||||
<small
|
||||
>{event.actor}{event.guid
|
||||
? ` · Player ${event.guid}`
|
||||
: ''}{event.game
|
||||
? ` · ${moderationGameName(event.game)}`
|
||||
: ''}</small
|
||||
>{#if event.subjectId}<CopyValue
|
||||
value={event.subjectId}
|
||||
label="Record ID"
|
||||
/>{/if}
|
||||
</li>{/each}
|
||||
</ol>{:else}<EmptyState
|
||||
title="No history found"
|
||||
description="Moderation decisions and private notes matching these filters will appear here."
|
||||
/>{/if}
|
||||
{/if}
|
||||
<nav class="pagination" aria-label="Moderation results pagination">
|
||||
<span role="status"
|
||||
>{report.total ? report.query.offset + 1 : 0}–{Math.min(
|
||||
report.query.offset + report.limit,
|
||||
report.total
|
||||
)} of {report.total.toLocaleString()} results</span
|
||||
>
|
||||
{#if report.total > report.limit || report.query.offset > 0}<div class="row-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy || report.query.offset === 0}
|
||||
onclick={() =>
|
||||
navigate({ offset: Math.max(0, report.query.offset - report.limit) })}
|
||||
>Previous</Button
|
||||
><Button
|
||||
size="sm"
|
||||
disabled={busy || report.query.offset + report.limit >= report.total}
|
||||
onclick={() => navigate({ offset: report.query.offset + report.limit })}
|
||||
>Next</Button
|
||||
>
|
||||
</div>{/if}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if permitted}{#key dialogKey}<ModerationDialog
|
||||
bind:open={dialogOpen}
|
||||
{mode}
|
||||
{seed}
|
||||
{admin}
|
||||
coverage={report.coverage}
|
||||
onsubmitted={submitted}
|
||||
/>{/key}{/if}
|
||||
|
||||
<style>
|
||||
.moderation-page,
|
||||
.record-list,
|
||||
.record {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.filters,
|
||||
.row-actions,
|
||||
.record-heading,
|
||||
.selected-player,
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.filters {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.filters :global(.search) {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
.record-heading,
|
||||
.selected-player,
|
||||
.pagination {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.coverage,
|
||||
.selected-player,
|
||||
.record {
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
.record {
|
||||
gap: var(--space-3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
vertical-align: top;
|
||||
}
|
||||
thead th {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
tbody th {
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
small,
|
||||
.hint,
|
||||
.notice,
|
||||
.pagination {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
td small,
|
||||
th small {
|
||||
display: block;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: var(--leading-body);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.hint {
|
||||
margin-block: var(--space-3);
|
||||
}
|
||||
.notice {
|
||||
padding: var(--space-3);
|
||||
border-left: 3px solid var(--border-strong);
|
||||
}
|
||||
dl {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-6);
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
dt {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.private-note {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.evidence-list {
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
.evidence-list li {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.evidence-list small {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.audit-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.view-actions {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.pagination {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
#moderation-results > h2 {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.filters {
|
||||
align-items: stretch;
|
||||
}
|
||||
.filters :global(.field) {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user