diff --git a/.github/workflows/locale-pull-request.yml b/.github/workflows/locale-pull-request.yml index 5c1692c6c..0d8681efe 100644 --- a/.github/workflows/locale-pull-request.yml +++ b/.github/workflows/locale-pull-request.yml @@ -12,23 +12,6 @@ jobs: pull-requests: write steps: - - name: Enforce base branch - uses: actions/github-script@v7 - with: - script: | - // Get the pull request - const pull_request = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number - }); - - // Check if the base branch is 'main' or 'master' - if (pull_request.data.base.ref === 'main' || pull_request.data.base.ref === 'master') { - console.error('Pull request is targeting the main branch. Please target the develop branch instead.'); - process.exit(1); - } - - name: Use Node.js 22 uses: actions/setup-node@v4 with: diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 000000000..03ff59a94 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec bun run "$(dirname "$0")/pre-push.ts" "$@" diff --git a/.husky/pre-push.ts b/.husky/pre-push.ts new file mode 100644 index 000000000..385beac09 --- /dev/null +++ b/.husky/pre-push.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env bun +import { readFileSync } from 'node:fs'; + +//MARK: Configs +const protectedBranchPrefixRegex = /^(experiments?|mock(up)?s?|private)\//; +const allowedRemoteName = 'git.fivem.net'; + +//MARK: Parsing push data +const remoteName = process.argv[2] ?? ''; +const remoteUrl = process.argv[3] ?? ''; + +//No restrictions on the allowed remote +if (remoteName === allowedRemoteName) { + process.exit(0); +} + +// Read refs being pushed from stdin. +const stdin = readFileSync(0, 'utf8'); +const lines = stdin.split('\n').map((l) => l.trim()).filter(Boolean); + +const blocked: string[] = []; +for (const line of lines) { + //Parse line + const parts = line.split(/\s+/); + if (parts.length < 4) continue; + const [localRef, localSha, remoteRef, remoteSha] = parts; + + // Check branches + if (!localRef.startsWith('refs/heads/')) continue; + const branch = localRef.slice('refs/heads/'.length); + if (protectedBranchPrefixRegex.test(branch)) { + blocked.push(branch); + } +} + +//MARK: Output +if (!blocked.length) { + process.exit(0); +} + +//Oh noooo :( +const msg = [ + `❌ Push blocked: these branches may only be pushed to ${allowedRemoteName}.`, + `Remote: ${remoteName} (${remoteUrl})`, + `Blocked branches:`, + ...blocked.map((b) => ` - ${b}`), + ``, + `Fix: push to ${allowedRemoteName}.` +].join('\n'); +process.stderr.write(msg); +process.exit(1); diff --git a/LICENSE b/LICENSE index 8e98ecd5f..ac24dd71c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2025 André Tabarra +Copyright (c) 2019-2025 Take-Two Interactive Software, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 1c4a4b922..8c8aebd1c 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -21,5 +21,6 @@ module.exports = { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [2, 'always', types], + // 'body-max-line-length': [0, 'always', 100], }, }; diff --git a/core/boot/startReadyWatcher.ts b/core/boot/startReadyWatcher.ts index ad93f0bc1..3974e29db 100644 --- a/core/boot/startReadyWatcher.ts +++ b/core/boot/startReadyWatcher.ts @@ -13,7 +13,7 @@ import { chalkInversePad } from '@lib/misc'; const console = consoleFactory(); -const getPublicIp = async () => { +export const getPublicIp = async () => { const zIpValidator = z.string().ip(); const reqOptions = { timeout: { request: 2000 }, @@ -41,16 +41,13 @@ const getPublicIp = async () => { const getOSMessage = async () => { const serverMessage = [ `To be able to access txAdmin from the internet open port ${txHostConfig.txaPort}`, - 'on your OS Firewall as well as in the hosting company.', + 'on your OS Firewall as well as in the hosting provider.', ]; const winWorkstationMessage = [ '[!] Home-hosting fxserver is not recommended [!]', 'You need to open the fxserver port (usually 30120) on Windows Firewall', 'and set up port forwarding on your router so other players can access it.', ]; - if (txEnv.displayAds) { - winWorkstationMessage.push('We recommend renting a server from ' + chalk.inverse(' https://zap-hosting.com/txAdmin ') + '.'); - } //FIXME: use si.osInfo() instead const distro = await getOsDistro(); diff --git a/core/globalData.ts b/core/globalData.ts index e4ae07798..3da050c0d 100644 --- a/core/globalData.ts +++ b/core/globalData.ts @@ -14,8 +14,8 @@ import { getHostVars, hostEnvVarSchemas } from './boot/getHostVars'; import { getZapVars } from './boot/getZapVars'; import { z, ZodSchema } from 'zod'; import { fromZodError } from 'zod-validation-error'; -import defaultAds from '../dynamicAds2.json'; import consts from '@shared/consts'; +import chalk from 'chalk'; const console = consoleFactory(); @@ -209,15 +209,18 @@ if (dataPathVar) { //NOTE: Non-ASCII in one of those paths (don't know which) will make NodeJS crash due to a bug in v8 (or something) // when running localization methods like Date.toLocaleString(). // There was also an issue with the slash() lib and with the +exec on FXServer -const nonASCIIRegex = /[^\x00-\x80]+/; +const nonASCIIRegex = /[^\x00-\x80]+/g; +const colorNonAscii = (x: string) => chalk.black.bgGreenBright( + x.replaceAll(nonASCIIRegex, (m) => chalk.bgRedBright(m)) +); if (nonASCIIRegex.test(fxsPath) || nonASCIIRegex.test(dataPath)) { fatalError.GlobalData(7, [ 'Due to environmental restrictions, your paths CANNOT contain non-ASCII characters.', 'Example of non-ASCII characters: çâýå, ρέθ, ñäé, ēļæ, глж, เซิร์, 警告.', 'Please make sure FXServer is not in a path contaning those characters.', `If on windows, we suggest you moving the artifact to "C:/fivemserver/${fxsVersion}/".`, - ['FXServer path', fxsPath], - ['txData path', dataPath], + 'FXServer path: ' + colorNonAscii(fxsPath), + 'txData path: ' + colorNonAscii(dataPath), ]); } @@ -471,36 +474,33 @@ if (ignoreDeprecatedConfigs) { const isPterodactyl = !isWindows && process.env?.TXADMIN_ENABLE === '1'; const isZapHosting = providerName === 'ZAP-Hosting'; +const setConsoleTitle = !(isPterodactyl || isZapHosting || providerName); //assume not a terminal -//Quick config to disable ads -const displayAds = process.env?.TXHOST_TMP_HIDE_ADS !== 'true' || isPterodactyl || isZapHosting; -const adSchema = z.object({ - img: z.string(), - url: z.string(), -}).nullable(); -const adsDataSchema = z.object({ - login: adSchema, - main: adSchema, -}); -let adsData: z.infer = { - login: null, - main: null, -}; -if (displayAds) { - try { - adsData = adsDataSchema.parse(defaultAds); - } catch (error) { - console.error('Failed to load ads data.', error); - } -} //FXServer Display Version let fxsVersionTag = fxsVersion.toString(); if (fxsVerParsed.branch && fxsVerParsed.branch !== 'master') { fxsVersionTag += '-ft'; } -if (isZapHosting) { - fxsVersionTag += '/ZAP'; + +let providerTag = ''; +const partnerPrefixes = { + 'gportal': 'GPor', + 'nitrado': 'Nitr', + 'nodecraft': 'NoCr', + 'shockbyte': 'ShBy', + 'xrealm': 'XRea', + 'zaphosting': 'ZapH', +} as { [key: string]: string }; +if (providerName) { + const cleanName = providerName.toLowerCase().replace(/[^a-z0-9]+/g, ''); + if (cleanName in partnerPrefixes) { + providerTag = partnerPrefixes[cleanName]; + } +} + +if (providerTag) { + fxsVersionTag += `/${providerTag}`; } else if (isPterodactyl) { fxsVersionTag += '/Ptero'; } else if (isWindows && fxsVerParsed.platform === 'windows') { @@ -520,10 +520,11 @@ export const txDevEnv = Object.freeze(_txDevEnv); export const txEnv = Object.freeze({ //Calculated isWindows, - isPterodactyl, //TODO: remove, used only in HB Data - isZapHosting, //TODO: remove, used only in HB Data and authLogic to disable src check - displayAds, - adsData, + setConsoleTitle, + + //TODO: remove, used only in diagnostics (HB Data + page) + isPterodactyl, + isZapHosting, //NOTE: This one is also used in authLogic to disable src check //Natives fxsVersionTag, diff --git a/core/index.ts b/core/index.ts index 21589e73a..d0ac41208 100644 --- a/core/index.ts +++ b/core/index.ts @@ -1,6 +1,6 @@ //NOTE: must be imported first to setup the environment import { txEnv, txHostConfig } from './globalData'; -import consoleFactory, { setTTYTitle } from '@lib/console'; +import consoleFactory from '@lib/console'; //Can be imported after import fs from 'node:fs'; @@ -16,7 +16,6 @@ const console = consoleFactory(); try { process.title = 'txAdmin'; //doesn't work for now setupProcessHandlers(); - setTTYTitle(); checkPreRelease(); } catch (error) { fatalError.Boot(0, 'Failed early process setup.', error); diff --git a/core/lib/diagnostics.ts b/core/lib/diagnostics.ts index e7b9a1b4a..6948f0e86 100644 --- a/core/lib/diagnostics.ts +++ b/core/lib/diagnostics.ts @@ -1,17 +1,15 @@ const modulename = 'WebServer:DiagnosticsFuncs'; import os from 'node:os'; -import humanizeDuration, { HumanizerOptions } from 'humanize-duration'; -import got from '@lib/got'; import getOsDistro from '@lib/host/getOsDistro.js'; import getHostUsage from '@lib/host/getHostUsage'; import pidUsageTree from '@lib/host/pidUsageTree.js'; import { txEnv, txHostConfig } from '@core/globalData'; import si from 'systeminformation'; import consoleFactory from '@lib/console'; -import { parseFxserverVersion } from '@lib/fxserver/fxsVersionParser'; import { getHeapStatistics } from 'node:v8'; import bytes from 'bytes'; -import { msToDuration } from './misc'; +import { msToShortishDuration } from './misc'; +import type { ProcessInfo } from '@shared/diagnosticsTypes'; const console = consoleFactory(modulename); @@ -28,7 +26,6 @@ type HostStaticDataType = { speedMax: number; physicalCores: number; cores: number; - clockWarning: string; }, }; type HostDynamicDataType = { @@ -51,50 +48,33 @@ let _hostStaticDataCache: HostStaticDataType; * FIXME: migrate to use gwmi on windows by default */ export const getProcessesData = async () => { - type ProcDataType = { - pid: number; - ppid: number | string; - name: string; - cpu: number; - memory: number; - order: number; - } - const procList: ProcDataType[] = []; + const procList: ProcessInfo[] = []; try { const txProcessId = process.pid; const processes = await pidUsageTree(txProcessId); - //NOTE: Cleaning invalid proccesses that might show up in Linux - Object.keys(processes).forEach((pid) => { - if (processes[pid] === null) delete processes[pid]; - }); - - //Foreach PID - Object.keys(processes).forEach((pid) => { - const curr = processes[pid]; + for (const [pid, proc] of Object.entries(processes)) { + //NOTE: Cleaning invalid proccesses that might show up in Linux + if (!pid || !proc) continue; const currPidInt = parseInt(pid); - //Define name and order let procName; - let order = curr.timestamp || 1; if (currPidInt === txProcessId) { - procName = 'txAdmin (inside FXserver)'; - order = 0; //forcing order because all process can start at the same second - } else if (curr.memory <= 10 * MEGABYTE) { - procName = 'FXServer MiniDump'; + procName = 'txAdmin'; + } else if (proc.memory <= 10 * MEGABYTE) { + procName = 'MiniDump'; } else { procName = 'FXServer'; } procList.push({ pid: currPidInt, - ppid: (curr.ppid === txProcessId) ? `${txProcessId} (txAdmin)` : curr.ppid, + parent: proc.ppid, name: procName, - cpu: curr.cpu, - memory: curr.memory / MEGABYTE, - order: order, + cpu: proc.cpu, + memory: proc.memory / MEGABYTE, }); - }); + } } catch (error) { if ((error as any).code = 'ENOENT') { console.error('Failed to get processes tree usage data.'); @@ -111,67 +91,10 @@ export const getProcessesData = async () => { } } - //Sort procList array - procList.sort((a, b) => a.order - b.order); - return procList; } -/** - * Gets the FXServer Data. - */ -export const getFXServerData = async () => { - //Check runner child state - const childState = txCore.fxRunner.child; - if (!childState?.isAlive) { - return { error: 'Server Offline' }; - } - if (!childState?.netEndpoint) { - return { error: 'Server is has no network endpoint' }; - } - - //Preparing request - const requestOptions = { - url: `http://${childState.netEndpoint}/info.json`, - maxRedirects: 0, - timeout: { request: 1500 }, - retry: { limit: 0 }, - }; - - //Making HTTP Request - let infoData: Record; - try { - infoData = await got.get(requestOptions).json(); - } catch (error) { - console.warn('Failed to get FXServer information.'); - console.verbose.dir(error); - return { error: 'Failed to retrieve FXServer data.
The server must be online for this operation.
Check the terminal for more information (if verbosity is enabled)' }; - } - - //Processing result - try { - const ver = parseFxserverVersion(infoData.server); - return { - error: false, - statusColor: 'success', - status: ' ONLINE ', - version: ver.valid ? `${ver.platform}:${ver.branch}:${ver.build}` : `${ver.platform ?? 'unknown'}:INVALID`, - versionMismatch: (ver.build !== txEnv.fxsVersion), - resources: infoData.resources.length, - onesync: (infoData.vars && infoData.vars.onesync_enabled === 'true') ? 'enabled' : 'disabled', - maxClients: (infoData.vars && infoData.vars.sv_maxClients) ? infoData.vars.sv_maxClients : '--', - txAdminVersion: (infoData.vars && infoData.vars['txAdmin-version']) ? infoData.vars['txAdmin-version'] : '--', - }; - } catch (error) { - console.warn('Failed to process FXServer information.'); - console.verbose.dir(error); - return { error: 'Failed to process FXServer data.
Check the terminal for more information (if verbosity is enabled)' }; - } -} - - - /** * Gets the Host Data. */ @@ -187,18 +110,6 @@ export const getHostData = async (): Promise => { try { const cpuStats = await si.cpu(); - const cpuSpeed = cpuStats.speedMin ?? cpuStats.speed; - - //TODO: move this to frontend - let clockWarning = ''; - if (cpuStats.cores < 8) { - if (cpuSpeed <= 2.4) { - clockWarning = ' VERY SLOW! '; - } else if (cpuSpeed < 3.0) { - clockWarning = ' SLOW '; - } - } - _hostStaticDataCache = { nodeVersion: process.version, username: osUsername, @@ -206,17 +117,16 @@ export const getHostData = async (): Promise => { cpu: { manufacturer: cpuStats.manufacturer, brand: cpuStats.brand, - speedMin: cpuSpeed, + speedMin: cpuStats.speedMin ?? cpuStats.speed, speedMax: cpuStats.speedMax, physicalCores: cpuStats.physicalCores, cores: cpuStats.cores, - clockWarning, } } } catch (error) { console.error('Error getting Host static data.'); console.verbose.dir(error); - return { error: 'Failed to retrieve host static data.
Check the terminal for more information (if verbosity is enabled)' }; + return { error: 'Failed to retrieve host static data. Check the terminal for more information (if verbosity is enabled).' }; } } @@ -246,7 +156,7 @@ export const getHostData = async (): Promise => { } catch (error) { console.error('Error getting Host dynamic data.'); console.verbose.dir(error); - return { error: 'Failed to retrieve host dynamic data.
Check the terminal for more information (if verbosity is enabled)' }; + return { error: 'Failed to retrieve host dynamic data. Check the terminal for more information (if verbosity is enabled).' }; } } @@ -265,7 +175,7 @@ export const getHostStaticData = (): HostStaticDataType => { /** * Gets txAdmin Data */ -export const getTxAdminData = async () => { +export const getRuntimeData = async () => { const stats = txCore.metrics.txRuntime; //shortcut const memoryUsage = getHeapStatistics(); @@ -276,10 +186,19 @@ export const getTxAdminData = async () => { hostApiTokenState = 'configured'; } + let runtime = 'Unknown runtime'; + if ('Bun' in globalThis) { + //@ts-ignore bun types not installed, just futureproofing + runtime = `Bun v${Bun.version}`; + } else if ('node' in process.versions && process.versions.node) { + runtime = `Node.js v${process.versions.node}`; + } + const defaultFlags = Object.entries(txHostConfig.defaults).filter(([k, v]) => Boolean(v)).map(([k, v]) => k); return { - //Stats - uptime: msToDuration(process.uptime() * 1000), + txEnv, + runtime, + uptime: msToShortishDuration(process.uptime() * 1000), databaseFileSize: bytes(txCore.database.fileSize), txHostConfig: { ...txHostConfig, @@ -287,45 +206,32 @@ export const getTxAdminData = async () => { hostApiToken: hostApiTokenState, defaults: defaultFlags, }, - txEnv: { - ...txEnv, - adsData: undefined, - }, monitor: { - hbFails: { - http: stats.monitorStats.healthIssues.http, - fd3: stats.monitorStats.healthIssues.fd3, - }, - restarts: { - bootTimeout: stats.monitorStats.restartReasons.bootTimeout, - close: stats.monitorStats.restartReasons.close, - heartBeat: stats.monitorStats.restartReasons.heartBeat, - healthCheck: stats.monitorStats.restartReasons.healthCheck, - both: stats.monitorStats.restartReasons.both, - } + hbFails: stats.monitorStats.healthIssues, + restarts: stats.monitorStats.restartReasons, }, performance: { - banCheck: stats.banCheckTime.resultSummary('ms').summary, - whitelistCheck: stats.whitelistCheckTime.resultSummary('ms').summary, - playersTableSearch: stats.playersTableSearchTime.resultSummary('ms').summary, - historyTableSearch: stats.historyTableSearchTime.resultSummary('ms').summary, - databaseSave: stats.databaseSaveTime.resultSummary('ms').summary, - perfCollection: stats.perfCollectionTime.resultSummary('ms').summary, + banCheck: stats.banCheckTime, + whitelistCheck: stats.whitelistCheckTime, + playersTableSearch: stats.playersTableSearchTime, + historyTableSearch: stats.historyTableSearchTime, + databaseSave: stats.databaseSaveTime, + perfCollection: stats.perfCollectionTime, }, logger: { - storageSize: (await txCore.logger.getStorageSize()).total, + storageSize: await txCore.logger.getStorageSize(), statusAdmin: txCore.logger.admin.getUsageStats(), statusFXServer: txCore.logger.fxserver.getUsageStats(), statusServer: txCore.logger.server.getUsageStats(), }, memoryUsage: { - heap_used: bytes(memoryUsage.used_heap_size), - heap_limit: bytes(memoryUsage.heap_size_limit), + heap_used: bytes(memoryUsage.used_heap_size) ?? '--', + heap_limit: bytes(memoryUsage.heap_size_limit) ?? '--', heap_pct: (memoryUsage.heap_size_limit > 0) ? (memoryUsage.used_heap_size / memoryUsage.heap_size_limit * 100).toFixed(2) : 0, - physical: bytes(memoryUsage.total_physical_size), - peak_malloced: bytes(memoryUsage.peak_malloced_memory), + physical: bytes(memoryUsage.total_physical_size) ?? '--', + peak_malloced: bytes(memoryUsage.peak_malloced_memory) ?? '--', }, }; } diff --git a/core/lib/fxserver/fxsConfigHelper.ts b/core/lib/fxserver/fxsConfigHelper.ts index fee029d3b..60ab5cea7 100644 --- a/core/lib/fxserver/fxsConfigHelper.ts +++ b/core/lib/fxserver/fxsConfigHelper.ts @@ -441,7 +441,7 @@ const validateCommands = async (parsedCommands: (ExecRecursionError | Command)[] } } - //Comment out any onesync sets + //Comment-out any onesync sets if (cmd.isConvarSetterFor('onesync')) { toCommentOut.add( cmd.file, @@ -451,7 +451,15 @@ const validateCommands = async (parsedCommands: (ExecRecursionError | Command)[] continue; } - //FIXME: add isConvarSetterFor for all "Settings page only" convars + //Comment-out the allowlist convars + if (cmd.isConvarSetterFor('sv_allowlistInstructions') || cmd.isConvarSetterFor('sv_appearAllowlisted')) { + toCommentOut.add( + cmd.file, + cmd.line, + 'The allowlist convars are overwritten by the txAdmin settings page.', + ); + continue; + } //Extract & process endpoint validity if (cmd.command === 'endpoint_add_tcp' || cmd.command === 'endpoint_add_udp') { diff --git a/core/lib/host/probeInternetTime.ts b/core/lib/host/probeInternetTime.ts new file mode 100644 index 000000000..5f36169a2 --- /dev/null +++ b/core/lib/host/probeInternetTime.ts @@ -0,0 +1,124 @@ +import got from "@lib/got"; +import { performance } from "node:perf_hooks"; + + +export type ProbeTimezoneInfo = { + timezone: string; + offset: string; + abbr: string; +}; + +type ParseResult = { + time: number; + timezone?: ProbeTimezoneInfo; +}; + +type TimeProbe = { + url: string; + parse: (body: string) => ParseResult; +}; + +const getLocalTimezone = () => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + } catch { + return 'UTC'; + } +}; + +const buildTimeProbes = (): TimeProbe[] => { + const localTz = getLocalTimezone(); + return [ + { + url: "https://time.akamai.com/?ms", + parse: (b) => ({ time: parseFloat(b.trim()) * 1000 }), + }, + { + url: "https://www.cloudflare.com/cdn-cgi/trace", + parse: (b) => { + const tsLine = b.split("\n").find((l) => l.startsWith("ts="))!; + const ts = parseFloat(tsLine.slice(3)); // seconds.frac + return { time: Math.round(ts * 1000) }; + }, + }, + { + url: `https://gettimeapi.dev/v1/time?timezone=${encodeURIComponent(localTz)}`, + parse: (b) => { + const json = JSON.parse(b); + return { + time: Date.parse(json.iso8601), + timezone: { + timezone: json.timezone, + offset: json.offset, + abbr: json.abbr, + }, + }; + }, + }, + ]; +}; + + +export type ProbeSuccess = { + url: string; + success: true; + serverTime: number; + rtt: number; + offset: number; + timezone?: ProbeTimezoneInfo; +}; +export type ProbeFailure = { + url: string; + success: false; + error: string; +}; +export type ProbeResult = ProbeSuccess | ProbeFailure; + +const runProbe = async ({ url, parse }: TimeProbe): Promise => { + const timeoutMs = 5000; + try { + const t0 = performance.now(); + const res = await got(url, { + retry: { limit: 0 }, + timeout: { request: timeoutMs }, + }); + const t1 = performance.now(); + const parsed = parse(res.body); + const rtt = t1 - t0; + const offset = parsed.time + rtt / 2 - Date.now(); + return { + url, + success: true, + serverTime: parsed.time, + rtt, + offset, + timezone: parsed.timezone, + }; + } catch (error) { + return { url, success: false, error: (error as Error)?.message ?? 'unknown error' }; + } +} + + +type RunAllProbesResult = { + date: Date | null; + avgTimeMs: number | null; + avgOffsetMs: number | null; + avgRttMs: number | null; + results: ProbeResult[]; +} + +export default async function probeInternetTime(): Promise { + const results = await Promise.all(buildTimeProbes().map(runProbe)); + const successResults = results.filter((r) => r.success) as ProbeSuccess[]; + const avgTimeMs = successResults.reduce((acc, r) => acc + r.serverTime, 0) / successResults.length; + const avgOffsetMs = successResults.reduce((acc, r) => acc + r.offset, 0) / successResults.length; + const avgRttMs = successResults.reduce((acc, r) => acc + r.rtt, 0) / successResults.length; + return { + date: avgTimeMs ? new Date(avgTimeMs) : null, + avgTimeMs: Math.floor(avgTimeMs), + avgOffsetMs: Math.floor(avgOffsetMs), + avgRttMs: Math.floor(avgRttMs), + results, + }; +} diff --git a/core/lib/htmlRenderSafety.test.ts b/core/lib/htmlRenderSafety.test.ts new file mode 100644 index 000000000..1fc18481c --- /dev/null +++ b/core/lib/htmlRenderSafety.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from 'vitest'; +import { + escapeHtmlAttribute, + escapeHtmlContent, + escapeHtmlRawText, + sanitizeClassToken, + sanitizeCssVarName, + sanitizeCssVarValue, + sanitizeSimpleHtml, +} from './htmlRenderSafety'; + +//MARK: HTML escapes +describe('escapeHtmlAttribute', () => { + test('escapes all characters that could break a quoted attribute', () => { + const value = `"'>&\``; + expect(escapeHtmlAttribute(value)).toBe('"'><script>alert(1)</script>&`'); + }); + + test('coerces non-string values to string', () => { + expect(escapeHtmlAttribute(123)).toBe('123'); + expect(escapeHtmlAttribute(null)).toBe('null'); + expect(escapeHtmlAttribute(undefined)).toBe('undefined'); + }); +}); + +describe('escapeHtmlContent', () => { + test('escapes tag and entity characters', () => { + expect(escapeHtmlContent('&')).toBe('<script>alert(1)</script>&'); + }); + + test('leaves quotes and backticks untouched', () => { + expect(escapeHtmlContent(`"hello" 'world' \`foo\``)).toBe(`"hello" 'world' \`foo\``); + }); + + test('coerces non-string values to string', () => { + expect(escapeHtmlContent(42)).toBe('42'); + expect(escapeHtmlContent(null)).toBe('null'); + }); +}); + +describe('escapeHtmlRawText', () => { + test('prevents tag breakout', () => { + expect(escapeHtmlRawText('')) + .toBe('\\u003c/script\\u003e\\u003cscript\\u003ealert(1)\\u003c/script\\u003e'); + }); + + test('prevents tag breakout', () => { + expect(escapeHtmlRawText('')) + .toBe('\\u003c/style\\u003e\\u003cscript\\u003ealert(1)\\u003c/script\\u003e'); + }); + + test('escapes line/paragraph separators', () => { + expect(escapeHtmlRawText('a\u2028b\u2029c')).toBe('a\\u2028b\\u2029c'); + }); + + test('works with JSON.stringify output for ' })); + expect(out).toBe('{"v":"\\u003c/script\\u003e\\u003cscript\\u003ealert(1)\\u003c/script\\u003e"}'); + }); +}); + +//MARK: CSS sanitizers +describe('sanitizeClassToken', () => { + test('keeps only safe class token chars', () => { + expect(sanitizeClassToken('dark theme-foo" onload="evil()')).toBe('dark theme-foo onloadevil'); + }); + + test('collapses extra whitespace', () => { + expect(sanitizeClassToken(' dark light ')).toBe('dark light'); + }); +}); + +describe('sanitizeCssVarName', () => { + test('removes unsafe chars from css variable names', () => { + expect(sanitizeCssVarName('x;}body{display:none/*')).toBe('xbodydisplaynone'); + }); +}); + +describe('sanitizeCssVarValue', () => { + test('strips characters that could break out of &ok')).toBe('1;}/stylescriptalert(1)/scriptok'); + }); + + test('preserves normal CSS values', () => { + expect(sanitizeCssVarValue('274 93% 39%')).toBe('274 93% 39%'); + }); +}); + +//MARK: HTML sanitizers +describe('sanitizeSimpleHtml', () => { + test('allows whitelisted formatting tags', () => { + expect(sanitizeSimpleHtml('bold and mono')) + .toBe('bold and mono'); + }); + + test('allows self-closing
', () => { + expect(sanitizeSimpleHtml('line1
line2')).toBe('line1
line2'); + }); + + test('strips non-whitelisted tags', () => { + expect(sanitizeSimpleHtml('')).toBe('<script>alert(1)</script>'); + }); + + test('strips tags that were previously over-whitelisted', () => { + const input = 'emu
pre

p

link'; + expect(sanitizeSimpleHtml(input)).not.toContain(''); + expect(sanitizeSimpleHtml(input)).not.toContain(''); + expect(sanitizeSimpleHtml(input)).not.toContain('
');
+        expect(sanitizeSimpleHtml(input)).not.toContain('

'); + expect(sanitizeSimpleHtml(input)).not.toContain('/ +// - Injecting HTML entities (&) which would be misinterpreted outside the raw text context +// U+2028/U+2029 are line/paragraph separators that can break JavaScript string literals. +const RAW_TEXT_ESCAPE_MAP: Record = { + '<': '\\u003c', + '>': '\\u003e', + '&': '\\u0026', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +}; + +// Matches characters that could break out of a quoted attribute. +const HTML_ATTR_ESCAPE_REGEX = /[&<>"'`]/g; + +// Escaping < > & in text content prevents tag injection and entity misinterpretation. +// Quotes and backticks are harmless in text nodes, so they are not escaped here. +const HTML_CONTENT_ESCAPE_MAP: Record = { + '&': '&', + '<': '<', + '>': '>', +}; +const HTML_CONTENT_ESCAPE_REGEX = /[&<>]/g; + +// Matches characters that could break out of a raw text element (` + */ +export const escapeHtmlRawText = (value: string) => { + return value.replace(RAW_TEXT_BREAKOUT_REGEX, (char) => RAW_TEXT_ESCAPE_MAP[char]); +}; + + +//MARK: CSS sanitizers +/** + * Sanitizes a string for use as one or more CSS class tokens. + * Splits on whitespace, strips unsafe characters, and rejoins. + * @example `

` + */ +export const sanitizeClassToken = (value: string) => { + return value + .split(/\s+/g) + .map((token) => token.replace(CSS_SAFE_TOKEN_REGEX, '')) + .filter((token) => token.length) + .join(' '); +}; + +/** + * Sanitizes a string for use as a CSS custom property name (after the `--` prefix). + * Only allows [A-Za-z0-9_-]. + * @example `--${sanitizeCssVarName(name)}: value;` + */ +export const sanitizeCssVarName = (value: string) => { + return value.replace(CSS_SAFE_TOKEN_REGEX, ''); +}; + +/** + * Sanitizes a CSS property value by stripping characters that could break out of + * a ``). + * @example `--name: ${sanitizeCssVarValue(value)};` + */ +export const sanitizeCssVarValue = (value: string) => { + return value.replace(RAW_TEXT_BREAKOUT_REGEX, ''); +}; + + +//MARK: HTML sanitizers +//FIXME: Deprecate this whenever ditching EJS & legacy pages +// Whitelist for sanitizeSimpleHtml — only tags actually used by renderMessage callers: +// and : resources.js timeout/offline messages +//
: resources.js multi-line messages +// All the tags are also seen in the checkJoin.ts rejectMessageTemplate function +const simpleHtmlSanitizer = new FilterXSS({ + whiteList: { + strong: [], + br: [], + code: [], + }, +}); + +/** + * Sanitizes an HTML string, stripping all tags except a minimal formatting set + * (``, `
`, ``). Intended for server-rendered message pages + * where simple formatting is needed but arbitrary HTML must be rejected. + */ +export const sanitizeSimpleHtml = (value: string) => { + return simpleHtmlSanitizer.process(value); +}; diff --git a/core/lib/misc.ts b/core/lib/misc.ts index d9f1867e9..dfd6d54e3 100644 --- a/core/lib/misc.ts +++ b/core/lib/misc.ts @@ -299,3 +299,34 @@ export const deepFreeze = >(obj: T) => { * Returns a chalk.inverse of a string with a 1ch padding */ export const chalkInversePad = (str: string) => chalk.inverse(` ${str} `); + + +/** + * Parses a string to a safe and finite integer, or returns null if it's not a valid integer. + */ +export const parseFiniteIntString = (str: string) => { + const parsed = parseInt(str); + if (Number.isNaN(parsed) || !Number.isSafeInteger(parsed) || !Number.isFinite(parsed)) { + return null; + } + return parsed; +} + +/** + * Returns a markdown code inline string + */ +export const mdCodeInline = (msg: string) => `\`${msg}\``; + + +/** + * Returns a markdown code block string + */ +export const mdCodeBlock = (msg: string, lang: string | false = '', maxLen: number | false = false) => { + const blockLang = lang ? lang : ''; + let blockMsg = msg; + if (maxLen && blockMsg.length > maxLen) { + blockMsg = blockMsg.substring(0, maxLen - 3) + '...'; + } + const escapedMessage = blockMsg.replaceAll(/`/g, '\\\`'); + return `\`\`\`${blockLang}\n${escapedMessage}\n\`\`\``; +}; diff --git a/core/lib/player/idUtils.test.ts b/core/lib/player/idUtils.test.ts index 0ddd2b762..5f41d8842 100644 --- a/core/lib/player/idUtils.test.ts +++ b/core/lib/player/idUtils.test.ts @@ -1,5 +1,6 @@ import { test, expect, suite, it } from 'vitest'; import * as idUtils from './idUtils'; +import { shortenId } from '@shared/utils'; test('parsePlayerId', () => { @@ -40,18 +41,33 @@ test('getIdFromOauthNameid', () => { expect(idUtils.getIdFromOauthNameid('xxxxx')).toBe(false); }); +//NOTE: testing here because the @shared workspace has no tests test('shortenId', () => { - // Invalid ids - expect(() => idUtils.shortenId(123 as any)).toThrow('id is not a string'); - expect(idUtils.shortenId('invalidFormat')).toBe('invalidFormat'); - expect(idUtils.shortenId(':1234567890123456')).toBe(':1234567890123456'); - expect(idUtils.shortenId('discord:')).toBe('discord:'); - - // Valid ID with length greater than >= 10 - expect(idUtils.shortenId('discord:383919883341266945')).toBe('discord:3839…6945'); - expect(idUtils.shortenId('xbl:12345678901')).toBe('xbl:1234…8901'); + // Invalid parameters + expect(() => shortenId(123 as any)).toThrow('id'); + expect(() => shortenId('discord:123456789', 2)).toThrow('numChars'); + expect(() => shortenId('discord:123456789', 'invalid' as any)).toThrow('numChars'); - // Valid ID with length <= 10 (should not be shortened) - expect(idUtils.shortenId('fivem:1234567890')).toBe('fivem:1234567890'); - expect(idUtils.shortenId('steam:1234')).toBe('steam:1234'); + // Invalid id formats + expect(shortenId('invalidFormat')).toBe('invalidFormat'); + expect(shortenId(':1234567890123456')).toBe(':1234567890123456'); + expect(shortenId('discord:')).toBe('discord:'); + + // Default behavior (numChars = 4) + expect(shortenId('discord:383919883341266945')).toBe('discord:3839…6945'); + expect(shortenId('xbl:12345678901')).toBe('xbl:1234…8901'); + + // Valid ID with length <= threshold (should not be shortened) + expect(shortenId('fivem:1234567890')).toBe('fivem:1234567890'); + expect(shortenId('steam:1234')).toBe('steam:1234'); + + // Custom numChars parameter + expect(shortenId('discord:383919883341266945', 3)).toBe('discord:383…945'); + expect(shortenId('discord:383919883341266945', 5)).toBe('discord:38391…66945'); + expect(shortenId('discord:383919883341266945', 6)).toBe('discord:383919…266945'); + expect(shortenId('2:bea2ac491d849477e66957b515f4a555a222e2e25911788d13fd46e9fda240a9', 20)).toBe('2:bea2ac491d849477e669…788d13fd46e9fda240a9'); + + // Edge case: exactly at threshold + expect(shortenId('discord:12345678', 3)).toBe('discord:12345678'); // 8 chars = 3*2+2, should not shorten + expect(shortenId('discord:123456789', 3)).toBe('discord:123…789'); // 9 chars > 3*2+2, should shorten }); diff --git a/core/lib/player/idUtils.ts b/core/lib/player/idUtils.ts index b32d83cf9..5a3e0aa92 100644 --- a/core/lib/player/idUtils.ts +++ b/core/lib/player/idUtils.ts @@ -1,5 +1,6 @@ import type { PlayerIdsObjectType } from "@shared/otherTypes"; import consts from "@shared/consts"; +import { shortenId } from "@shared/utils"; /** @@ -136,34 +137,12 @@ export const getIdFromOauthNameid = (nameid: string) => { } -/** - * Shortens an ID/HWID string to just leading and trailing 4 characters. - * Unicode symbol alternatives: ‥,…,~,≈,-,•,◇ - */ -export const shortenId = (id: string) => { - if (typeof id !== 'string') throw new Error(`id is not a string`); - - const [idType, idValue] = id.split(':', 2); - if (!idType || !idValue) { - return id; // Invalid format, return as is - } - - if (idValue.length <= 10) { - return id; // Do not shorten if ID value is 10 characters or fewer - } - - const start = idValue.slice(0, 4); - const end = idValue.slice(-4); - return `${idType}:${start}…${end}`; -} - - /** * Returns a string of shortened IDs/HWIDs */ export const summarizeIdsArray = (ids: string[]) => { if (!Array.isArray(ids)) return ''; if (ids.length === 0) return ''; - const shortList = ids.map(shortenId).join(', '); + const shortList = ids.map(id => shortenId(id)).join(', '); return `[${shortList}]`; } diff --git a/core/lib/player/playerClasses.ts b/core/lib/player/playerClasses.ts index 171bbd1cf..953b5a371 100644 --- a/core/lib/player/playerClasses.ts +++ b/core/lib/player/playerClasses.ts @@ -18,8 +18,8 @@ const console = consoleFactory(modulename); export class BasePlayer { displayName: string = 'unknown'; pureName: string = 'unknown'; - ids: string[] = []; - hwids: string[] = []; + idsOnline: string[] = []; + hwidsOnline: string[] = []; license: null | string = null; //extracted for convenience dbData: false | DatabasePlayerType = false; isConnected: boolean = false; @@ -36,25 +36,36 @@ export class BasePlayer { } /** - * Returns all available identifiers (current+db) + * Getter for the IDs in the database that are not currently online. */ - getAllIdentifiers() { - if (this.dbData && this.dbData.ids) { - return union(this.ids, this.dbData.ids); - } else { - return [...this.ids]; - } + get idsOffline() { + if (!this.dbData || !this.dbData.ids.length) return []; + return this.dbData.ids.filter(x => !this.idsOnline.includes(x)); } /** - * Returns all available hardware identifiers (current+db) + * Getter for the IDs in the database that are not currently online. */ - getAllHardwareIdentifiers() { - if (this.dbData && this.dbData.hwids) { - return union(this.hwids, this.dbData.hwids); - } else { - return [...this.hwids]; - } + get hwidsOffline() { + if (!this.dbData || !this.dbData.hwids.length) return []; + return this.dbData.hwids.filter(x => !this.hwidsOnline.includes(x)); + } + + + /** + * Getter for all available identifiers (current+db) + */ + get allIdentifiers() { + if (!this.dbData || !this.dbData.ids.length) return this.idsOnline; + return union(this.idsOnline, this.dbData.ids); + } + + /** + * Getter for all available hardware identifiers (current+db) + */ + get allHardwareIdentifiers() { + if (!this.dbData || !this.dbData.hwids.length) return this.hwidsOnline; + return union(this.hwidsOnline, this.dbData.hwids); } /** @@ -63,11 +74,12 @@ export class BasePlayer { * will contain the license but may be better to also explicitly add it to the array here? */ getHistory() { - if (!this.ids.length) return []; - return txCore.database.actions.findMany( - this.getAllIdentifiers(), - this.getAllHardwareIdentifiers() - ); + const allIdentifiers = this.allIdentifiers; + const allHardwareIdentifiers = this.allHardwareIdentifiers; + if (!allIdentifiers.length && !allHardwareIdentifiers.length) { + return []; + } + return txCore.database.actions.findMany(allIdentifiers, allHardwareIdentifiers); } /** @@ -96,12 +108,27 @@ export class BasePlayer { }); //Remove entries from whitelistApprovals & whitelistRequests + const allIds = this.allIdentifiers; const allIdsFilter = (x: DatabaseWhitelistApprovalsType) => { - return this.ids.includes(x.identifier); + return allIds.includes(x.identifier); } txCore.database.whitelist.removeManyApprovals(allIdsFilter); txCore.database.whitelist.removeManyRequests({ license: this.license }); } + + /** + * Removes specific IDs/HWIDs from the player. + */ + removeIds(idsToDelete: string[]) { + if (!this.dbData) throw new Error(`cannot remove ids for a player that has no dbData`); + if (!this.license) throw new Error(`cannot remove ids for a player that has no license`); + if (!Array.isArray(idsToDelete) || !idsToDelete.length) return; + + //Remove from dbData + const newIds = this.dbData.ids.filter(x => !idsToDelete.includes(x)); + const newHwids = this.dbData.hwids.filter(x => !idsToDelete.includes(x)); + this.mutateDbData({ ids: newIds, hwids: newHwids }); + } } @@ -118,7 +145,7 @@ export class ServerPlayer extends BasePlayer { readonly #fxPlayerlist: FxPlayerlist; // readonly psid: string; //TODO: calculate player session id (sv mutex, netid, rollover id) here readonly netid: number; - readonly tsConnected = now(); + readonly tsConnected: number = now(); readonly isRegistered: boolean; readonly #minuteCronInterval?: ReturnType; // #offlineDbDataCacheTimeout?: ReturnType; @@ -146,8 +173,8 @@ export class ServerPlayer extends BasePlayer { //NOTE: ignoring IP completely const { validIdsArray, validIdsObject } = parsePlayerIds(playerData.ids); this.license = validIdsObject.license; - this.ids = validIdsArray; - this.hwids = playerData.hwids.filter(x => { + this.idsOnline = validIdsArray; + this.hwidsOnline = playerData.hwids.filter(x => { return typeof x === 'string' && consts.regexValidHwidToken.test(x); }); @@ -190,15 +217,15 @@ export class ServerPlayer extends BasePlayer { displayName: this.displayName, pureName: this.pureName, tsLastConnection: this.tsConnected, - ids: union(dbPlayer.ids, this.ids), - hwids: union(dbPlayer.hwids, this.hwids), + ids: union(dbPlayer.ids, this.idsOnline), + hwids: union(dbPlayer.hwids, this.hwidsOnline), }); } else { //Register player to the database const toRegister = { license: this.license, - ids: this.ids, - hwids: this.hwids, + ids: this.idsOnline, + hwids: this.hwidsOnline, displayName: this.displayName, pureName: this.pureName, playTime: 0, @@ -308,11 +335,13 @@ export class ServerPlayer extends BasePlayer { /** - * Marks this player as disconnected, clears dbData (mem optimization) and clears minute cron + * Marks this player as disconnected, and clears minute cron */ disconnect() { this.isConnected = false; // this.dbData = false; + this.idsOnline = []; + this.hwidsOnline = []; clearInterval(this.#minuteCronInterval); } } @@ -344,8 +373,6 @@ export class DatabasePlayer extends BasePlayer { //fill in data this.license = license; - this.ids = this.dbData.ids; - this.hwids = this.dbData.hwids; this.displayName = this.dbData.displayName; this.pureName = this.dbData.pureName; } diff --git a/core/lib/xss.js b/core/lib/xss.js deleted file mode 100644 index 26aabc933..000000000 --- a/core/lib/xss.js +++ /dev/null @@ -1,13 +0,0 @@ -import xssClass from 'xss'; - - -/** - * Returns a function with the passed whitelist parameter. - * https://github.com/leizongmin/js-xss#whitelist - */ -export default (customWL = []) => { - const xss = new xssClass.FilterXSS({ - whiteList: customWL, - }); - return (x) => xss.process(x); -}; diff --git a/core/modules/AdminStore/index.js b/core/modules/AdminStore/index.js index 422fffd10..42b653be3 100644 --- a/core/modules/AdminStore/index.js +++ b/core/modules/AdminStore/index.js @@ -13,6 +13,7 @@ const console = consoleFactory(modulename); //NOTE: The way I'm doing versioning right now is horrible but for now it's the best I can do //NOTE: I do not need to version every admin, just the file itself +//NOTE: The only reason every admin has a schema is because I did not want to change the format of the file const ADMIN_SCHEMA_VERSION = 1; @@ -46,7 +47,7 @@ export default class AdminStore { //FIXME: move to a separate file //TODO: maybe put in @shared so the frontend's UnauthorizedPage can use it //TODO: when migrating the admins page to react, definitely put this in @shared so the front rendering doesn't depend on the backend response - lessons learned from the settings page. - //FIXME: if not using enums, definitely use so other type of type safety + //FIXME: if not using enums, definitely use some other type of type safety //FIXME: maybe rename all_permissions to `administrator` (just like discord) or `super_admin` and rename the `Admins` page to `Users`. This fits better with how people use txAdmin as "mods" are not really admins this.registeredPermissions = { 'all_permissions': 'All Permissions', @@ -61,12 +62,13 @@ export default class AdminStore { 'server.cfg.editor': 'Read/Write server.cfg', //FIXME: rename to server.cfg_editor 'txadmin.log.view': 'View System Logs', //FIXME: rename to system.log.view 'server.log.view': 'View Server Logs', + 'players.remove_ids': 'Remove Player IDs', 'menu.vehicle': 'Spawn / Fix Vehicles', 'menu.clear_area': 'Reset world area', 'menu.viewids': 'View Player IDs in-game', //be able to see the ID of the players 'players.direct_message': 'Direct Message', - 'players.whitelist': 'Whitelist', + 'players.whitelist': 'Allowlist', 'players.warn': 'Warn', 'players.kick': 'Kick', 'players.ban': 'Ban', @@ -156,7 +158,7 @@ export default class AdminStore { //Handling password let password_hash, password_temporary; - if(password){ + if (password) { password_hash = isPlainTextPassword ? GetPasswordHash(password) : password; // password_temporary = false; //undefined will do the same } else { @@ -219,7 +221,7 @@ export default class AdminStore { return { name: user.name, master: user.master, - providers: Object.keys(user.providers), + providers: user.providers, permissions: user.permissions, }; }); @@ -339,7 +341,7 @@ export default class AdminStore { restore(); } } catch (error) { - console.error(`Cannot check admins file integrity: ${error.message}`); + console.error(`Failed to check admins file integrity: ${error.message}`); } } @@ -348,8 +350,8 @@ export default class AdminStore { * Add a new admin to the admins file * NOTE: I'm fully aware this coud be optimized. Leaving this way to improve readability and error verbosity * @param {string} name - * @param {object|undefined} citizenfxData or false - * @param {object|undefined} discordData or false + * @param {object|false} citizenfxData or false + * @param {object|false} discordData or false * @param {string} password * @param {array} permissions */ @@ -613,6 +615,7 @@ export default class AdminStore { /** * Notify game server about admin changes + * FIXME: doesn't need to be async, just make sure it never throws */ async refreshOnlineAdmins() { //Refresh auth of all admins connected to socket.io @@ -620,17 +623,20 @@ export default class AdminStore { try { //Getting all admin identifiers + //FIXME: use getAdminsIdentifiers() instead const adminIDs = this.admins.reduce((ids, adm) => { const adminIDs = Object.keys(adm.providers).map((pName) => adm.providers[pName].identifier); return ids.concat(adminIDs); }, []); //Finding online admins - const playerList = txCore.fxPlayerlist.getPlayerList(); - const onlineIDs = playerList.filter((p) => { - return p.ids.some((i) => adminIDs.includes(i)); - }).map((p) => p.netid); - + const onlineIDs = []; + const { idsFound } = txCore.fxPlayerlist.getAssociatedOnlineNetIds(adminIDs); + for (const netid of idsFound.map(x => x[1])) { + if (!onlineIDs.includes(netid)) { + onlineIDs.push(netid); + } + } txCore.fxRunner.sendEvent('adminsUpdated', onlineIDs); } catch (error) { console.verbose.error('Failed to refreshOnlineAdmins() with error:'); diff --git a/core/modules/ConfigStore/schema/index.ts b/core/modules/ConfigStore/schema/index.ts index 96b78cd3b..3e2a9b4b5 100644 --- a/core/modules/ConfigStore/schema/index.ts +++ b/core/modules/ConfigStore/schema/index.ts @@ -13,8 +13,8 @@ import { SYM_RESET_CONFIG } from "@lib/symbols"; //Type inference utils -type InferConfigScopes = IferConfigValues; -type IferConfigValues = { +type InferConfigScopes = InferConfigValues; +type InferConfigValues = { [K in keyof S]: S[K]['default'] | z.infer; } type WritableValues = { diff --git a/core/modules/ConfigStore/schema/whitelist.ts b/core/modules/ConfigStore/schema/whitelist.ts index c2f466c6f..8eecf2cd0 100644 --- a/core/modules/ConfigStore/schema/whitelist.ts +++ b/core/modules/ConfigStore/schema/whitelist.ts @@ -5,15 +5,15 @@ import consts from "@shared/consts"; const mode = typeDefinedConfig({ - name: 'Whitelist Mode', + name: 'Allowlist Mode', default: 'disabled', - validator: z.enum(['disabled', 'adminOnly', 'approvedLicense', 'discordMember', 'discordRoles']), + validator: z.enum(['disabled', 'adminOnly', 'approvedLicense', 'discordMember', 'discordRoles', 'external']), fixer: SYM_FIXER_DEFAULT, }); const rejectionMessage = typeDefinedConfig({ - name: 'Whitelist Rejection Message', - default: 'Please join http://discord.gg/example and request to be whitelisted.', + name: 'Allowlist Instructions', + default: 'Please join http://discord.gg/example and request to be allowlisted.', validator: z.string(), fixer: SYM_FIXER_DEFAULT, }); @@ -25,7 +25,7 @@ export const polishDiscordRolesArray = (input: string[]) => { } const discordRoles = typeDefinedConfig({ - name: 'Whitelisted Discord Roles', + name: 'Allowlisted Discord Roles', default: [], validator: discordSnowflakeSchema.array().transform(polishDiscordRolesArray), fixer: (input: any) => { diff --git a/core/modules/Database/dao/actions.ts b/core/modules/Database/dao/actions.ts index 093401f7b..47329871f 100644 --- a/core/modules/Database/dao/actions.ts +++ b/core/modules/Database/dao/actions.ts @@ -70,7 +70,8 @@ export default class ActionsDao { .filter(customFilter as (a: DatabaseActionType) => a is T) .filter(idsMatchFilter) .cloneDeep() - .value(); + .value() + .sort((a, b) => a.timestamp - b.timestamp); //FIXME: shouldn't be needed, remove after the autosort migration } catch (error) { const msg = `Failed to search for a registered action database with error: ${(error as Error).message}`; console.verbose.error(msg); diff --git a/core/modules/Database/dao/cleanup.ts b/core/modules/Database/dao/cleanup.ts index ec220ab5c..8cbad9864 100644 --- a/core/modules/Database/dao/cleanup.ts +++ b/core/modules/Database/dao/cleanup.ts @@ -2,6 +2,7 @@ import { DbInstance, SavePriority } from "../instance"; import consoleFactory from '@lib/console'; import { DatabasePlayerType, DatabaseWhitelistApprovalsType, DatabaseWhitelistRequestsType } from '../databaseTypes'; import { now } from '@lib/misc'; +import chalk from "chalk"; const console = consoleFactory('DatabaseDao'); @@ -100,7 +101,7 @@ export default class CleanupDao { //Optimize players //Players that have not joined the last 16 days, and have less than 2 hours of playtime - let playerRemoved; + let playerRemoved = 0; try { const sixteenDaysAgo = now() - (16 * oneDay); const filter = (p: DatabasePlayerType) => { @@ -114,7 +115,8 @@ export default class CleanupDao { //Optimize whitelistRequests + whitelistApprovals //Removing the ones older than 7 days - let wlRequestsRemoved, wlApprovalsRemoved; + let wlRequestsRemoved = 0; + let wlApprovalsRemoved = 0; const sevenDaysAgo = now() - (7 * oneDay); try { const wlRequestsFilter = (req: DatabaseWhitelistRequestsType) => { @@ -131,10 +133,19 @@ export default class CleanupDao { console.error(msg); } - this.db.writeFlag(SavePriority.LOW); - console.ok(`Internal Database optimized. This applies only for the txAdmin internal database, and does not affect your MySQL or framework (ESX/QBCore/etc) databases.`); - console.ok(`- ${playerRemoved} players that haven't connected in the past 16 days and had less than 2 hours of playtime.`); - console.ok(`- ${wlRequestsRemoved} whitelist requests older than a week.`); - console.ok(`- ${wlApprovalsRemoved} whitelist approvals older than a week.`); + //Skip if failed or nothing was removed + if (playerRemoved || wlRequestsRemoved || wlApprovalsRemoved) { + this.db.writeFlag(SavePriority.LOW); + console.ok(`Internal Database optimized. This applies only for the txAdmin internal database, and does not affect your MySQL or framework (ESX/QBCore/etc) databases.`); + } + if (playerRemoved) { + console.ok(chalk.dim(`- ${playerRemoved} players that haven't connected in the past 16 days and had less than 2 hours of playtime.`)); + } + if (wlRequestsRemoved) { + console.ok(chalk.dim(`- ${wlRequestsRemoved} whitelist requests older than a week.`)); + } + if (wlApprovalsRemoved) { + console.ok(chalk.dim(`- ${wlApprovalsRemoved} whitelist approvals older than a week.`)); + } } } diff --git a/core/modules/DiscordBot/defaultJsons.ts b/core/modules/DiscordBot/defaultJsons.ts index e4182ebd7..e7437d832 100644 --- a/core/modules/DiscordBot/defaultJsons.ts +++ b/core/modules/DiscordBot/defaultJsons.ts @@ -1,5 +1,3 @@ -import { txEnv } from "@core/globalData"; - export const defaultEmbedJson = JSON.stringify({ "title": "{{serverName}}", "url": "{{serverBrowserUrl}}", @@ -56,10 +54,5 @@ export const defaultEmbedConfigJson = JSON.stringify({ "label": "txAdmin Discord", "url": "https://discord.gg/txAdmin" }, - txEnv.displayAds ? { - "emoji": "😏", - "label": "ZAP-Hosting", - "url": "https://zap-hosting.com/txadmin6" - } : undefined, ].filter(Boolean) }); diff --git a/core/modules/DiscordBot/discordHelpers.ts b/core/modules/DiscordBot/discordHelpers.ts index bd35451b0..a31bca3a6 100644 --- a/core/modules/DiscordBot/discordHelpers.ts +++ b/core/modules/DiscordBot/discordHelpers.ts @@ -6,7 +6,6 @@ const console = consoleFactory(modulename); const allEmojis = new Set(orderedEmojis); - /** * Generic embed generation functions */ diff --git a/core/modules/DiscordBot/index.ts b/core/modules/DiscordBot/index.ts index 2594513ea..7645dc6b4 100644 --- a/core/modules/DiscordBot/index.ts +++ b/core/modules/DiscordBot/index.ts @@ -339,13 +339,19 @@ export default class DiscordBot { .map((x) => x[0]) if (prohibitedPermsInUse.length) { const name = this.#client.user.username; - const perms = prohibitedPermsInUse.includes('Administrator') - ? 'Administrator' - : prohibitedPermsInUse.join(', '); - return sendError( - `This bot (${name}) has dangerous permissions (${perms}) and for your safety the bot has been disabled.`, - { code: 'DangerousPermission' } - ); + if(prohibitedPermsInUse.includes('Administrator')) { + return sendError( + `This bot (\`${name}\`) has \`Administrator\` permission and for your safety the bot has been disabled.`, + { code: 'DangerousPermission' } + ); + } else { + const permsList = prohibitedPermsInUse.map((x) => `\`${x}\``).join(', '); + const plural = prohibitedPermsInUse.length > 1 ? 'has dangerous permissions' : 'has a dangerous permission'; + return sendError( + `This bot (\`${name}\`) ${plural} (${permsList}) and for your safety the bot has been disabled.`, + { code: 'DangerousPermission' } + ); + } } //Fetching announcements channel diff --git a/core/modules/FxMonitor/utils.ts b/core/modules/FxMonitor/utils.ts index 1ea3277ad..3241bc0bf 100644 --- a/core/modules/FxMonitor/utils.ts +++ b/core/modules/FxMonitor/utils.ts @@ -71,6 +71,17 @@ export class Stopwatch { return Math.floor(elapsedMs / 1000); } } + + /** + * Returns the elapsed time in milliseconds or Infinity if not started. + */ + get elapsedMs() { + if (this.tsStart === null) { + return Infinity; + } else { + return Date.now() - this.tsStart; + } + } } diff --git a/core/modules/FxPlayerlist/index.ts b/core/modules/FxPlayerlist/index.ts index 049fffd83..eaee840cc 100644 --- a/core/modules/FxPlayerlist/index.ts +++ b/core/modules/FxPlayerlist/index.ts @@ -33,7 +33,7 @@ export type PlayerDropEvent = { * A list with 50k connected players will weight around 26mb, meaning no optimization is required there. */ export default class FxPlayerlist { - #playerlist: (ServerPlayer | undefined)[] = []; + #playerlist: (ServerPlayer | undefined)[] = []; //FIXME: make continuous array instead of indexed by netid licenseCache: [mutexid: string, license: string][] = []; licenseCacheLimit = 50_000; //mutex+id+license * 50_000 = ~4mb joinLeaveLog: [ts: number, isJoin: boolean][] = []; @@ -123,20 +123,21 @@ export default class FxPlayerlist { netid: p!.netid, displayName: p!.displayName, pureName: p!.pureName, - ids: p!.ids, license: p!.license, }); }); } + /** * Returns a specifc ServerPlayer or undefined. * NOTE: this returns the actual object and not a deep clone! */ getPlayerById(netid: number) { - return this.#playerlist[netid]; + return this.#playerlist[netid]; //FIXME: do this.#playerlist.find() instead } + /** * Returns a specifc ServerPlayer or undefined. * NOTE: this returns the actual object and not a deep clone! @@ -145,6 +146,7 @@ export default class FxPlayerlist { return this.#playerlist.filter(p => p && p.license === searchLicense && p.isConnected) as ServerPlayer[]; } + /** * Returns a set of all online players' licenses. */ @@ -152,6 +154,33 @@ export default class FxPlayerlist { return new Set(this.#playerlist.filter(p => p && p.isConnected).map(p => p!.license)); } + + /** + * Returns a list of online players' netids associated with each ID/HWID provided. + */ + getAssociatedOnlineNetIds(targetIds: string[] | null = null, targetHwids: string[] | null = null) { + type IdAssociation = [id: string, netid: number]; + if (!targetIds?.length && !targetHwids?.length) { + return { + idsFound: [] as IdAssociation[], + hwidsFound: [] as IdAssociation[], + } + } + const idsFound: IdAssociation[] = []; + const hwidsFound: IdAssociation[] = []; + for (const player of this.#playerlist.filter(p => p && p.isConnected) as ServerPlayer[]) { + targetIds?.filter(id => player.idsOnline.includes(id)).forEach(id => { + idsFound.push([id, player.netid]); + }); + targetHwids?.filter(hwid => player.hwidsOnline.includes(hwid)).forEach(hwid => { + hwidsFound.push([hwid, player.netid]); + }); + } + + return { idsFound, hwidsFound }; + } + + /** * Receives initial data callback from ServerPlayer and dispatches to the server as stdin. */ @@ -188,7 +217,7 @@ export default class FxPlayerlist { type: 'playerJoining', src: payload.id, ts: currTs, - data: { ids: this.#playerlist[payload.id]!.ids } + data: { ids: svPlayer.idsOnline } }], mutex); txCore.webServer.webSocket.buffer('playerlist', { mutex, @@ -196,7 +225,6 @@ export default class FxPlayerlist { netid: svPlayer.netid, displayName: svPlayer.displayName, pureName: svPlayer.pureName, - ids: svPlayer.ids, license: svPlayer.license, }); } catch (error) { diff --git a/core/modules/FxResources.ts b/core/modules/FxResources.ts index 4fc7b3bdf..fdf2546c0 100644 --- a/core/modules/FxResources.ts +++ b/core/modules/FxResources.ts @@ -1,4 +1,5 @@ const modulename = 'FxResources'; +import { cloneDeep } from 'lodash-es'; import consoleFactory from '@lib/console'; import { Stopwatch } from './FxMonitor/utils'; const console = consoleFactory(modulename); @@ -41,14 +42,19 @@ export default class FxResources { public resourceReport?: ResourceReportType; private resBooting: ResPendingStartState | null = null; private resBootLog: ResBootLogEntry[] = []; + private prevBootLog: ResBootLogEntry[] | null = null; /** * Reset boot state on server close */ handleServerClose() { - this.resBooting = null; + //Save the previous boot log + if (this.resBootLog.length > 0) { + this.prevBootLog = this.resBootLog; + } this.resBootLog = []; + this.resBooting = null; } @@ -67,11 +73,22 @@ export default class FxResources { } } else if (event === 'onResourceStart') { //Resource started - this.resBootLog.push({ - resource, - duration: this.resBooting?.time.elapsed ?? 0, - tsBooted: Date.now(), - }) + if (this.resBooting?.name === resource) { + this.resBootLog.push({ + resource, + duration: this.resBooting.time.elapsedMs ?? -1, + tsBooted: Date.now(), + }); + } else { + if (resource !== 'monitor') { + console.verbose.warn(`Resource ${resource} started while ${this.resBooting?.name ?? 'unknown'} was booting`); + } + this.resBootLog.push({ + resource, + duration: -1, + tsBooted: Date.now(), + }); + } } } @@ -91,6 +108,13 @@ export default class FxResources { } } + /** + * Getter for the latest boot log + */ + public get latestBootLog() { + return cloneDeep(this.resBooting ? this.resBootLog : this.prevBootLog); + } + /** * Handle resource report. diff --git a/core/modules/FxRunner/index.ts b/core/modules/FxRunner/index.ts index 4a2c6e945..fd4530cc3 100644 --- a/core/modules/FxRunner/index.ts +++ b/core/modules/FxRunner/index.ts @@ -369,7 +369,7 @@ export default class FxRunner { * Useful for when we change txAdmin settings and want it to reflect on the server. * This will also fire the `txAdmin:event:configChanged` */ - private async updateMutableConvars() { + public async updateMutableConvars() { console.log('Updating FXServer ConVars.'); try { await setupCustomLocaleFile(); @@ -377,11 +377,12 @@ export default class FxRunner { for (const [set, convar, value] of convarList) { this.sendCommand(set, [convar, value], SYM_SYSTEM_AUTHOR); } - return this.sendEvent('configChanged'); + this.sendEvent('configChanged'); + return convarList; } catch (error) { console.verbose.error('Error updating FXServer ConVars'); console.verbose.dir(error); - return false; + return null; } } diff --git a/core/modules/FxRunner/utils.ts b/core/modules/FxRunner/utils.ts index 1d4670e3f..c79953988 100644 --- a/core/modules/FxRunner/utils.ts +++ b/core/modules/FxRunner/utils.ts @@ -26,7 +26,12 @@ export const childProcessEventBlackHole = (...args: any[]) => { * Returns a tuple with the convar name and value, formatted for the server command line */ export const getMutableConvars = (isCmdLine = false) => { - const checkPlayerJoin = txConfig.banlist.enabled || txConfig.whitelist.mode !== 'disabled'; + const checkPlayerJoin = txConfig.banlist.enabled || ( + txConfig.whitelist.mode !== 'disabled' + && txConfig.whitelist.mode !== 'external' + ); + const appearAllowlisted = txConfig.whitelist.mode !== 'disabled'; + const convars: RawConvarSetTuple[] = [ ['setr', 'locale', txConfig.general.language ?? 'en'], ['set', 'serverName', txConfig.general.serverName ?? 'txAdmin'], @@ -41,9 +46,20 @@ export const getMutableConvars = (isCmdLine = false) => { ['set', 'hideDefaultWarning', txConfig.gameFeatures.hideDefaultWarning], ['set', 'hideDefaultScheduledRestartWarning', txConfig.gameFeatures.hideDefaultScheduledRestartWarning], + //Server variables + ['sets', 'sv_appearAllowlisted', appearAllowlisted], + // //NOTE: no auto update, maybe we shouldn't tie core and server verbosity anyways // ['setr', 'verbose', console.isVerbose], ]; + + if (appearAllowlisted && txConfig.whitelist.rejectionMessage) { + const instructions = isCmdLine + ? txConfig.whitelist.rejectionMessage.replaceAll('\n', '\\n') + : txConfig.whitelist.rejectionMessage; + convars.push(['sets', 'sv_allowlistInstructions', instructions]); + } + return convars.map((c) => polishConvarSetTuple(c, isCmdLine)); }; @@ -53,7 +69,7 @@ type ConvarSetTuple = [setter: string, name: string, value: string]; const polishConvarSetTuple = ([setter, name, value]: RawConvarSetTuple, isCmdLine = false): ConvarSetTuple => { return [ isCmdLine ? `+${setter}` : setter, - 'txAdmin-' + name, + name.startsWith('sv_') ? name : 'txAdmin-' + name, value.toString(), ]; } @@ -63,6 +79,7 @@ export const mutableConvarConfigDependencies = [ 'gameFeatures.*', 'banlist.enabled', 'whitelist.mode', + 'whitelist.rejectionMessage', ]; diff --git a/core/modules/Logger/FXServerLogger/ConsoleTransformer.ts b/core/modules/Logger/FXServerLogger/ConsoleTransformer.ts index 188e92227..8672ee97d 100644 --- a/core/modules/Logger/FXServerLogger/ConsoleTransformer.ts +++ b/core/modules/Logger/FXServerLogger/ConsoleTransformer.ts @@ -25,8 +25,8 @@ type StyleChannelConfig = { //Precalculating some styles const chalkToStr = (color: ChalkInstance) => color('\x00').split('\x00')[0]; -const precalcMarkerAdminCmd = chalkToStr(chalk.bgHex('#e6b863').black); -const precalcMarkerSystemCmd = chalkToStr(chalk.bgHex('#36383D').hex('#CCCCCC')); +const precalcMarkerAdminCmd = chalkToStr(chalk.bgAnsi256(180).black); +const precalcMarkerSystemCmd = chalkToStr(chalk.bgAnsi256(237).ansi256(252)); const precalcMarkerInfo = chalkToStr(chalk.bgBlueBright.black); const ANSI_RESET = '\x1B[0m'; const ANSI_ERASE_LINE = '\x1b[K'; diff --git a/core/modules/Logger/FXServerLogger/index.ts b/core/modules/Logger/FXServerLogger/index.ts index eeed9b9fb..bf13276dd 100644 --- a/core/modules/Logger/FXServerLogger/index.ts +++ b/core/modules/Logger/FXServerLogger/index.ts @@ -53,7 +53,10 @@ export default class FXServerLogger extends LoggerBase { * Returns a string with short usage stats */ getUsageStats() { - return `Buffer: ${bytes(this.recentBuffer.length)}, lrErrors: ${this.lrErrors}`; + return { + buffer: bytes(this.recentBuffer.length)!, + lrErrors: this.lrErrors, + }; } diff --git a/core/modules/Logger/LoggerBase.ts b/core/modules/Logger/LoggerBase.ts index b70f100bc..de594b6a1 100644 --- a/core/modules/Logger/LoggerBase.ts +++ b/core/modules/Logger/LoggerBase.ts @@ -17,7 +17,7 @@ export class LoggerBase { lrStream: rfs.RotatingFileStream; lrErrors = 0; public activeFilePath: string; - private lrLastError: string | undefined; + public lrLastError: string | undefined; private basePath: string; private logNameRegex: RegExp; diff --git a/core/modules/Logger/handlers/admin.js b/core/modules/Logger/handlers/admin.js index 0c52975ef..00fc477d8 100644 --- a/core/modules/Logger/handlers/admin.js +++ b/core/modules/Logger/handlers/admin.js @@ -28,7 +28,10 @@ export default class AdminLogger extends LoggerBase { * Returns a string with short usage stats */ getUsageStats() { - return `Writes: ${this.writeCounter}, lrErrors: ${this.lrErrors}`; + return { + writes: this.writeCounter, + lrErrors: this.lrErrors, + }; } /** @@ -65,12 +68,13 @@ export default class AdminLogger extends LoggerBase { */ write(author, action, type = 'default') { let saveMsg; + const prefix = `[${author}]`; if (type === 'command') { - saveMsg = `[${author}] executed "${action}"`; - console.log(`${author} executed ` + chalkInversePad(action)); + saveMsg = `executed "${action}"`; + console.log(prefix, `executed ` + chalkInversePad(action)); } else { saveMsg = action; - console.log(saveMsg); + console.log(prefix, saveMsg); } this.writeSystem(author, saveMsg); } diff --git a/core/modules/Logger/handlers/server.js b/core/modules/Logger/handlers/server.js index 2d29b1c79..a1c6576f0 100644 --- a/core/modules/Logger/handlers/server.js +++ b/core/modules/Logger/handlers/server.js @@ -94,17 +94,22 @@ export default class ServerLogger extends LoggerBase { ? `${bytes(bufferJsonSizeRes.bytesPerElement)}/e` : 'LowCount'; - return `Buffer: ${this.recentBuffer.length}, lrErrors: ${this.lrErrors}, mem: ${bufferJsonSizeStr}, rate: ${eventsPerMinStr}`; + return { + buffer: this.recentBuffer.length, + lrErrors: this.lrErrors, + mem: bufferJsonSizeStr, + rate: eventsPerMinStr, + }; } /*** * Returns the recent fxserver buffer containing HTML markers, and not XSS escaped. * The size of this buffer is usually above 64kb, never above 128kb. - * @param {Number} lastN + * @param {number|undefined} lastN * @returns the recent buffer, optionally only the last N elements */ - getRecentBuffer(lastN) { + getRecentBuffer(lastN = undefined) { return (lastN) ? this.recentBuffer.slice(-lastN) : this.recentBuffer; } diff --git a/core/modules/Logger/loggerUtils.ts b/core/modules/Logger/loggerUtils.ts index a0c399af8..7a18aca4e 100644 --- a/core/modules/Logger/loggerUtils.ts +++ b/core/modules/Logger/loggerUtils.ts @@ -26,14 +26,15 @@ export const getLogSizes = async (basePath: string, filterRegex: RegExp) => { const fileStatsArray = fileStatsSizes.map((op, index) => { if (op.status === 'fulfilled') { totalBytes += op.value.size; - return [statNames[index], bytes(op.value.size)]; + return [statNames[index], bytes(op.value.size)!]; } else { return [statNames[index], false]; } - }); + }) as [filename: string, size: string | false][]; return { - total: bytes(totalBytes), - files: Object.fromEntries(fileStatsArray), + totalBytes: bytes(totalBytes)!, + fileCount: statNames.length, + // files: Object.fromEntries(fileStatsArray), //FIXME: unused }; }; diff --git a/core/modules/Metrics/statsUtils.ts b/core/modules/Metrics/statsUtils.ts index 9cc6cfdad..f011a0c3c 100644 --- a/core/modules/Metrics/statsUtils.ts +++ b/core/modules/Metrics/statsUtils.ts @@ -145,20 +145,25 @@ export class QuantileArray { /** * Processes the cache and returns the count and quantiles, if enough data. */ - result(): QuantileArrayOutput { + result(round = false): QuantileArrayOutput { if (this.#cache.size < this.#minSize) { return { enoughData: false, } } else { + const p5 = d3array.quantile(this.#cache.values(), 0.05)!; + const p25 = d3array.quantile(this.#cache.values(), 0.25)!; + const p50 = d3array.quantile(this.#cache.values(), 0.50)!; + const p75 = d3array.quantile(this.#cache.values(), 0.75)!; + const p95 = d3array.quantile(this.#cache.values(), 0.95)!; return { enoughData: true, count: this.#cache.size, - p5: d3array.quantile(this.#cache.values(), 0.05)!, - p25: d3array.quantile(this.#cache.values(), 0.25)!, - p50: d3array.quantile(this.#cache.values(), 0.50)!, - p75: d3array.quantile(this.#cache.values(), 0.75)!, - p95: d3array.quantile(this.#cache.values(), 0.95)!, + p5: round ? Math.round(p5) : p5, + p25: round ? Math.round(p25) : p25, + p50: round ? Math.round(p50) : p50, + p75: round ? Math.round(p75) : p75, + p95: round ? Math.round(p95) : p95, }; } } @@ -185,11 +190,11 @@ export class QuantileArray { } toJSON() { - return this.result(); + return this.result(false); } [inspect.custom]() { - return this.result(); + return this.result(true); } } type QuantileArrayOutput = { @@ -201,8 +206,9 @@ type QuantileArrayOutput = { p75: number; p95: number; } | { + //if less than min size enoughData: false; -}; //if less than min size +}; type QuantileArraySummary = QuantileArrayOutput & { summary: string, @@ -255,7 +261,7 @@ export const estimateArrayJsonSize = (srcArray: any[], minLength: number): JsonE if (srcArray.length <= minLength) { return { enoughData: false }; } - + // Determine a reasonable sample size: // - At least 100 elements // - Up to 10% of the buffer length diff --git a/core/modules/Metrics/svRuntime/index.ts b/core/modules/Metrics/svRuntime/index.ts index 952957fc8..0d1e69f5f 100644 --- a/core/modules/Metrics/svRuntime/index.ts +++ b/core/modules/Metrics/svRuntime/index.ts @@ -373,7 +373,7 @@ export default class SvRuntimeMetrics { } } - //Checking if at least 12h of data + //Checking if at least 36 snapshots (3h of data) in the last 6h if (totalSnapshots < minSnapshots) { return null; //not enough data for meaningful analysis } diff --git a/core/modules/WebServer/authLogic.ts b/core/modules/WebServer/authLogic.ts index 36c11ff9c..4ef36ac03 100644 --- a/core/modules/WebServer/authLogic.ts +++ b/core/modules/WebServer/authLogic.ts @@ -227,6 +227,15 @@ export const nuiAuthLogic = ( return failResp('Invalid Request: source'); } + // DEBUG + // throw new Error('make sure you know what you are doing'); + // return successResp(new AuthedAdmin({ + // name: 'tempadmin', + // master: true, + // permissions: ['all_permissions'], + // password_temporary: false, + // }), undefined); + // Check missing headers if (typeof reqHeader['x-txadmin-token'] !== 'string') { return failResp('Invalid Request: token header'); diff --git a/core/modules/WebServer/getReactIndex.ts b/core/modules/WebServer/getReactIndex.ts index 496285185..7e65e175e 100644 --- a/core/modules/WebServer/getReactIndex.ts +++ b/core/modules/WebServer/getReactIndex.ts @@ -8,6 +8,13 @@ import consts from "@shared/consts"; import consoleFactory from '@lib/console'; import { AuthedAdminType, checkRequestAuth } from "./authLogic"; import { isString } from "@modules/CacheStore"; +import { + escapeHtmlAttribute, + escapeHtmlRawText, + sanitizeClassToken, + sanitizeCssVarName, + sanitizeCssVarValue, +} from "@lib/htmlRenderSafety"; const console = consoleFactory(modulename); // NOTE: it's not possible to remove the hardcoded import of the entry point in the index.html file @@ -121,7 +128,6 @@ export default async function getReactIndex(ctx: CtxWithVars | AuthedCtx) { hasMasterAccount: txCore.adminStore.hasAdmins(true), defaultTheme: tmpDefaultTheme, customThemes: tmpCustomThemes.map(({ name, isDark }) => ({ name, isDark })), - adsData: txEnv.adsData, providerLogo: txHostConfig.providerLogo, providerName: txHostConfig.providerName, hostConfigSource: txHostConfig.sourceName, @@ -139,44 +145,48 @@ export default async function getReactIndex(ctx: CtxWithVars | AuthedCtx) { //Prepare placeholders const replacers: { [key: string]: string } = {}; - replacers.basePath = ``; - replacers.ogTitle = `txAdmin - ${txConfig.general.serverName}`; - replacers.ogDescripttion = `Manage & Monitor your FiveM/RedM Server with txAdmin v${txEnv.txaVersion} atop FXServer ${txEnv.fxsVersion}`; - replacers.txConstsInjection = ``; + replacers.basePath = ``; + replacers.ogTitle = escapeHtmlAttribute(`txAdmin - ${txConfig.general.serverName}`); + replacers.ogDescripttion = escapeHtmlAttribute(`Manage & Monitor your FiveM/RedM Server with txAdmin v${txEnv.txaVersion} atop FXServer ${txEnv.fxsVersion}`); + replacers.txConstsInjection = ``; replacers.devModules = txDevEnv.ENABLED ? devModulesScript : ''; //Prepare custom themes style tag + replacers.customThemesStyle = ''; if (tmpCustomThemes.length) { const cssThemes = []; for (const theme of tmpCustomThemes) { const cssVars = []; + const safeThemeName = sanitizeClassToken(theme.name); + if (!safeThemeName) continue; for (const [name, value] of Object.entries(theme.style)) { - cssVars.push(`--${name}: ${value};`); + const safeName = sanitizeCssVarName(name); + const safeValue = sanitizeCssVarValue(value); + if (!safeName || !safeValue) continue; + cssVars.push(`--${safeName}: ${safeValue};`); } - cssThemes.push(`.theme-${theme.name} { ${cssVars.join(' ')} }`); + cssThemes.push(`.theme-${safeThemeName} { ${cssVars.join(' ')} }`); } - replacers.customThemesStyle = ``; - } else { - replacers.customThemesStyle = ''; + replacers.customThemesStyle = ``; } //Setting the theme class from the cookie - const themeCookie = ctx.cookies.get('txAdmin-theme'); + let htmlClasses = tmpDefaultTheme; + const themeCookie = ctx.cookies.get(consts.cookies.theme); if (themeCookie) { if (tmpDefaultThemes.includes(themeCookie)) { - replacers.htmlClasses = themeCookie; + htmlClasses = themeCookie; } else { const selectedCustomTheme = tmpCustomThemes.find((theme) => theme.name === themeCookie); if (!selectedCustomTheme) { - replacers.htmlClasses = tmpDefaultTheme; + htmlClasses = tmpDefaultTheme; } else { const lightDarkSelector = selectedCustomTheme.isDark ? 'dark' : 'light'; - replacers.htmlClasses = `${lightDarkSelector} theme-${selectedCustomTheme.name}`; + htmlClasses = `${lightDarkSelector} theme-${selectedCustomTheme.name}`; } } - } else { - replacers.htmlClasses = tmpDefaultTheme; } + replacers.htmlClasses = escapeHtmlAttribute(htmlClasses); //Replace let htmlOut = htmlFile; @@ -189,8 +199,7 @@ export default async function getReactIndex(ctx: CtxWithVars | AuthedCtx) { //This is required because of how badly the WebPipe handles "large" files if (!txDevEnv.ENABLED) { const base = ctx.txVars.isWebInterface ? `./` : `nui://monitor/panel/`; - htmlOut = htmlOut.replace(/src="\.\/index-(\w+(?:\.v\d+)?)\.js"/, `src="${base}index-$1.js"`); - htmlOut = htmlOut.replace(/href="\.\/index-(\w+(?:\.v\d+)?)\.css"/, `href="${base}index-$1.css"`); + htmlOut = htmlOut.replaceAll(/(src|href)="\.\/(\w+)-(\w+(?:\.v\d+)?)\.(js|css)"/g, `$1="${base}$2-$3.$4"`); } return htmlOut; diff --git a/core/modules/WebServer/index.ts b/core/modules/WebServer/index.ts index df4d1f0a1..c25f31350 100644 --- a/core/modules/WebServer/index.ts +++ b/core/modules/WebServer/index.ts @@ -27,6 +27,7 @@ import fatalError from '@lib/fatalError'; import { isProxy } from 'node:util/types'; import serveStaticMw from './middlewares/serveStaticMw'; import serveRuntimeMw from './middlewares/serveRuntimeMw'; +import consts from '@shared/consts'; const console = consoleFactory(modulename); const nanoid = customAlphabet(dict49, 32); @@ -54,8 +55,9 @@ export default class WebServer { //Generate cookie key & luaComToken const pathHash = crypto.createHash('shake256', { outputLength: 6 }) .update(txEnv.profilePath) - .digest('hex'); - this.sessionCookieName = `tx:${pathHash}`; + .digest('hex') + .padStart(12, '0'); + this.sessionCookieName = `${consts.cookies.session}:${pathHash}`; this.luaComToken = nanoid(); @@ -63,7 +65,6 @@ export default class WebServer { // Setting up Koa // =================== this.app = new Koa(); - this.app.keys = ['txAdmin' + nanoid()]; // Some people might want to enable it, but we are not guaranteeing XFF security // due to the many possible ways you can connect to koa. diff --git a/core/modules/WebServer/middlewares/ctxUtilsMw.ts b/core/modules/WebServer/middlewares/ctxUtilsMw.ts index ab97cf166..c7963ca58 100644 --- a/core/modules/WebServer/middlewares/ctxUtilsMw.ts +++ b/core/modules/WebServer/middlewares/ctxUtilsMw.ts @@ -2,7 +2,7 @@ const modulename = 'WebCtxUtils'; import path from 'node:path'; import fsp from 'node:fs/promises'; import ejs from 'ejs'; -import xssInstancer from '@lib/xss.js'; +import { escapeHtmlAttribute, escapeHtmlContent, escapeHtmlRawText, sanitizeSimpleHtml } from '@lib/htmlRenderSafety'; import { txDevEnv, txEnv, txHostConfig } from '@core/globalData'; import consoleFactory from '@lib/console'; import getReactIndex, { tmpCustomThemes } from '../getReactIndex'; @@ -18,6 +18,7 @@ export type CtxTxUtils = { send: (data: T) => void; utils: { render: (view: string, data?: { headerTitle?: string, [key: string]: any }) => Promise; + renderMessage: (message: string, options?: { headerTitle?: string; }) => Promise; error: (httpStatus?: number, message?: string) => void; serveReactIndex: () => Promise; legacyNavigateToPage: (href: string) => void; @@ -25,7 +26,6 @@ export type CtxTxUtils = { } //Helper functions -const xss = xssInstancer(); const getRenderErrorText = (view: string, error: Error, data: any) => { console.error(`Error rendering ${view}.`); console.verbose.dir(error); @@ -33,10 +33,10 @@ const getRenderErrorText = (view: string, error: Error, data: any) => { return [ '
',
         `Error rendering '${view}'.`,
-        `Message: ${xss(error.message)}`,
+        `Message: ${escapeHtmlContent(error.message)}`,
         'The data provided was:',
         '================',
-        xss(JSON.stringify(data, null, 2)),
+        escapeHtmlContent(JSON.stringify(data, null, 2)),
         '
', ].join('\n'); }; @@ -46,7 +46,7 @@ const getWebViewPath = (view: string) => { }; const getJavascriptConsts = (allConsts: NonNullable = {}) => { return Object.entries(allConsts) - .map(([name, val]) => `const ${name} = ${JSON.stringify(val)};`) + .map(([name, val]) => `const ${name} = ${escapeHtmlRawText(JSON.stringify(val))};`) .join(' '); }; function getEjsOptions(filePath: string) { @@ -88,7 +88,7 @@ body { Redirecting to {{href}}...

diff --git a/web/main/diagnostics.ejs b/web/main/diagnostics.ejs deleted file mode 100644 index 32cf5977a..000000000 --- a/web/main/diagnostics.ejs +++ /dev/null @@ -1,314 +0,0 @@ -<%- await include('parts/header.ejs', locals) %> - - -
-
- -
-
Environment:
-
- <% if (host.error) { %> - <%- host.error %> - <% } else { %> - Node: <%= host.static.nodeVersion %>
- OS: <%= host.static.osDistro %>
- Username: <%= host.static.username %>
- - CPU Model: <%- host.static.cpu.manufacturer %> <%- host.static.cpu.brand %>
- CPU Stats: - <%- host.static.cpu.physicalCores %>c/<%- host.static.cpu.cores %>t - - <%- host.static.cpu.speedMin %> GHz - <%- host.static.cpu.clockWarning %>
- <% if (host.dynamic) { %> - CPU Usage: <%= host.dynamic.cpuUsage %>%
- Memory: - <%= host.dynamic.memory.usage %>% - (<%= host.dynamic.memory.used.toFixed(2) %>/<%= host.dynamic.memory.total.toFixed(2) %>) - <% } else { %> - Dynamic usage data not available. - <% } %> - <% } %> -
-
- - -
-
txAdmin Runtime:
-
- Uptime: <%= txadmin.uptime %>
- Versions: - v<%= txAdminVersion %> / - b<%= fxServerVersion %>
- Database File Size: <%= txadmin.databaseFileSize %>
- Env:
- ├─ FXServer: <%= txadmin.txEnv.fxsPath %>
- ├─ Profile: <%= txadmin.txEnv.profilePath %>
- ├─ Defaults: <%= txadmin.txHostConfig.defaults.length > 0 ? txadmin.txHostConfig.defaults.join(', ') : '--' %>
- ├─ Interface: <%= txadmin.txHostConfig.netInterface ?? '--' %>
- └─ Provider: <%= txadmin.txHostConfig.providerName ?? '--' %>
- Monitor:
- ├─ HB Fails: - HTTP <%= txadmin.monitor.hbFails.http %> / - FD3 <%= txadmin.monitor.hbFails.fd3 %>
- └─ Restarts: - BT <%= txadmin.monitor.restarts.bootTimeout %> / - CL <%= txadmin.monitor.restarts.close %> / - HB <%= txadmin.monitor.restarts.heartBeat %> / - HC <%= txadmin.monitor.restarts.healthCheck %> / - BO <%= txadmin.monitor.restarts.both %>
- Performance Times:
- ├─ BanCheck: <%= txadmin.performance.banCheck %>
- ├─ WhitelistCheck: <%= txadmin.performance.whitelistCheck %>
- ├─ PlayersTable: <%= txadmin.performance.playersTableSearch %>
- ├─ HistoryTable: <%= txadmin.performance.historyTableSearch %>
- ├─ DatabaseSave: <%= txadmin.performance.databaseSave %>
- └─ PerfCollection: <%= txadmin.performance.perfCollection %>
- Memory:
- ├─ Heap: <%= txadmin.memoryUsage.heap_used %> / <%= txadmin.memoryUsage.heap_limit %> (<%= txadmin.memoryUsage.heap_pct %>%)
- ├─ Physical: <%= txadmin.memoryUsage.physical %>
- └─ Peak. Alloc.: <%= txadmin.memoryUsage.peak_malloced %>
- Logger Status:
- ├─ Storage Size: <%= txadmin.logger.storageSize %>
- ├─ Admin: <%= txadmin.logger.statusAdmin %>
- ├─ FXServer: <%= txadmin.logger.statusFXServer %>
- └─ Server: <%= txadmin.logger.statusServer %>
-
-
- - -
- <%- message %> -
- -
- - - -
- -
-
Diagnostics Report:
-
-
-
- To receive txAdmin Support, it is recommended that you send the diagnostics data directly to the Support Team. -
-
- -
-
-
-
- - -
-
FXServer /info.json:
-
- <% if (fxserver.versionMismatch) { %> - - <% } %> - <% if (fxserver.error !== false) { %> - <%- fxserver.error %> - <% } else { %> - Status: <%= fxserver.status %>
- Version: <%= fxserver.version %>
- Resources: <%= fxserver.resources %>
- OneSync: <%= fxserver.onesync %>
- Max Clients: <%= fxserver.maxClients %>
- txAdmin Version: <%= fxserver.txAdminVersion %>
- <% } %> -
-
- - -
-
Processes:
-
- <% if (!proccesses.length) { %> - Failed to retrieve processed data.
- Check the terminal for more information (if verbosity is enabled) - <% } else { %> - <% for (const process of proccesses) { %> - Process: (<%= process.pid %>) <%= process.name %>
- Parent: <%= process.ppid %>
- Memory: <%= process.memory.toFixed(2) %>MB
- CPU: <%= process.cpu.toFixed(2) %>%
-
- <% } %> - <% } %> -
-
- -
- -
- -<%- await include('parts/footer.ejs', locals) %> - - - - - - diff --git a/web/main/message.ejs b/web/main/message.ejs index fa49db5f9..c5119eaa7 100644 --- a/web/main/message.ejs +++ b/web/main/message.ejs @@ -7,7 +7,7 @@
diff --git a/web/main/resources.ejs b/web/main/resources.ejs index bb81af915..91bb2a1a8 100644 --- a/web/main/resources.ejs +++ b/web/main/resources.ejs @@ -98,20 +98,29 @@ <% if (resource.status === 'started') { %> - > + data-resAction="ensure_res" + data-resName="<%= encodeURIComponent(resource.name) %>" + <%= disableActions %> + > Restart - > + data-resAction="stop_res" + data-resName="<%= encodeURIComponent(resource.name) %>" + <%= disableActions %> + > Stop <% } else { %> - > + data-resAction="ensure_res" + data-resName="<%= encodeURIComponent(resource.name) %>" + <%= disableActions %> + > Start <% } %> @@ -328,6 +337,16 @@ //============================================== Utils function refreshResourceList() { + const resButtons = document.getElementsByClassName('btn-res-action'); + for (const btn of resButtons) { + btn.onclick = (e) => { + e.preventDefault(); + const resAction = e.currentTarget.dataset.resaction; + const resName = decodeURIComponent(e.currentTarget.dataset.resname); + btnCommand(resAction, resName); + }; + } + if ($('#defResCheckbox').is(':checked')) { defaultResources.forEach(defRes => { $(`#res-${defRes}`).show(); diff --git a/web/main/whitelist.ejs b/web/main/whitelist.ejs index db2a9995d..3cc49d5a1 100644 --- a/web/main/whitelist.ejs +++ b/web/main/whitelist.ejs @@ -40,9 +40,9 @@
@@ -54,8 +54,8 @@
- Whitelist Requests:
- Players that tried to join the server but were not whitelisted. + Allowlist Requests:
+ Players that tried to join the server but were not allowlisted.
@@ -118,7 +118,7 @@
- Approved Whitelists Pending Join:
+ Approved Allowlists Pending Join:
Players that are already approved, but haven't joined the server yet.
@@ -430,7 +430,7 @@ async function denyAllRequests(reqId, action){ const confirmOptions = { - title: 'Deny all whitelist requests?', + title: 'Deny all allowlist requests?', content: `Players will still be able to try to join again, and will receive a new request ID.`, }; if (!await txAdminConfirm(confirmOptions)) return; @@ -550,8 +550,8 @@ let identifier = await txAdminPrompt({ modalColor: 'green', confirmBtnClass: 'btn-green', - title: 'Whitelist player', - description: `Type in the Player Identifier you want to whitelist.
+ title: 'Allowlist player', + description: `Type in the Player Identifier you want to allowlist.
This can be any of the following: ${acceptedIdTypes.join(', ')}`, placeholder: 'discord:272800190639898628' }); diff --git a/web/public/img/advert-gportal-192x64.png b/web/public/img/advert-gportal-192x64.png new file mode 100644 index 000000000..b5224c0c1 Binary files /dev/null and b/web/public/img/advert-gportal-192x64.png differ diff --git a/web/public/img/advert-gportal-256x80.png b/web/public/img/advert-gportal-256x80.png new file mode 100644 index 000000000..939c38b9f Binary files /dev/null and b/web/public/img/advert-gportal-256x80.png differ diff --git a/web/public/img/advert-nitrado-192x64.png b/web/public/img/advert-nitrado-192x64.png new file mode 100644 index 000000000..60251fad1 Binary files /dev/null and b/web/public/img/advert-nitrado-192x64.png differ diff --git a/web/public/img/advert-nitrado-256x80.png b/web/public/img/advert-nitrado-256x80.png new file mode 100644 index 000000000..5ab499c6c Binary files /dev/null and b/web/public/img/advert-nitrado-256x80.png differ diff --git a/web/public/img/advert-nodecraft-192x64.png b/web/public/img/advert-nodecraft-192x64.png new file mode 100644 index 000000000..3e26c0f03 Binary files /dev/null and b/web/public/img/advert-nodecraft-192x64.png differ diff --git a/web/public/img/advert-nodecraft-256x80.png b/web/public/img/advert-nodecraft-256x80.png new file mode 100644 index 000000000..d79d9d3d8 Binary files /dev/null and b/web/public/img/advert-nodecraft-256x80.png differ diff --git a/web/public/img/advert-shockbyte-192x64.png b/web/public/img/advert-shockbyte-192x64.png new file mode 100644 index 000000000..7b6b3ccd5 Binary files /dev/null and b/web/public/img/advert-shockbyte-192x64.png differ diff --git a/web/public/img/advert-shockbyte-256x80.png b/web/public/img/advert-shockbyte-256x80.png new file mode 100644 index 000000000..d8bae8b0e Binary files /dev/null and b/web/public/img/advert-shockbyte-256x80.png differ diff --git a/web/public/img/advert-xrealm-192x64.png b/web/public/img/advert-xrealm-192x64.png new file mode 100644 index 000000000..509772d48 Binary files /dev/null and b/web/public/img/advert-xrealm-192x64.png differ diff --git a/web/public/img/advert-xrealm-256x80.png b/web/public/img/advert-xrealm-256x80.png new file mode 100644 index 000000000..f4a5031a1 Binary files /dev/null and b/web/public/img/advert-xrealm-256x80.png differ diff --git a/web/public/img/advert-zaphosting-192x64.png b/web/public/img/advert-zaphosting-192x64.png new file mode 100644 index 000000000..76bdc4d38 Binary files /dev/null and b/web/public/img/advert-zaphosting-192x64.png differ diff --git a/web/public/img/advert-zaphosting-256x80.png b/web/public/img/advert-zaphosting-256x80.png new file mode 100644 index 000000000..a0454dc34 Binary files /dev/null and b/web/public/img/advert-zaphosting-256x80.png differ diff --git a/web/public/img/zap256_black.png b/web/public/img/zap256_black.png deleted file mode 100644 index 79ce4ed19..000000000 Binary files a/web/public/img/zap256_black.png and /dev/null differ diff --git a/web/public/img/zap256_white.png b/web/public/img/zap256_white.png deleted file mode 100644 index f57aeb952..000000000 Binary files a/web/public/img/zap256_white.png and /dev/null differ diff --git a/web/public/js/txadmin/base.js b/web/public/js/txadmin/base.js index f33aee134..18d315911 100644 --- a/web/public/js/txadmin/base.js +++ b/web/public/js/txadmin/base.js @@ -136,12 +136,20 @@ const txAdminAPI = ({type, url, data, dataType, timeout, success, error}) => { url = TX_BASE_PATH + url; timeout = timeout ?? REQ_TIMEOUT_MEDIUM; - dataType = dataType || 'json'; + dataType = dataType ?? 'json'; //This is the expected return type, not request type + let contentType; + if (typeof data === 'object') { + if (data === null) throw new TypeError('txAdminAPI data cannot be null'); + if (Object.prototype.toString.call(data) === '[object Object]') { + data = JSON.stringify(data); + contentType = 'application/json'; + } + } success = success || (() => {}); error = error || (() => {}); const headers = {'X-TxAdmin-CsrfToken': (csrfToken) ? csrfToken : 'not_set'} // console.log(`txAdminAPI Req to: ${url}`); - return $.ajax({type, url, timeout, data, dataType, success, error, headers}); + return $.ajax({type, url, timeout, data, dataType, contentType, success, error, headers}); }; const txAdminAlert = ({content, modalColor, title}) => { diff --git a/web/standalone/setup.ejs b/web/standalone/setup.ejs index 19ffdbed9..135fd3782 100644 --- a/web/standalone/setup.ejs +++ b/web/standalone/setup.ejs @@ -147,8 +147,8 @@
A short server name to be used in txAdmin interface and Chat/Discord messages. - +