monitor/core/routes/history/search.ts
2025-04-16 22:30:27 +07:00

168 lines
6.7 KiB
TypeScript

const modulename = 'WebServer:HistorySearch';
import { DatabaseActionType } from '@modules/Database/databaseTypes';
import consoleFactory from '@lib/console';
import { AuthedCtx } from '@modules/WebServer/ctxTypes';
import { chain as createChain } from 'lodash-es';
import Fuse from 'fuse.js';
import { now } from '@lib/misc';
import { parseLaxIdsArrayInput } from '@lib/player/idUtils';
import { HistoryTableActionType, HistoryTableSearchResp } from '@shared/historyApiTypes';
import { TimeCounter } from '@modules/Metrics/statsUtils';
const console = consoleFactory(modulename);
//Helpers
const DEFAULT_LIMIT = 100; //cant override it for now
const ALLOWED_SORTINGS = ['timestamp'];
/**
* Returns the players stats for the Players page table
*/
export default async function HistorySearch(ctx: AuthedCtx) {
//Sanity check
if (typeof ctx.query === 'undefined') {
return ctx.utils.error(400, 'Invalid Request');
}
const {
searchValue,
searchType,
filterbyType,
filterbyAdmin,
sortingKey,
sortingDesc,
offsetParam,
offsetActionId
} = ctx.query;
const sendTypedResp = (data: HistoryTableSearchResp) => ctx.send(data);
const searchTime = new TimeCounter();
const dbo = txCore.database.getDboRef();
let chain = dbo.chain.get('actions').clone(); //shallow clone to avoid sorting the original
//sort the actions by the sortingKey/sortingDesc
const parsedSortingDesc = sortingDesc === 'true';
if (typeof sortingKey !== 'string' || !ALLOWED_SORTINGS.includes(sortingKey)) {
return sendTypedResp({ error: 'Invalid sorting key' });
}
chain = chain.sort((a, b) => {
// @ts-ignore
return parsedSortingDesc ? b[sortingKey] - a[sortingKey] : a[sortingKey] - b[sortingKey];
});
//offset the actions by the offsetParam/offsetActionId
if (offsetParam !== undefined && offsetActionId !== undefined) {
const parsedOffsetParam = parseInt(offsetParam as string);
if (isNaN(parsedOffsetParam) || typeof offsetActionId !== 'string' || !offsetActionId.length) {
return sendTypedResp({ error: 'Invalid offsetParam or offsetActionId' });
}
chain = chain.takeRightWhile((a) => {
return a.id !== offsetActionId && parsedSortingDesc
? a[sortingKey as keyof DatabaseActionType] as number <= parsedOffsetParam
: a[sortingKey as keyof DatabaseActionType] as number >= parsedOffsetParam
});
}
//filter the actions by the simple filters (lightweight)
const effectiveTypeFilter = typeof filterbyType === 'string' && filterbyType.length ? filterbyType : undefined;
const effectiveAdminFilter = typeof filterbyAdmin === 'string' && filterbyAdmin.length ? filterbyAdmin : undefined;
if (effectiveTypeFilter || effectiveAdminFilter) {
chain = chain.filter((a) => {
if (effectiveTypeFilter && a.type !== effectiveTypeFilter) {
return false;
}
if (effectiveAdminFilter && a.author !== effectiveAdminFilter) {
return false;
}
return true;
});
}
// filter the actions by the searchValue/searchType (VERY HEAVY!)
if (typeof searchType === 'string') {
if (typeof searchValue !== 'string' || !searchValue.length) {
return sendTypedResp({ error: 'Invalid searchValue' });
}
if (searchType === 'actionId') {
//Searching by action ID
const cleanId = searchValue.toUpperCase().trim();
if (!cleanId.length) {
return sendTypedResp({ error: 'This action ID is unsearchable (empty?).' });
}
const actions = chain.value();
const fuse = new Fuse(actions, {
isCaseSensitive: true, //maybe that's an optimization?!
keys: ['id'],
threshold: 0.3
});
const filtered = fuse.search(cleanId).map(x => x.item);
chain = createChain(filtered);
} else if (searchType === 'reason') {
//Searching by player notes
const actions = chain.value();
const fuse = new Fuse(actions, {
keys: ['reason'],
threshold: 0.3
});
const filtered = fuse.search(searchValue).map(x => x.item);
chain = createChain(filtered);
} else if (searchType === 'identifiers') {
//Searching by target identifiers
const { validIds, validHwids, invalids } = parseLaxIdsArrayInput(searchValue);
if (invalids.length) {
return sendTypedResp({ error: `Invalid identifiers (${invalids.join(',')}). Prefix any identifier with their type, like 'fivem:123456' instead of just '123456'.` });
}
if (!validIds.length && !validHwids.length) {
return sendTypedResp({ error: `No valid identifiers found.` });
}
chain = chain.filter((a) => {
if (validIds.length && !validIds.some((id) => a.ids.includes(id))) {
return false;
}
if (validHwids.length && 'hwids' in a && !validHwids.some((hwid) => a.hwids!.includes(hwid))) {
return false;
}
return true;
});
} else {
return sendTypedResp({ error: 'Unknown searchType' });
}
}
//filter players by the limit - taking 1 more to check if we reached the end
chain = chain.take(DEFAULT_LIMIT + 1);
const actions = chain.value();
const hasReachedEnd = actions.length <= DEFAULT_LIMIT;
const currTs = now();
const processedActions = actions.slice(0, DEFAULT_LIMIT).map((a) => {
let banExpiration, warnAcked;
if (a.type === 'ban') {
if (a.expiration === false) {
banExpiration = 'permanent' as const;
} else if (typeof a.expiration === 'number' && a.expiration < currTs) {
banExpiration = 'expired' as const;
} else {
banExpiration = 'active' as const;
}
} else if (a.type === 'warn') {
warnAcked = a.acked;
}
return {
id: a.id,
type: a.type,
playerName: a.playerName,
author: a.author,
reason: a.reason,
timestamp: a.timestamp,
isRevoked: !!a.revocation.timestamp,
banExpiration,
warnAcked,
} satisfies HistoryTableActionType;
});
txCore.metrics.txRuntime.historyTableSearchTime.count(searchTime.stop().milliseconds);
return sendTypedResp({
history: processedActions,
hasReachedEnd,
});
};