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

185 lines
7.7 KiB
TypeScript

const modulename = 'WebServer:PlayersTableSearch';
import { PlayersTablePlayerType, PlayersTableSearchResp } from '@shared/playerApiTypes';
import { DatabasePlayerType } from '@modules/Database/databaseTypes';
import consoleFactory from '@lib/console';
import { AuthedCtx } from '@modules/WebServer/ctxTypes';
import cleanPlayerName from '@shared/cleanPlayerName';
import { chain as createChain } from 'lodash-es';
import Fuse from 'fuse.js';
import { parseLaxIdsArrayInput } from '@lib/player/idUtils';
import { TimeCounter } from '@modules/Metrics/statsUtils';
const console = consoleFactory(modulename);
//Helpers
const DEFAULT_LIMIT = 100; //cant override it for now
const ALLOWED_SORTINGS = ['playTime', 'tsJoined', 'tsLastConnection'];
const SIMPLE_FILTERS = ['isAdmin', 'isOnline', 'isWhitelisted', 'hasNote'];
//'isBanned', 'hasPreviousBan'
/**
* Returns the players stats for the Players page table
*/
export default async function PlayerSearch(ctx: AuthedCtx) {
//Sanity check
if (typeof ctx.query === 'undefined') {
return ctx.utils.error(400, 'Invalid Request');
}
const {
searchValue,
searchType,
filters,
sortingKey,
sortingDesc,
offsetParam,
offsetLicense
} = ctx.query;
const sendTypedResp = (data: PlayersTableSearchResp) => ctx.send(data);
const searchTime = new TimeCounter();
const adminsIdentifiers = txCore.adminStore.getAdminsIdentifiers();
const onlinePlayersLicenses = txCore.fxPlayerlist.getOnlinePlayersLicenses();
const dbo = txCore.database.getDboRef();
let chain = dbo.chain.get('players').clone(); //shallow clone to avoid sorting the original
/*
In order:
- [X] sort the players by the sortingKey/sortingDesc
- [x] filter the players by the simple filters (lightweight)
- [x] offset the players by the offsetParam/offsetLicense
- [x] filter the players by the searchValue/searchType (VERY HEAVY!)
- [ ] reduce actions table to get info on currently filtered players
- [ ] filter players by isBanned & hasPreviousBan
- [x] filter players by the limit
- [x] process the result and (no {isBanned, warnCount, banCount} for now)
- [x] return the result
*/
//sort the players 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];
});
//filter the players by the simple filters (lightweight)
if (typeof filters === 'string' && filters.length) {
const validRequestedFilters = new Set(filters.split(',').filter((x) => SIMPLE_FILTERS.includes(x)));
if (validRequestedFilters.size) {
const playerFilterFunctions = {
'isAdmin': (p: DatabasePlayerType) => p.ids.some((id) => adminsIdentifiers.includes(id)),
'isOnline': (p: DatabasePlayerType) => onlinePlayersLicenses.has(p.license),
'isWhitelisted': (p: DatabasePlayerType) => p.tsWhitelisted,
'hasNote': (p: DatabasePlayerType) => p.notes,
};
chain = chain.filter((p) => {
for (const filterName of validRequestedFilters) {
if (!playerFilterFunctions[filterName as keyof typeof playerFilterFunctions](p)) {
return false;
}
}
return true;
});
}
}
//offset the players by the offsetParam/offsetLicense
if (offsetParam !== undefined && offsetLicense !== undefined) {
const parsedOffsetParam = parseInt(offsetParam as string);
if (isNaN(parsedOffsetParam) || typeof offsetLicense !== 'string' || !offsetLicense.length) {
return sendTypedResp({ error: 'Invalid offsetParam or offsetLicense' });
}
chain = chain.takeRightWhile((p) => {
return p.license !== offsetLicense && parsedSortingDesc
? p[sortingKey as keyof DatabasePlayerType] as number <= parsedOffsetParam
: p[sortingKey as keyof DatabasePlayerType] as number >= parsedOffsetParam
});
}
// filter the players by the searchValue/searchType (VERY HEAVY!)
if (typeof searchType === 'string') {
if (typeof searchValue !== 'string' || !searchValue.length) {
return sendTypedResp({ error: 'Invalid searchValue' });
}
if (searchType === 'playerName') {
//Searching by player name
const { pureName } = cleanPlayerName(searchValue);
if (pureName === 'emptyname') {
return sendTypedResp({ error: 'This player name is unsearchable (pureName is empty).' });
}
const players = chain.value();
const fuse = new Fuse(players, {
isCaseSensitive: true, //maybe that's an optimization?!
keys: ['pureName'],
threshold: 0.3
});
const filtered = fuse.search(pureName).map(x => x.item);
chain = createChain(filtered);
} else if (searchType === 'playerNotes') {
//Searching by player notes
const players = chain.value();
const fuse = new Fuse(players, {
keys: ['notes.text'],
threshold: 0.3
});
const filtered = fuse.search(searchValue).map(x => x.item);
chain = createChain(filtered);
} else if (searchType === 'playerIds') {
//Searching by player 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((p) => {
if (validIds.length && !validIds.some((id) => p.ids.includes(id))) {
return false;
}
if (validHwids.length && !validHwids.some((hwid) => p.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 players = chain.value();
const hasReachedEnd = players.length <= DEFAULT_LIMIT;
const processedPlayers: PlayersTablePlayerType[] = players.slice(0, DEFAULT_LIMIT).map((p) => {
return {
license: p.license,
displayName: p.displayName,
playTime: p.playTime,
tsJoined: p.tsJoined,
tsLastConnection: p.tsLastConnection,
notes: p.notes ? p.notes.text : undefined,
isAdmin: p.ids.some((id) => adminsIdentifiers.includes(id)),
isOnline: onlinePlayersLicenses.has(p.license),
isWhitelisted: p.tsWhitelisted ? true : false,
// isBanned: boolean,
// warnCount: number,
// banCount: number,
};
});
txCore.metrics.txRuntime.playersTableSearchTime.count(searchTime.stop().milliseconds);
return sendTypedResp({
players: processedPlayers,
hasReachedEnd,
});
};