242 lines
11 KiB
TypeScript
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;
|
|
})();
|
|
}
|