532 lines
20 KiB
TypeScript
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 }
|
|
}
|