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

532 lines
20 KiB
TypeScript

const modulename = 'WebServer:PlayerCheckJoin';
import cleanPlayerName from '@shared/cleanPlayerName';
import { GenericApiErrorResp } from '@shared/genericApiTypes';
import { DatabaseActionBanType, DatabaseActionType, DatabaseWhitelistApprovalsType } from '@modules/Database/databaseTypes';
import { anyUndefined, now } from '@lib/misc';
import { filterPlayerHwids, parsePlayerIds, shortenId, summarizeIdsArray } from '@lib/player/idUtils';
import type { PlayerIdsObjectType } from "@shared/otherTypes";
import xssInstancer from '@lib/xss';
import playerResolver from '@lib/player/playerResolver';
import humanizeDuration, { Unit } from 'humanize-duration';
import consoleFactory from '@lib/console';
import { TimeCounter } from '@modules/Metrics/statsUtils';
import { InitializedCtx } from '@modules/WebServer/ctxTypes';
const console = consoleFactory(modulename);
const xss = xssInstancer();
//Helper
const htmlCodeTag = '<code style="background-color: hsl(202deg 40% 66% / 35%); padding: 2px 2px; border-radius: 4px;">';
const htmlCodeIdTag = '<code style="letter-spacing: 2px; background-color: #ff7f5059; padding: 2px 4px; border-radius: 6px;">';
const htmlGuildNameTag = '<strong style="color: cornflowerblue">';
const rejectMessageTemplate = (title: string, content: string) => {
content = content.replaceAll('<code>', htmlCodeTag);
content = content.replaceAll('<codeid>', htmlCodeIdTag).replaceAll('</codeid>', '</code>');
content = content.replaceAll('<guildname>', htmlGuildNameTag).replaceAll('</guildname>', '</strong>');
return `
<div style="
background-color: rgba(30, 30, 30, 0.5);
padding: 20px;
border: solid 2px var(--color-modal-border);
border-radius: var(--border-radius-normal);
margin-top: 25px;
position: relative;
">
<h2>${title}</h2>
<br>
<p style="font-size: 1.25rem; padding: 0px">
${content}
</p>
<img src="https://forum-cfx-re.akamaized.net/original/5X/c/3/8/e/c38e8346a39c6483385c0727bee5c2abc705156a.png" style="
position: absolute;
right: 15px;
bottom: 15px;
opacity: 25%;
">
</div>`.replaceAll(/[\r\n]/g, '');
}
const prepCustomMessage = (msg: string) => {
if (!msg) return '';
return '<br>' + msg.trim().replaceAll(/\n/g, '<br>');
}
//Resp Type
type AllowRespType = {
allow: true;
}
type DenyRespType = {
allow: false;
reason: string;
}
type PlayerCheckJoinApiRespType = AllowRespType | DenyRespType | GenericApiErrorResp;
/**
* Endpoint for checking a player join, which checks whitelist and bans.
*/
export default async function PlayerCheckJoin(ctx: InitializedCtx) {
const sendTypedResp = (data: PlayerCheckJoinApiRespType) => ctx.send(data);
//If checking not required at all
if (
!txConfig.banlist.enabled
&& txConfig.whitelist.mode === 'disabled'
) {
return sendTypedResp({ allow: true });
}
//Checking request
if (anyUndefined(
ctx.request.body,
ctx.request.body.playerName,
ctx.request.body.playerIds,
ctx.request.body.playerHwids,
)) {
return sendTypedResp({ error: 'Invalid request.' });
}
const { playerName, playerIds, playerHwids } = ctx.request.body;
//Validating body data
if (typeof playerName !== 'string') return sendTypedResp({ error: 'playerName should be an string.' });
if (!Array.isArray(playerIds)) return sendTypedResp({ error: 'playerIds should be an array.' });
const { validIdsArray, validIdsObject } = parsePlayerIds(playerIds);
if (validIdsArray.length < 1) return sendTypedResp({ error: 'Identifiers array must contain at least 1 valid identifier.' });
if (!Array.isArray(playerHwids)) return sendTypedResp({ error: 'playerHwids should be an array.' });
const { validHwidsArray } = filterPlayerHwids(playerHwids);
try {
// If ban checking enabled
if (txConfig.banlist.enabled) {
const checkTime = new TimeCounter();
const result = checkBan(validIdsArray, validIdsObject, validHwidsArray);
txCore.metrics.txRuntime.banCheckTime.count(checkTime.stop().milliseconds);
if (!result.allow) return sendTypedResp(result);
}
//Checking whitelist
if (txConfig.whitelist.mode === 'adminOnly') {
const checkTime = new TimeCounter();
const result = await checkAdminOnlyMode(validIdsArray, validIdsObject, playerName);
txCore.metrics.txRuntime.whitelistCheckTime.count(checkTime.stop().milliseconds);
if (!result.allow) return sendTypedResp(result);
} else if (txConfig.whitelist.mode === 'approvedLicense') {
const checkTime = new TimeCounter();
const result = await checkApprovedLicense(validIdsArray, validIdsObject, validHwidsArray, playerName);
txCore.metrics.txRuntime.whitelistCheckTime.count(checkTime.stop().milliseconds);
if (!result.allow) return sendTypedResp(result);
} else if (txConfig.whitelist.mode === 'discordMember') {
const checkTime = new TimeCounter();
const result = await checkDiscordMember(validIdsArray, validIdsObject, playerName);
txCore.metrics.txRuntime.whitelistCheckTime.count(checkTime.stop().milliseconds);
if (!result.allow) return sendTypedResp(result);
} else if (txConfig.whitelist.mode === 'discordRoles') {
const checkTime = new TimeCounter();
const result = await checkDiscordRoles(validIdsArray, validIdsObject, playerName);
txCore.metrics.txRuntime.whitelistCheckTime.count(checkTime.stop().milliseconds);
if (!result.allow) return sendTypedResp(result);
}
//If not blocked by ban/wl, allow join
// return sendTypedResp({ allow: false, reason: 'APPROVED, BUT TEMP BLOCKED (DEBUG)' });
return sendTypedResp({ allow: true });
} catch (error) {
const msg = `Failed to check ban/whitelist status: ${(error as Error).message}`;
console.error(msg);
console.verbose.dir(error);
return sendTypedResp({ error: msg });
}
};
/**
* Checks if the player is banned
*/
function checkBan(
validIdsArray: string[],
validIdsObject: PlayerIdsObjectType,
validHwidsArray: string[]
): AllowRespType | DenyRespType {
// Check active bans on matching identifiers
const ts = now();
const filter = (action: DatabaseActionType): action is DatabaseActionBanType => {
return (
action.type === 'ban'
&& (!action.expiration || action.expiration > ts)
&& (!action.revocation.timestamp)
);
};
const activeBans = txCore.database.actions.findMany(validIdsArray, validHwidsArray, filter);
if (activeBans.length) {
const ban = activeBans[0];
// Count matching IDs
const matchingIdsCount = ban.ids.filter(id => validIdsArray.includes(id)).length;
// Only ban if at least 2 IDs match
if (matchingIdsCount >= 2) {
//Translation keys
const textKeys = {
title_permanent: txCore.translator.t('ban_messages.reject.title_permanent'),
title_temporary: txCore.translator.t('ban_messages.reject.title_temporary'),
label_expiration: txCore.translator.t('ban_messages.reject.label_expiration'),
label_date: txCore.translator.t('ban_messages.reject.label_date'),
label_author: txCore.translator.t('ban_messages.reject.label_author'),
label_reason: txCore.translator.t('ban_messages.reject.label_reason'),
label_id: txCore.translator.t('ban_messages.reject.label_id'),
note_multiple_bans: txCore.translator.t('ban_messages.reject.note_multiple_bans'),
note_diff_license: txCore.translator.t('ban_messages.reject.note_diff_license'),
};
//Ban data
let title;
let expLine = '';
if (ban.expiration) {
const duration = txCore.translator.tDuration(
(ban.expiration - ts) * 1000,
{
largest: 2,
units: ['d', 'h', 'm'] as Unit[],
},
);
expLine = `<strong>${textKeys.label_expiration}:</strong> ${duration} <br>`;
title = textKeys.title_temporary;
} else {
title = textKeys.title_permanent;
}
const banDate = new Date(ban.timestamp * 1000).toLocaleString(
txCore.translator.canonical,
{ dateStyle: 'medium', timeStyle: 'medium' }
);
//Ban author
let authorLine = '';
if (!txConfig.gameFeatures.hideAdminInPunishments) {
authorLine = `<strong>${textKeys.label_author}:</strong> ${xss(ban.author)} <br>`;
}
//Informational notes
let note = '';
if (activeBans.length > 1) {
note += `<br>${textKeys.note_multiple_bans}`;
}
const bannedLicense = ban.ids.find(id => id.startsWith('license:'));
if (bannedLicense && validIdsObject.license && bannedLicense.substring(8) !== validIdsObject.license) {
note += `<br>${textKeys.note_diff_license}`;
}
//Prepare rejection message
const reason = rejectMessageTemplate(
title,
`${expLine}
<strong>${textKeys.label_date}:</strong> ${banDate} <br>
<strong>${textKeys.label_reason}:</strong> ${xss(ban.reason)} <br>
<strong>${textKeys.label_id}:</strong> <codeid>${ban.id}</codeid> <br>
${authorLine}
${prepCustomMessage(txConfig.banlist.rejectionMessage)}
<span style="font-style: italic;">${note}</span>`
);
//Send serverlog message
const matchingIds = ban.ids.filter(id => validIdsArray.includes(id));
const matchingHwids = ('hwids' in ban && ban.hwids)
? ban.hwids.filter(hw => validHwidsArray.includes(hw))
: [];
const combined = [...matchingIds, ...matchingHwids];
const summarizedIds = summarizeIdsArray(combined);
const loggerReason = `active ban (${ban.id}) for identifiers ${summarizedIds}`;
txCore.logger.server.write([{
src: 'tx',
type: 'playerJoinDenied',
ts,
data: { reason: loggerReason }
}]);
return { allow: false, reason };
}
}
return { allow: true };
}
/**
* Checks if the player is an admin
*/
async function checkAdminOnlyMode(
validIdsArray: string[],
validIdsObject: PlayerIdsObjectType,
playerName: string
): Promise<AllowRespType | DenyRespType> {
const textKeys = {
mode_title: txCore.translator.t('whitelist_messages.admin_only.mode_title'),
insufficient_ids: txCore.translator.t('whitelist_messages.admin_only.insufficient_ids'),
deny_message: txCore.translator.t('whitelist_messages.admin_only.deny_message'),
};
//Check if fivem/discord ids are available
if (!validIdsObject.license && !validIdsObject.discord) {
return {
allow: false,
reason: rejectMessageTemplate(
textKeys.mode_title,
textKeys.insufficient_ids
),
}
}
//Looking for admin
const admin = txCore.adminStore.getAdminByIdentifiers(validIdsArray);
if (admin) return { allow: true };
//Prepare rejection message
const reason = rejectMessageTemplate(
textKeys.mode_title,
`${textKeys.deny_message} <br>
${prepCustomMessage(txConfig.whitelist.rejectionMessage)}`
);
return { allow: false, reason };
}
/**
* Checks if the player is a discord guild member
*/
async function checkDiscordMember(
validIdsArray: string[],
validIdsObject: PlayerIdsObjectType,
playerName: string
): Promise<AllowRespType | DenyRespType> {
const guildname = `<guildname>${txCore.discordBot.guildName}</guildname>`;
const textKeys = {
mode_title: txCore.translator.t('whitelist_messages.guild_member.mode_title'),
insufficient_ids: txCore.translator.t('whitelist_messages.guild_member.insufficient_ids'),
deny_title: txCore.translator.t('whitelist_messages.guild_member.deny_title'),
deny_message: txCore.translator.t('whitelist_messages.guild_member.deny_message', { guildname }),
};
//Check if discord id is available
if (!validIdsObject.discord) {
return {
allow: false,
reason: rejectMessageTemplate(
textKeys.mode_title,
textKeys.insufficient_ids
),
}
}
//Resolving member
let errorTitle, errorMessage;
try {
const { isMember, memberRoles } = await txCore.discordBot.resolveMemberRoles(validIdsObject.discord);
if (isMember) {
return { allow: true };
} else {
errorTitle = textKeys.deny_title;
errorMessage = textKeys.deny_message;
}
} catch (error) {
errorTitle = `Error validating Discord Server Member Whitelist:`;
errorMessage = `<code>${(error as Error).message}</code>`;
}
//Prepare rejection message
const reason = rejectMessageTemplate(
errorTitle,
`${errorMessage} <br>
${prepCustomMessage(txConfig.whitelist.rejectionMessage)}`
);
return { allow: false, reason };
}
/**
* Checks if the player has specific discord guild roles
*/
async function checkDiscordRoles(
validIdsArray: string[],
validIdsObject: PlayerIdsObjectType,
playerName: string
): Promise<AllowRespType | DenyRespType> {
const guildname = `<guildname>${txCore.discordBot.guildName}</guildname>`;
const textKeys = {
mode_title: txCore.translator.t('whitelist_messages.guild_roles.mode_title'),
insufficient_ids: txCore.translator.t('whitelist_messages.guild_roles.insufficient_ids'),
deny_notmember_title: txCore.translator.t('whitelist_messages.guild_roles.deny_notmember_title'),
deny_notmember_message: txCore.translator.t('whitelist_messages.guild_roles.deny_notmember_message', { guildname }),
deny_noroles_title: txCore.translator.t('whitelist_messages.guild_roles.deny_noroles_title'),
deny_noroles_message: txCore.translator.t('whitelist_messages.guild_roles.deny_noroles_message', { guildname }),
};
//Check if discord id is available
if (!validIdsObject.discord) {
return {
allow: false,
reason: rejectMessageTemplate(
textKeys.mode_title,
textKeys.insufficient_ids
),
}
}
//Resolving member
let errorTitle, errorMessage;
try {
const { isMember, memberRoles } = await txCore.discordBot.resolveMemberRoles(validIdsObject.discord);
if (isMember) {
const matchingRole = txConfig.whitelist.discordRoles
.find((requiredRole) => memberRoles?.includes(requiredRole));
if (matchingRole) {
return { allow: true };
} else {
errorTitle = textKeys.deny_noroles_title;
errorMessage = textKeys.deny_noroles_message;
}
} else {
errorTitle = textKeys.deny_notmember_title;
errorMessage = textKeys.deny_notmember_message;
}
} catch (error) {
errorTitle = `Error validating Discord Role Whitelist:`;
errorMessage = `<code>${(error as Error).message}</code>`;
}
//Prepare rejection message
const reason = rejectMessageTemplate(
errorTitle,
`${errorMessage} <br>
${prepCustomMessage(txConfig.whitelist.rejectionMessage)}`
);
return { allow: false, reason };
}
/**
* Checks if the player has a whitelisted license
*/
async function checkApprovedLicense(
validIdsArray: string[],
validIdsObject: PlayerIdsObjectType,
validHwidsArray: string[],
playerName: string
): Promise<AllowRespType | DenyRespType> {
const textKeys = {
mode_title: txCore.translator.t('whitelist_messages.approved_license.mode_title'),
insufficient_ids: txCore.translator.t('whitelist_messages.approved_license.insufficient_ids'),
deny_title: txCore.translator.t('whitelist_messages.approved_license.deny_title'),
request_id_label: txCore.translator.t('whitelist_messages.approved_license.request_id_label'),
};
//Check if license is available
if (!validIdsObject.license) {
return {
allow: false,
reason: rejectMessageTemplate(
textKeys.mode_title,
textKeys.insufficient_ids
),
}
}
//Finding the player and checking if already whitelisted
let player;
try {
player = playerResolver(null, null, validIdsObject.license);
const dbData = player.getDbData();
if (dbData && dbData.tsWhitelisted) {
return { allow: true };
}
} catch (error) { }
//Common vars
const { displayName, pureName } = cleanPlayerName(playerName);
const ts = now();
//Searching for the license/discord on whitelistApprovals
const allIdsFilter = (x: DatabaseWhitelistApprovalsType) => {
return validIdsArray.includes(x.identifier);
}
const approvals = txCore.database.whitelist.findManyApprovals(allIdsFilter);
if (approvals.length) {
//update or register player
if (typeof player !== 'undefined' && player.license) {
player.setWhitelist(true);
} else {
txCore.database.players.register({
license: validIdsObject.license,
ids: validIdsArray,
hwids: validHwidsArray,
displayName,
pureName,
playTime: 0,
tsLastConnection: ts,
tsJoined: ts,
tsWhitelisted: ts,
});
}
//Remove entries from whitelistApprovals & whitelistRequests
txCore.database.whitelist.removeManyApprovals(allIdsFilter);
txCore.database.whitelist.removeManyRequests({ license: validIdsObject.license });
//return allow join
return { allow: true };
}
//Player is not whitelisted
//Resolve player discord
let discordTag, discordAvatar;
if (validIdsObject.discord && txCore.discordBot.isClientReady) {
try {
const { tag, avatar } = await txCore.discordBot.resolveMemberProfile(validIdsObject.discord);
discordTag = tag;
discordAvatar = avatar;
} catch (error) { }
}
//Check if this player has an active wl request
//NOTE: it could return multiple, but we are not dealing with it
let wlRequestId: string;
const requests = txCore.database.whitelist.findManyRequests({ license: validIdsObject.license });
if (requests.length) {
wlRequestId = requests[0].id; //just getting the first
txCore.database.whitelist.updateRequest(validIdsObject.license, {
playerDisplayName: displayName,
playerPureName: pureName,
discordTag,
discordAvatar,
tsLastAttempt: ts,
});
} else {
wlRequestId = txCore.database.whitelist.registerRequest({
license: validIdsObject.license,
playerDisplayName: displayName,
playerPureName: pureName,
discordTag,
discordAvatar,
tsLastAttempt: ts,
});
txCore.fxRunner.sendEvent('whitelistRequest', {
action: 'requested',
playerName: displayName,
requestId: wlRequestId,
license: validIdsObject.license,
});
}
//Prepare rejection message
const reason = rejectMessageTemplate(
textKeys.deny_title,
`<strong>${textKeys.request_id_label}:</strong>
<codeid>${wlRequestId}</codeid> <br>
${prepCustomMessage(txConfig.whitelist.rejectionMessage)}`
);
return { allow: false, reason }
}