Files
electric-cherry/src/lib/server/moderation/overview.ts
T

242 lines
11 KiB
TypeScript

import { db } from '../db/index.js';
import { currentCaseAssessment } from './assessments.js';
import { moderationActor, moderationNow, policyRevision } from './core.js';
import { runtimeFreshMs } from './actions.js';
import { refreshActionStates } from './runtime.js';
import {
moderationCaseStatuses,
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);
const caseStatus = params.get('caseStatus') ?? '';
const priority = params.get('priority') ?? '';
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,
caseStatus: ['active', 'closed', ...moderationCaseStatuses].includes(caseStatus)
? (caseStatus as ModerationQuery['caseStatus'])
: '',
priority: ['low', 'normal', 'high', 'urgent'].includes(priority)
? (priority as ModerationQuery['priority'])
: '',
assignedTo: (params.get('assignedTo') ?? '').slice(0, 128)
};
}
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.view === 'cases') {
if (query.caseStatus === 'active' || query.caseStatus === 'closed') {
where.push(
`p.status ${query.caseStatus === 'closed' ? 'NOT ' : ''}IN ('open','in_review','escalated')`
);
} else if (query.caseStatus) {
where.push('p.status=?');
values.push(query.caseStatus);
}
if (query.priority) {
where.push('p.priority=?');
values.push(query.priority);
}
if (query.assignedTo === 'unassigned') where.push('p.assignee_id IS NULL');
else if (query.assignedTo) {
where.push('p.assignee_id=?');
values.push(query.assignedTo === 'me' ? actorId : query.assignedTo);
}
}
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.case_id AS caseId',
'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.priority,p.assignee_id AS assigneeId,(SELECT username FROM auth_users WHERE id=p.assignee_id) AS assignee,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision',
"CASE WHEN p.status IN ('open','in_review','escalated') THEN 0 ELSE 1 END,CASE p.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,p.updated_at DESC,p.id"
).map((c) => ({
...c,
assessment: currentCaseAssessment(c.id),
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;
})();
}