From 4db62b55206946043aa174dfb960d3cec34a34f5 Mon Sep 17 00:00:00 2001
From: magi200
Date: Wed, 16 Apr 2025 22:30:27 +0700
Subject: [PATCH] a
---
.eslintignore | 2 +
.eslintrc.cjs | 11 +
.github/CODEOWNERS | 1 +
.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml | 50 +
.github/ISSUE_TEMPLATE/bug_report.md | 30 +
.github/ISSUE_TEMPLATE/config.yml | 5 +
.github/copilot-instructions.md | 6 +
.github/workflows/locale-pull-request.yml | 45 +
.github/workflows/publish-tagged.yml | 60 +
.github/workflows/run-tests.yml | 38 +
.gitignore | 82 +
.husky/.gitignore | 1 +
.husky/commit-msg | 4 +
.husky/pre-commit | 15 +
.license-reportrc | 11 +
.npm-upgrade.json | 8 +
LICENSE | 21 +
README.md | 87 +
commitlint.config.cjs | 25 +
core/.eslintrc.cjs | 83 +
core/.npm-upgrade.json | 48 +
core/boot/checkPreRelease.ts | 48 +
core/boot/getHostVars.ts | 116 +
core/boot/getNativeVars.ts | 85 +
core/boot/getZapVars.ts | 73 +
core/boot/globalPlaceholder.ts | 52 +
core/boot/setup.ts | 67 +
core/boot/setupProcessHandlers.ts | 37 +
core/boot/startReadyWatcher.ts | 193 +
core/deployer/index.js | 216 +
core/deployer/recipeEngine.js | 565 +
core/deployer/recipeParser.ts | 126 +
core/deployer/utils.ts | 40 +
core/global.d.ts | 66 +
core/globalData.ts | 578 +
core/index.ts | 83 +
core/lib/MemCache.ts | 47 +
core/lib/console.test.ts | 48 +
core/lib/console.ts | 388 +
core/lib/diagnostics.ts | 331 +
core/lib/fatalError.ts | 90 +
core/lib/fs.ts | 71 +
core/lib/fxserver/fxsConfigHelper.ts | 776 +
core/lib/fxserver/fxsVersionParser.test.ts | 70 +
core/lib/fxserver/fxsVersionParser.ts | 25 +
core/lib/fxserver/runtimeFiles.ts | 43 +
core/lib/fxserver/scanMonitorFiles.ts | 107 +
core/lib/fxserver/serverData.ts | 236 +
core/lib/got.ts | 12 +
core/lib/host/getHostUsage.ts | 60 +
core/lib/host/getOsDistro.js | 97 +
core/lib/host/isIpAddressLocal.ts | 29 +
core/lib/host/pidUsageTree.js | 7 +
core/lib/misc.test.ts | 238 +
core/lib/misc.ts | 301 +
core/lib/player/idUtils.test.ts | 57 +
core/lib/player/idUtils.ts | 159 +
core/lib/player/playerClasses.ts | 365 +
core/lib/player/playerFinder.ts | 15 +
core/lib/player/playerResolver.ts | 62 +
core/lib/quitProcess.ts | 18 +
core/lib/symbols.ts | 16 +
core/lib/xss.js | 13 +
core/modules/AdminStore/index.js | 682 +
.../modules/AdminStore/providers/CitizenFX.ts | 107 +
core/modules/CacheStore.ts | 143 +
core/modules/ConfigStore/changelog.ts | 30 +
core/modules/ConfigStore/configMigrations.ts | 84 +
core/modules/ConfigStore/configParser.test.ts | 274 +
core/modules/ConfigStore/configParser.ts | 218 +
core/modules/ConfigStore/index.ts | 273 +
core/modules/ConfigStore/schema/banlist.ts | 92 +
core/modules/ConfigStore/schema/discordBot.ts | 69 +
.../ConfigStore/schema/gameFeatures.ts | 88 +
core/modules/ConfigStore/schema/general.ts | 28 +
core/modules/ConfigStore/schema/index.ts | 55 +
core/modules/ConfigStore/schema/logger.ts | 41 +
core/modules/ConfigStore/schema/oldConfig.ts | 168 +
core/modules/ConfigStore/schema/restarter.ts | 39 +
core/modules/ConfigStore/schema/server.ts | 72 +
core/modules/ConfigStore/schema/utils.ts | 53 +
core/modules/ConfigStore/schema/webServer.ts | 32 +
core/modules/ConfigStore/schema/whitelist.ts | 43 +
core/modules/ConfigStore/utils.test.ts | 157 +
core/modules/ConfigStore/utils.ts | 120 +
core/modules/Database/dao/actions.ts | 240 +
core/modules/Database/dao/cleanup.ts | 140 +
core/modules/Database/dao/players.ts | 110 +
core/modules/Database/dao/stats.ts | 111 +
core/modules/Database/dao/whitelist.ts | 133 +
core/modules/Database/databaseTypes.ts | 68 +
core/modules/Database/dbUtils.ts | 124 +
core/modules/Database/index.ts | 76 +
core/modules/Database/instance.ts | 260 +
core/modules/Database/migrations.js | 173 +
core/modules/DiscordBot/commands/info.ts | 147 +
core/modules/DiscordBot/commands/status.ts | 294 +
core/modules/DiscordBot/commands/whitelist.ts | 120 +
core/modules/DiscordBot/defaultJsons.ts | 65 +
core/modules/DiscordBot/discordHelpers.ts | 113 +
core/modules/DiscordBot/index.ts | 504 +
.../DiscordBot/interactionCreateHandler.ts | 82 +
core/modules/DiscordBot/slash.ts | 115 +
core/modules/FxMonitor/index.ts | 610 +
core/modules/FxMonitor/utils.ts | 393 +
core/modules/FxPlayerlist/index.ts | 237 +
core/modules/FxResources.ts | 143 +
core/modules/FxRunner/ProcessManager.ts | 220 +
core/modules/FxRunner/handleFd3Messages.ts | 159 +
core/modules/FxRunner/index.ts | 530 +
core/modules/FxRunner/utils.test.ts | 159 +
core/modules/FxRunner/utils.ts | 240 +
core/modules/FxScheduler.ts | 319 +
.../Logger/FXServerLogger/ConsoleLineEnum.ts | 10 +
.../FXServerLogger/ConsoleTransformer.ts | 213 +
.../Logger/FXServerLogger/fxsLogger.test.ts | 290 +
.../Logger/FXServerLogger/fxsLoggerUtils.ts | 75 +
core/modules/Logger/FXServerLogger/index.ts | 178 +
core/modules/Logger/LoggerBase.ts | 79 +
core/modules/Logger/handlers/admin.js | 77 +
core/modules/Logger/handlers/server.js | 308 +
core/modules/Logger/index.ts | 47 +
core/modules/Logger/loggerUtils.ts | 59 +
core/modules/Metrics/index.ts | 55 +
.../playerDrop/classifyDropReason.test.ts | 181 +
.../Metrics/playerDrop/classifyDropReason.ts | 296 +
core/modules/Metrics/playerDrop/config.ts | 8 +
core/modules/Metrics/playerDrop/index.ts | 351 +
.../playerDrop/playerDropMigrations.ts | 59 +
.../Metrics/playerDrop/playerDropSchemas.ts | 114 +
.../Metrics/playerDrop/playerDropUtils.ts | 17 +
core/modules/Metrics/statsUtils.test.ts | 198 +
core/modules/Metrics/statsUtils.ts | 303 +
core/modules/Metrics/svRuntime/config.ts | 62 +
core/modules/Metrics/svRuntime/index.ts | 390 +
.../modules/Metrics/svRuntime/logOptimizer.ts | 22 +
.../Metrics/svRuntime/perfParser.test.ts | 127 +
core/modules/Metrics/svRuntime/perfParser.ts | 166 +
core/modules/Metrics/svRuntime/perfSchemas.ts | 94 +
.../Metrics/svRuntime/perfUtils.test.ts | 100 +
core/modules/Metrics/svRuntime/perfUtils.ts | 136 +
core/modules/Metrics/txRuntime/index.ts | 212 +
core/modules/Translator.ts | 136 +
core/modules/UpdateChecker/index.ts | 157 +
.../UpdateChecker/queryChangelogApi.ts | 111 +
.../UpdateChecker/updateRollout.test.ts | 65 +
core/modules/UpdateChecker/updateRollout.ts | 69 +
core/modules/WebServer/authLogic.ts | 269 +
core/modules/WebServer/ctxTypes.ts | 40 +
core/modules/WebServer/getReactIndex.ts | 197 +
core/modules/WebServer/index.ts | 270 +
core/modules/WebServer/middlewares/authMws.ts | 185 +
.../WebServer/middlewares/cacheControlMw.ts | 16 +
.../WebServer/middlewares/ctxUtilsMw.ts | 229 +
.../WebServer/middlewares/ctxVarsMw.ts | 62 +
.../middlewares/globalRateLimiter.ts | 97 +
.../WebServer/middlewares/httpLoadMonitor.ts | 80 +
.../WebServer/middlewares/serveRuntimeMw.ts | 116 +
.../WebServer/middlewares/serveStaticMw.ts | 348 +
.../WebServer/middlewares/sessionMws.ts | 189 +
.../WebServer/middlewares/topLevelMw.ts | 108 +
core/modules/WebServer/router.ts | 134 +
core/modules/WebServer/webSocket.ts | 293 +
core/modules/WebServer/wsRooms/dashboard.ts | 45 +
core/modules/WebServer/wsRooms/liveconsole.ts | 30 +
core/modules/WebServer/wsRooms/playerlist.ts | 20 +
core/modules/WebServer/wsRooms/serverlog.ts | 13 +
core/modules/WebServer/wsRooms/status.ts | 21 +
core/package.json | 88 +
core/routes/adminManager/actions.ts | 251 +
core/routes/adminManager/getModal.ts | 106 +
core/routes/adminManager/page.ts | 49 +
core/routes/advanced/actions.js | 166 +
core/routes/advanced/get.js | 20 +
.../authentication/addMasterCallback.ts | 63 +
core/routes/authentication/addMasterPin.ts | 45 +
core/routes/authentication/addMasterSave.ts | 96 +
.../authentication/changeIdentifiers.ts | 77 +
core/routes/authentication/changePassword.ts | 69 +
core/routes/authentication/getIdentifiers.ts | 27 +
core/routes/authentication/logout.ts | 22 +
core/routes/authentication/oauthMethods.ts | 90 +
.../routes/authentication/providerCallback.ts | 89 +
.../routes/authentication/providerRedirect.ts | 36 +
core/routes/authentication/self.ts | 13 +
core/routes/authentication/verifyPassword.ts | 85 +
core/routes/banTemplates/getBanTemplates.ts | 19 +
core/routes/banTemplates/saveBanTemplates.ts | 53 +
core/routes/cfgEditor/get.js | 38 +
core/routes/cfgEditor/save.js | 74 +
core/routes/deployer/actions.js | 286 +
core/routes/deployer/status.js | 35 +
core/routes/deployer/stepper.js | 95 +
core/routes/devDebug.ts | 74 +
core/routes/diagnostics/page.ts | 37 +
core/routes/diagnostics/sendReport.ts | 172 +
core/routes/fxserver/commands.ts | 194 +
core/routes/fxserver/controls.ts | 81 +
core/routes/fxserver/downloadLog.js | 29 +
core/routes/fxserver/schedule.ts | 69 +
core/routes/history/actionModal.ts | 39 +
core/routes/history/actions.ts | 184 +
core/routes/history/search.ts | 167 +
core/routes/history/stats.ts | 34 +
core/routes/hostStatus.ts | 9 +
core/routes/index.ts | 78 +
core/routes/intercom.ts | 50 +
core/routes/masterActions/actions.ts | 202 +
core/routes/masterActions/getBackup.ts | 34 +
core/routes/masterActions/page.ts | 16 +
core/routes/perfChart.ts | 61 +
core/routes/player/actions.ts | 364 +
core/routes/player/checkJoin.ts | 531 +
core/routes/player/modal.ts | 97 +
core/routes/player/search.ts | 184 +
core/routes/player/stats.ts | 21 +
core/routes/playerDrops.ts | 100 +
core/routes/resources.js | 152 +
core/routes/serverLog.js | 20 +
core/routes/serverLogPartial.js | 37 +
core/routes/settings/getConfigs.ts | 67 +
core/routes/settings/resetServerDataPath.ts | 61 +
core/routes/settings/saveConfigs.ts | 405 +
core/routes/setup/get.ts | 38 +
core/routes/setup/post.js | 429 +
core/routes/systemLogs.ts | 33 +
core/routes/whitelist/actions.ts | 206 +
core/routes/whitelist/list.ts | 93 +
core/routes/whitelist/page.ts | 17 +
core/testing/fileSetup.ts | 61 +
core/testing/globalSetup.ts | 34 +
core/tsconfig.json | 66 +
core/txAdmin.ts | 118 +
core/txManager.ts | 210 +
core/vitest.config.ts | 26 +
docs/banner.png | Bin 0 -> 133039 bytes
docs/custom-server-log.md | 30 +
docs/dev-notes.md | 758 +
docs/development.md | 126 +
docs/discord-status.md | 101 +
docs/env-config.md | 175 +
docs/events.md | 197 +
docs/feature-graveyard.md | 21 +
docs/logs.md | 58 +
docs/menu.md | 117 +
docs/palettes.json | 275 +
docs/permissions.md | 32 +
docs/recipe.md | 262 +
docs/translation.md | 28 +
docs/zaphosting.png | Bin 0 -> 26585 bytes
dynamicAds.json | 36 +
dynamicAds2.json | 18 +
entrypoint.js | 29 +
fxmanifest.lua | 74 +
locale/ar.json | 367 +
locale/bg.json | 367 +
locale/bs.json | 367 +
locale/cs.json | 367 +
locale/da.json | 367 +
locale/de.json | 367 +
locale/el.json | 367 +
locale/en.json | 367 +
locale/es.json | 367 +
locale/et.json | 367 +
locale/fa.json | 367 +
locale/fi.json | 367 +
locale/fr.json | 367 +
locale/hr.json | 367 +
locale/hu.json | 367 +
locale/id.json | 367 +
locale/it.json | 367 +
locale/ja.json | 367 +
locale/lt.json | 367 +
locale/lv.json | 367 +
locale/mn.json | 367 +
locale/ne.json | 367 +
locale/nl.json | 367 +
locale/no.json | 367 +
locale/pl.json | 367 +
locale/pt.json | 367 +
locale/ro.json | 367 +
locale/ru.json | 367 +
locale/sl.json | 367 +
locale/sv.json | 367 +
locale/th.json | 367 +
locale/tr.json | 367 +
locale/uk.json | 367 +
locale/vi.json | 367 +
locale/zh.json | 367 +
nui/index.html | 19 +
nui/package.json | 37 +
nui/public/images/txadmin-redm.png | Bin 0 -> 12127 bytes
nui/public/images/txadmin.png | Bin 0 -> 10390 bytes
nui/public/sounds/announcement.mp3 | Bin 0 -> 20064 bytes
nui/public/sounds/message.mp3 | Bin 0 -> 7848 bytes
nui/public/sounds/warning_open.mp3 | Bin 0 -> 33024 bytes
nui/public/sounds/warning_pulse.mp3 | Bin 0 -> 7605 bytes
nui/src/App.css | 22 +
nui/src/MenuWrapper.tsx | 116 +
nui/src/components/IFramePage/IFramePage.tsx | 51 +
nui/src/components/MainPage/MainPageList.tsx | 562 +
nui/src/components/MainPage/MenuListItem.tsx | 272 +
nui/src/components/MenuRoot.tsx | 38 +
nui/src/components/MenuRootContent.tsx | 69 +
.../PlayerModalErrorBoundary.tsx | 33 +
.../ErrorHandling/PlayerModalHasError.tsx | 43 +
.../components/PlayerModal/PlayerModal.tsx | 214 +
.../PlayerModal/Tabs/DialogActionView.tsx | 420 +
.../PlayerModal/Tabs/DialogBanView.tsx | 291 +
.../PlayerModal/Tabs/DialogBaseView.tsx | 33 +
.../PlayerModal/Tabs/DialogHistoryView.tsx | 202 +
.../PlayerModal/Tabs/DialogIdView.tsx | 121 +
.../PlayerModal/Tabs/DialogInfoView.tsx | 216 +
.../PlayerModal/Tabs/DialogLoadError.tsx | 19 +
nui/src/components/PlayersPage/PlayerCard.tsx | 185 +
.../PlayersPage/PlayerPageHeader.tsx | 168 +
.../PlayersPage/PlayersListEmpty.tsx | 32 +
.../PlayersPage/PlayersListGrid.tsx | 109 +
.../components/PlayersPage/PlayersPage.tsx | 41 +
nui/src/components/WarnPage/WarnPage.tsx | 299 +
nui/src/components/misc/ButtonXS.tsx | 19 +
nui/src/components/misc/HelpTooltip.tsx | 56 +
nui/src/components/misc/PageTabs.tsx | 42 +
nui/src/components/misc/TextField.tsx | 26 +
.../components/misc/TopLevelErrorBoundary.tsx | 67 +
nui/src/hooks/useDebouce.tsx | 19 +
nui/src/hooks/useExitListener.tsx | 29 +
nui/src/hooks/useHudListenersService.tsx | 218 +
nui/src/hooks/useKey.ts | 15 +
nui/src/hooks/useKeyboardNavigation.tsx | 86 +
nui/src/hooks/useListenerForSomething.ts | 40 +
nui/src/hooks/useLocale.ts | 23 +
nui/src/hooks/useNuiEvent.ts | 51 +
nui/src/hooks/useNuiListenersService.tsx | 28 +
nui/src/hooks/usePlayerListListener.ts | 98 +
nui/src/index.css | 15 +
nui/src/index.tsx | 100 +
nui/src/provider/DialogProvider.tsx | 222 +
nui/src/provider/IFrameProvider.tsx | 94 +
nui/src/provider/KeyboardNavProvider.tsx | 69 +
nui/src/provider/PlayerModalProvider.tsx | 121 +
nui/src/provider/TooltipProvider.tsx | 104 +
nui/src/state/healmode.state.ts | 13 +
nui/src/state/isRedm.state.ts | 8 +
nui/src/state/keys.state.ts | 21 +
nui/src/state/page.state.ts | 24 +
nui/src/state/permissions.state.ts | 39 +
nui/src/state/playerDetails.state.ts | 74 +
nui/src/state/playerModal.state.ts | 35 +
nui/src/state/playermode.state.ts | 15 +
nui/src/state/players.state.ts | 163 +
nui/src/state/server.state.ts | 73 +
nui/src/state/teleportmode.state.ts | 15 +
nui/src/state/vehiclemode.state.ts | 15 +
nui/src/state/visibility.state.ts | 17 +
nui/src/styles/module-augmentation.d.ts | 12 +
nui/src/styles/theme-redm.tsx | 75 +
nui/src/styles/theme.tsx | 67 +
nui/src/utils/config.json | 16 +
nui/src/utils/constants.ts | 353 +
nui/src/utils/copyToClipboard.ts | 15 +
nui/src/utils/debugData.ts | 28 +
nui/src/utils/debugLog.ts | 19 +
nui/src/utils/fetchNui.ts | 43 +
nui/src/utils/fetchWebPipe.ts | 56 +
nui/src/utils/generateMockPlayerData.ts | 45 +
nui/src/utils/getNotiDuration.ts | 17 +
nui/src/utils/miscUtils.ts | 69 +
nui/src/utils/registerDebugFunctions.ts | 143 +
nui/src/utils/shouldHelpAlertShow.ts | 29 +
nui/src/utils/vehicleSpawnDialogHelper.ts | 70 +
nui/tsconfig.json | 30 +
nui/tsconfig.node.json | 14 +
nui/vite.config.ts | 80 +
package-lock.json | 16490 +++++++++++++++
package.json | 58 +
panel/.eslintrc.cjs | 22 +
panel/components.json | 16 +
panel/index.html | 29 +
panel/package.json | 88 +
panel/postcss.config.js | 7 +
panel/public/favicon_default.svg | 14 +
panel/public/favicon_offline.svg | 14 +
panel/public/favicon_online.svg | 14 +
panel/public/favicon_partial.svg | 14 +
panel/public/img/discord.png | Bin 0 -> 8388 bytes
panel/public/img/zap_login.png | Bin 0 -> 16765 bytes
panel/public/img/zap_main.png | Bin 0 -> 16374 bytes
panel/src/components/AccountDialog.tsx | 370 +
panel/src/components/Avatar.tsx | 75 +
panel/src/components/BanForm.tsx | 243 +
panel/src/components/BigRadioItem.tsx | 31 +
panel/src/components/BreakpointDebugger.tsx | 39 +
panel/src/components/CardContentOverlay.tsx | 35 +
panel/src/components/ConfirmDialog.tsx | 87 +
panel/src/components/DateTimeCorrected.tsx | 39 +
.../components/DebouncedResizeContainer.tsx | 92 +
panel/src/components/Divider.tsx | 15 +
panel/src/components/DynamicNewBadge.tsx | 79 +
panel/src/components/ErrorFallback.tsx | 102 +
panel/src/components/GenericSpinner.tsx | 10 +
panel/src/components/InlineCode.tsx | 17 +
panel/src/components/KickIcons.tsx | 11 +
panel/src/components/Logos.tsx | 69 +
panel/src/components/MainPageLink.tsx | 114 +
panel/src/components/MarkdownProse.tsx | 37 +
panel/src/components/ModalCentralMessage.tsx | 7 +
panel/src/components/MultiIdsList.tsx | 236 +
panel/src/components/PageCalloutRow.tsx | 108 +
panel/src/components/PromptDialog.tsx | 119 +
panel/src/components/SwitchText.tsx | 70 +
panel/src/components/ThemeProvider.tsx | 26 +
panel/src/components/TimeInputDialog.tsx | 106 +
panel/src/components/TxAnchor.tsx | 60 +
panel/src/components/TxToaster.tsx | 191 +
panel/src/components/dndSortable.tsx | 117 +
panel/src/components/dropDownSelect.tsx | 116 +
panel/src/components/page-header.tsx | 153 +
panel/src/components/serverIcon.tsx | 48 +
panel/src/components/ui/alert-dialog.tsx | 143 +
panel/src/components/ui/alert.tsx | 59 +
panel/src/components/ui/autosize-textarea.tsx | 111 +
panel/src/components/ui/avatar.tsx | 48 +
panel/src/components/ui/badge.tsx | 36 +
panel/src/components/ui/button.tsx | 65 +
panel/src/components/ui/card.tsx | 79 +
panel/src/components/ui/checkbox.tsx | 28 +
panel/src/components/ui/dialog.tsx | 121 +
panel/src/components/ui/dropdown-menu.tsx | 198 +
panel/src/components/ui/input.tsx | 26 +
panel/src/components/ui/label.tsx | 24 +
panel/src/components/ui/navigation-menu.tsx | 130 +
panel/src/components/ui/radio-group.tsx | 42 +
panel/src/components/ui/scroll-area.tsx | 46 +
panel/src/components/ui/select.tsx | 159 +
panel/src/components/ui/separator.tsx | 29 +
panel/src/components/ui/sheet.tsx | 138 +
panel/src/components/ui/switch.tsx | 27 +
panel/src/components/ui/table.tsx | 117 +
panel/src/components/ui/tabs-vertical.tsx | 72 +
panel/src/components/ui/tabs.tsx | 53 +
panel/src/components/ui/textarea.tsx | 25 +
panel/src/components/ui/tooltip.tsx | 28 +
panel/src/global.d.ts | 34 +
panel/src/globals.css | 280 +
panel/src/hooks/actionModal.ts | 52 +
panel/src/hooks/auth.ts | 144 +
panel/src/hooks/dialogs.ts | 139 +
panel/src/hooks/fetch.ts | 294 +
panel/src/hooks/pages.ts | 74 +
panel/src/hooks/playerModal.ts | 60 +
panel/src/hooks/playerlist.ts | 49 +
panel/src/hooks/sheets.ts | 40 +
panel/src/hooks/status.ts | 27 +
panel/src/hooks/theme.ts | 144 +
panel/src/hooks/useIdContext.ts | 13 +
panel/src/hooks/useWarningBar.ts | 47 +
panel/src/layout/ActionModal/ActionIdsTab.tsx | 18 +
.../src/layout/ActionModal/ActionInfoTab.tsx | 152 +
panel/src/layout/ActionModal/ActionModal.tsx | 201 +
.../layout/ActionModal/ActionModifyTab.tsx | 80 +
panel/src/layout/AuthShell.tsx | 112 +
panel/src/layout/DesktopNavbar.tsx | 162 +
panel/src/layout/Header.tsx | 213 +
panel/src/layout/MainRouter.tsx | 205 +
panel/src/layout/MainSheets.tsx | 122 +
panel/src/layout/MainShell.tsx | 102 +
panel/src/layout/MainSocket.tsx | 79 +
panel/src/layout/PlayerModal/PlayerBanTab.tsx | 88 +
.../layout/PlayerModal/PlayerHistoryTab.tsx | 90 +
panel/src/layout/PlayerModal/PlayerIdsTab.tsx | 49 +
.../src/layout/PlayerModal/PlayerInfoTab.tsx | 262 +
panel/src/layout/PlayerModal/PlayerModal.tsx | 215 +
.../layout/PlayerModal/PlayerModalFooter.tsx | 177 +
.../layout/PlayerlistSidebar/Playerlist.tsx | 271 +
.../PlayerlistSidebar/PlayerlistSidebar.tsx | 35 +
.../PlayerlistSidebar/PlayerlistSummary.tsx | 21 +
.../layout/ServerSidebar/ServerControls.tsx | 232 +
panel/src/layout/ServerSidebar/ServerMenu.tsx | 112 +
.../layout/ServerSidebar/ServerSchedule.tsx | 219 +
.../layout/ServerSidebar/ServerSidebar.tsx | 79 +
.../src/layout/ServerSidebar/ServerStatus.tsx | 203 +
panel/src/layout/WarningBar.tsx | 140 +
panel/src/lib/dateTime.ts | 151 +
panel/src/lib/hotkeyEventListener.ts | 37 +
panel/src/lib/humanizeDuration.ts | 387 +
panel/src/lib/navigation.ts | 86 +
panel/src/lib/playerDropCategories.ts | 77 +
panel/src/lib/utils.ts | 175 +
panel/src/main.tsx | 113 +
panel/src/pages/AddLegacyBanPage.tsx | 145 +
.../BanTemplates/BanTemplatesInputDialog.tsx | 200 +
.../BanTemplatesListAddButton.tsx | 23 +
.../BanTemplates/BanTemplatesListItem.tsx | 53 +
.../pages/BanTemplates/BanTemplatesPage.tsx | 270 +
panel/src/pages/Dashboard/DashboardPage.tsx | 91 +
panel/src/pages/Dashboard/FullPerfCard.tsx | 257 +
panel/src/pages/Dashboard/PlayerDropCard.tsx | 244 +
panel/src/pages/Dashboard/ServerStatsCard.tsx | 140 +
panel/src/pages/Dashboard/ThreadPerfCard.tsx | 318 +
.../src/pages/Dashboard/chartingUtils.test.ts | 416 +
panel/src/pages/Dashboard/chartingUtils.ts | 262 +
panel/src/pages/Dashboard/dashboardHooks.ts | 86 +
.../src/pages/Dashboard/drawFullPerfChart.ts | 581 +
panel/src/pages/History/HistoryPage.tsx | 151 +
panel/src/pages/History/HistorySearchBox.tsx | 281 +
panel/src/pages/History/HistoryTable.tsx | 368 +
panel/src/pages/Iframe.tsx | 30 +
.../pages/LiveConsole/LiveConsoleFooter.tsx | 184 +
.../pages/LiveConsole/LiveConsoleHeader.tsx | 24 +
.../src/pages/LiveConsole/LiveConsolePage.tsx | 461 +
.../LiveConsole/LiveConsoleSaveSheet.tsx | 189 +
.../LiveConsole/LiveConsoleSearchBar.tsx | 214 +
.../src/pages/LiveConsole/ScrollDownAddon.ts | 65 +
.../src/pages/LiveConsole/liveConsoleHooks.ts | 45 +
.../pages/LiveConsole/liveConsoleMarkers.ts | 175 +
.../LiveConsole/liveConsoleUtils.test.ts | 113 +
.../src/pages/LiveConsole/liveConsoleUtils.ts | 131 +
panel/src/pages/LiveConsole/xtermOptions.ts | 56 +
.../src/pages/LiveConsole/xtermOverrides.css | 24 +
panel/src/pages/NotFound.tsx | 24 +
.../pages/PlayerDropsPage/DrilldownCard.tsx | 181 +
.../DrilldownChangesSubcard.tsx | 153 +
.../DrilldownCrashesSubcard.tsx | 154 +
.../DrilldownOverviewSubcard.tsx | 67 +
.../DrilldownResourcesSubcard.tsx | 50 +
.../PlayerDropsGenericSubcards.tsx | 32 +
.../pages/PlayerDropsPage/PlayerDropsPage.tsx | 94 +
.../pages/PlayerDropsPage/TimelineCard.tsx | 134 +
.../PlayerDropsPage/TimelineDropsChart.tsx | 167 +
.../pages/PlayerDropsPage/chartingUtils.ts | 139 +
.../PlayerDropsPage/drawDropsTimeline.ts | 465 +
panel/src/pages/PlayerDropsPage/utils.test.ts | 133 +
panel/src/pages/PlayerDropsPage/utils.ts | 158 +
panel/src/pages/Players/PlayersPage.tsx | 173 +
panel/src/pages/Players/PlayersSearchBox.tsx | 282 +
panel/src/pages/Players/PlayersTable.tsx | 314 +
.../src/pages/Settings/SettingsCardShell.tsx | 93 +
panel/src/pages/Settings/SettingsPage.tsx | 249 +
panel/src/pages/Settings/SettingsTab.tsx | 32 +
panel/src/pages/Settings/settingsItems.tsx | 98 +
panel/src/pages/Settings/tabCards/_blank.tsx | 75 +
.../src/pages/Settings/tabCards/_template.tsx | 297 +
panel/src/pages/Settings/tabCards/bans.tsx | 160 +
panel/src/pages/Settings/tabCards/discord.tsx | 263 +
.../src/pages/Settings/tabCards/fxserver.tsx | 499 +
.../src/pages/Settings/tabCards/gameMenu.tsx | 139 +
.../Settings/tabCards/gameNotifications.tsx | 143 +
panel/src/pages/Settings/tabCards/general.tsx | 151 +
.../src/pages/Settings/tabCards/whitelist.tsx | 201 +
panel/src/pages/Settings/utils.ts | 245 +
panel/src/pages/SystemLogPage.tsx | 263 +
panel/src/pages/TestingPage/TestingPage.tsx | 43 +
panel/src/pages/TestingPage/TmpApi.tsx | 77 +
panel/src/pages/TestingPage/TmpAuthState.tsx | 57 +
panel/src/pages/TestingPage/TmpColors.tsx | 50 +
.../src/pages/TestingPage/TmpDndSortable.tsx | 130 +
panel/src/pages/TestingPage/TmpFiller.tsx | 13 +
.../pages/TestingPage/TmpHexHslConverter.tsx | 108 +
panel/src/pages/TestingPage/TmpJsonEditor.tsx | 229 +
panel/src/pages/TestingPage/TmpMarkdown.tsx | 92 +
panel/src/pages/TestingPage/TmpPageHeader.tsx | 72 +
panel/src/pages/TestingPage/TmpSocket.tsx | 67 +
panel/src/pages/TestingPage/TmpSwr.tsx | 78 +
panel/src/pages/TestingPage/TmpToasts.tsx | 127 +
.../pages/TestingPage/TmpWarningBarState.tsx | 81 +
panel/src/pages/UnauthorizedPage.tsx | 55 +
panel/src/pages/auth/AddMasterCallback.tsx | 246 +
panel/src/pages/auth/AddMasterPin.tsx | 127 +
panel/src/pages/auth/CfxreCallback.tsx | 57 +
panel/src/pages/auth/Login.tsx | 241 +
panel/src/pages/auth/cfxreLoginButton.css | 18 +
panel/src/pages/auth/errors.tsx | 131 +
panel/src/vite-env.d.ts | 1 +
panel/tailwind.config.cjs | 198 +
panel/tsconfig.json | 38 +
panel/tsconfig.node.json | 15 +
panel/vite.config.ts | 72 +
resource/cl_logger.lua | 362 +
resource/cl_main.lua | 242 +
resource/cl_playerlist.lua | 202 +
resource/menu/client/cl_base.lua | 231 +
resource/menu/client/cl_freeze.lua | 39 +
resource/menu/client/cl_functions.lua | 165 +
resource/menu/client/cl_instructional_ui.lua | 85 +
resource/menu/client/cl_main_page.lua | 310 +
resource/menu/client/cl_player_ids.lua | 209 +
resource/menu/client/cl_player_mode.lua | 293 +
resource/menu/client/cl_ptfx.lua | 141 +
resource/menu/client/cl_spectate.lua | 358 +
resource/menu/client/cl_trollactions.lua | 166 +
resource/menu/client/cl_vehicle.lua | 428 +
resource/menu/client/cl_webpipe.lua | 101 +
resource/menu/server/sv_freeze_player.lua | 27 +
resource/menu/server/sv_functions.lua | 24 +
resource/menu/server/sv_main_page.lua | 129 +
resource/menu/server/sv_player_modal.lua | 63 +
resource/menu/server/sv_player_mode.lua | 26 +
resource/menu/server/sv_spectate.lua | 101 +
resource/menu/server/sv_trollactions.lua | 31 +
resource/menu/server/sv_vehicle.lua | 129 +
resource/menu/server/sv_webpipe.lua | 165 +
resource/menu/vendor/freecam/INFO.txt | 4 +
resource/menu/vendor/freecam/LICENSE.txt | 21 +
resource/menu/vendor/freecam/camera.lua | 154 +
resource/menu/vendor/freecam/config.lua | 112 +
resource/menu/vendor/freecam/main.lua | 155 +
resource/menu/vendor/freecam/utils.lua | 102 +
resource/shared.lua | 106 +
resource/sv_admins.lua | 131 +
resource/sv_ctx.lua | 139 +
resource/sv_initialData.lua | 81 +
resource/sv_logger.lua | 249 +
resource/sv_main.lua | 518 +
resource/sv_playerlist.lua | 257 +
resource/sv_reportHeap.js | 23 +
resource/sv_resources.lua | 53 +
scripts/build/TxAdminRunner.ts | 106 +
scripts/build/config.ts | 13 +
scripts/build/dev.ts | 114 +
scripts/build/publish.ts | 48 +
scripts/build/utils.ts | 122 +
scripts/dev/fixStatsFilePlayers.code.ts | 37 +
scripts/dev/fixStatsFilePlayers.ts | 10 +
scripts/dev/makeOldStatsFile.code.ts | 130 +
scripts/dev/makeOldStatsFile.ts | 18 +
scripts/dev/quick_playerlist_tester.html | 196 +
scripts/lint-formatter.js | 28 +
scripts/list-dependencies.js | 89 +
scripts/list-licenses.js | 28 +
scripts/locale-utils.js | 543 +
scripts/test_build.sh | 32 +
scripts/typecheck-formatter.js | 42 +
shared/authApiTypes.ts | 70 +
shared/cleanFullPath.ts | 40 +
shared/cleanPlayerName.ts | 276 +
shared/consts.ts | 45 +
shared/enums.ts | 19 +
shared/genericApiTypes.ts | 19 +
shared/historyApiTypes.ts | 61 +
shared/localeMap.ts | 83 +
shared/otherTypes.ts | 71 +
shared/package.json | 16 +
shared/playerApiTypes.ts | 104 +
shared/socketioTypes.ts | 119 +
shared/tsconfig.json | 28 +
shared/txDevEnv.ts | 94 +
web/main/adminManager.ejs | 315 +
web/main/advanced.ejs | 155 +
web/main/cfgEditor.ejs | 131 +
web/main/diagnostics.ejs | 314 +
web/main/insights.ejs | 354 +
web/main/masterActions.ejs | 358 +
web/main/message.ejs | 20 +
web/main/resources.ejs | 436 +
web/main/serverLog.ejs | 509 +
web/main/whitelist.ejs | 620 +
web/parts/adminModal.ejs | 80 +
web/parts/footer.ejs | 19 +
web/parts/header.ejs | 31 +
web/public/css/codemirror.css | 349 +
web/public/css/codemirror_lucario.css | 37 +
web/public/css/coreui.css | 16577 ++++++++++++++++
web/public/css/coreui.min.css | 8 +
web/public/css/dark.css | 323 +
web/public/css/dark.css.map | 1 +
web/public/css/dark.scss | 460 +
web/public/css/foldgutter.css | 20 +
web/public/css/jquery-confirm.min.css | 9 +
web/public/css/simple-line-icons.css | 777 +
web/public/css/txAdmin.css | 508 +
web/public/fonts/Simple-Line-Icons.woff2 | Bin 0 -> 30064 bytes
web/public/img/default_avatar.png | Bin 0 -> 12915 bytes
web/public/img/fivem-server-icon.png | Bin 0 -> 24360 bytes
web/public/img/redm-server-icon.png | Bin 0 -> 26167 bytes
web/public/img/tx.png | Bin 0 -> 1443 bytes
web/public/img/txSnaily2anim_320.png | Bin 0 -> 153775 bytes
web/public/img/txadmin.png | Bin 0 -> 10390 bytes
web/public/img/txadmin_beta.png | Bin 0 -> 11414 bytes
web/public/img/unknown-server-icon.png | Bin 0 -> 21706 bytes
web/public/img/zap256_black.png | Bin 0 -> 13041 bytes
web/public/img/zap256_white.png | Bin 0 -> 24402 bytes
web/public/js/bootstrap-notify.min.js | 2 +
web/public/js/codeEditor/autorefresh.js | 47 +
web/public/js/codeEditor/codemirror.js | 9765 +++++++++
web/public/js/codeEditor/comment.js | 211 +
web/public/js/codeEditor/dialog.js | 163 +
web/public/js/codeEditor/mode/fivem-cfg.js | 32 +
web/public/js/codeEditor/mode/simple.js | 216 +
web/public/js/codeEditor/mode/yaml.js | 120 +
web/public/js/codeEditor/search.js | 303 +
web/public/js/codeEditor/searchcursor.js | 321 +
web/public/js/coreui.bundle.min.js | 12 +
web/public/js/jquery-confirm.min.js | 10 +
web/public/js/socket.io.min.js | 7 +
web/public/js/txadmin/base.js | 241 +
web/standalone/deployer.ejs | 586 +
web/standalone/setup.ejs | 898 +
698 files changed, 137040 insertions(+)
create mode 100644 .eslintignore
create mode 100644 .eslintrc.cjs
create mode 100644 .github/CODEOWNERS
create mode 100644 .github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml
create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md
create mode 100644 .github/ISSUE_TEMPLATE/config.yml
create mode 100644 .github/copilot-instructions.md
create mode 100644 .github/workflows/locale-pull-request.yml
create mode 100644 .github/workflows/publish-tagged.yml
create mode 100644 .github/workflows/run-tests.yml
create mode 100644 .gitignore
create mode 100644 .husky/.gitignore
create mode 100644 .husky/commit-msg
create mode 100644 .husky/pre-commit
create mode 100644 .license-reportrc
create mode 100644 .npm-upgrade.json
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 commitlint.config.cjs
create mode 100644 core/.eslintrc.cjs
create mode 100644 core/.npm-upgrade.json
create mode 100644 core/boot/checkPreRelease.ts
create mode 100644 core/boot/getHostVars.ts
create mode 100644 core/boot/getNativeVars.ts
create mode 100644 core/boot/getZapVars.ts
create mode 100644 core/boot/globalPlaceholder.ts
create mode 100644 core/boot/setup.ts
create mode 100644 core/boot/setupProcessHandlers.ts
create mode 100644 core/boot/startReadyWatcher.ts
create mode 100644 core/deployer/index.js
create mode 100644 core/deployer/recipeEngine.js
create mode 100644 core/deployer/recipeParser.ts
create mode 100644 core/deployer/utils.ts
create mode 100644 core/global.d.ts
create mode 100644 core/globalData.ts
create mode 100644 core/index.ts
create mode 100644 core/lib/MemCache.ts
create mode 100644 core/lib/console.test.ts
create mode 100644 core/lib/console.ts
create mode 100644 core/lib/diagnostics.ts
create mode 100644 core/lib/fatalError.ts
create mode 100644 core/lib/fs.ts
create mode 100644 core/lib/fxserver/fxsConfigHelper.ts
create mode 100644 core/lib/fxserver/fxsVersionParser.test.ts
create mode 100644 core/lib/fxserver/fxsVersionParser.ts
create mode 100644 core/lib/fxserver/runtimeFiles.ts
create mode 100644 core/lib/fxserver/scanMonitorFiles.ts
create mode 100644 core/lib/fxserver/serverData.ts
create mode 100644 core/lib/got.ts
create mode 100644 core/lib/host/getHostUsage.ts
create mode 100644 core/lib/host/getOsDistro.js
create mode 100644 core/lib/host/isIpAddressLocal.ts
create mode 100644 core/lib/host/pidUsageTree.js
create mode 100644 core/lib/misc.test.ts
create mode 100644 core/lib/misc.ts
create mode 100644 core/lib/player/idUtils.test.ts
create mode 100644 core/lib/player/idUtils.ts
create mode 100644 core/lib/player/playerClasses.ts
create mode 100644 core/lib/player/playerFinder.ts
create mode 100644 core/lib/player/playerResolver.ts
create mode 100644 core/lib/quitProcess.ts
create mode 100644 core/lib/symbols.ts
create mode 100644 core/lib/xss.js
create mode 100644 core/modules/AdminStore/index.js
create mode 100644 core/modules/AdminStore/providers/CitizenFX.ts
create mode 100644 core/modules/CacheStore.ts
create mode 100644 core/modules/ConfigStore/changelog.ts
create mode 100644 core/modules/ConfigStore/configMigrations.ts
create mode 100644 core/modules/ConfigStore/configParser.test.ts
create mode 100644 core/modules/ConfigStore/configParser.ts
create mode 100644 core/modules/ConfigStore/index.ts
create mode 100644 core/modules/ConfigStore/schema/banlist.ts
create mode 100644 core/modules/ConfigStore/schema/discordBot.ts
create mode 100644 core/modules/ConfigStore/schema/gameFeatures.ts
create mode 100644 core/modules/ConfigStore/schema/general.ts
create mode 100644 core/modules/ConfigStore/schema/index.ts
create mode 100644 core/modules/ConfigStore/schema/logger.ts
create mode 100644 core/modules/ConfigStore/schema/oldConfig.ts
create mode 100644 core/modules/ConfigStore/schema/restarter.ts
create mode 100644 core/modules/ConfigStore/schema/server.ts
create mode 100644 core/modules/ConfigStore/schema/utils.ts
create mode 100644 core/modules/ConfigStore/schema/webServer.ts
create mode 100644 core/modules/ConfigStore/schema/whitelist.ts
create mode 100644 core/modules/ConfigStore/utils.test.ts
create mode 100644 core/modules/ConfigStore/utils.ts
create mode 100644 core/modules/Database/dao/actions.ts
create mode 100644 core/modules/Database/dao/cleanup.ts
create mode 100644 core/modules/Database/dao/players.ts
create mode 100644 core/modules/Database/dao/stats.ts
create mode 100644 core/modules/Database/dao/whitelist.ts
create mode 100644 core/modules/Database/databaseTypes.ts
create mode 100644 core/modules/Database/dbUtils.ts
create mode 100644 core/modules/Database/index.ts
create mode 100644 core/modules/Database/instance.ts
create mode 100644 core/modules/Database/migrations.js
create mode 100644 core/modules/DiscordBot/commands/info.ts
create mode 100644 core/modules/DiscordBot/commands/status.ts
create mode 100644 core/modules/DiscordBot/commands/whitelist.ts
create mode 100644 core/modules/DiscordBot/defaultJsons.ts
create mode 100644 core/modules/DiscordBot/discordHelpers.ts
create mode 100644 core/modules/DiscordBot/index.ts
create mode 100644 core/modules/DiscordBot/interactionCreateHandler.ts
create mode 100644 core/modules/DiscordBot/slash.ts
create mode 100644 core/modules/FxMonitor/index.ts
create mode 100644 core/modules/FxMonitor/utils.ts
create mode 100644 core/modules/FxPlayerlist/index.ts
create mode 100644 core/modules/FxResources.ts
create mode 100644 core/modules/FxRunner/ProcessManager.ts
create mode 100644 core/modules/FxRunner/handleFd3Messages.ts
create mode 100644 core/modules/FxRunner/index.ts
create mode 100644 core/modules/FxRunner/utils.test.ts
create mode 100644 core/modules/FxRunner/utils.ts
create mode 100644 core/modules/FxScheduler.ts
create mode 100644 core/modules/Logger/FXServerLogger/ConsoleLineEnum.ts
create mode 100644 core/modules/Logger/FXServerLogger/ConsoleTransformer.ts
create mode 100644 core/modules/Logger/FXServerLogger/fxsLogger.test.ts
create mode 100644 core/modules/Logger/FXServerLogger/fxsLoggerUtils.ts
create mode 100644 core/modules/Logger/FXServerLogger/index.ts
create mode 100644 core/modules/Logger/LoggerBase.ts
create mode 100644 core/modules/Logger/handlers/admin.js
create mode 100644 core/modules/Logger/handlers/server.js
create mode 100644 core/modules/Logger/index.ts
create mode 100644 core/modules/Logger/loggerUtils.ts
create mode 100644 core/modules/Metrics/index.ts
create mode 100644 core/modules/Metrics/playerDrop/classifyDropReason.test.ts
create mode 100644 core/modules/Metrics/playerDrop/classifyDropReason.ts
create mode 100644 core/modules/Metrics/playerDrop/config.ts
create mode 100644 core/modules/Metrics/playerDrop/index.ts
create mode 100644 core/modules/Metrics/playerDrop/playerDropMigrations.ts
create mode 100644 core/modules/Metrics/playerDrop/playerDropSchemas.ts
create mode 100644 core/modules/Metrics/playerDrop/playerDropUtils.ts
create mode 100644 core/modules/Metrics/statsUtils.test.ts
create mode 100644 core/modules/Metrics/statsUtils.ts
create mode 100644 core/modules/Metrics/svRuntime/config.ts
create mode 100644 core/modules/Metrics/svRuntime/index.ts
create mode 100644 core/modules/Metrics/svRuntime/logOptimizer.ts
create mode 100644 core/modules/Metrics/svRuntime/perfParser.test.ts
create mode 100644 core/modules/Metrics/svRuntime/perfParser.ts
create mode 100644 core/modules/Metrics/svRuntime/perfSchemas.ts
create mode 100644 core/modules/Metrics/svRuntime/perfUtils.test.ts
create mode 100644 core/modules/Metrics/svRuntime/perfUtils.ts
create mode 100644 core/modules/Metrics/txRuntime/index.ts
create mode 100644 core/modules/Translator.ts
create mode 100644 core/modules/UpdateChecker/index.ts
create mode 100644 core/modules/UpdateChecker/queryChangelogApi.ts
create mode 100644 core/modules/UpdateChecker/updateRollout.test.ts
create mode 100644 core/modules/UpdateChecker/updateRollout.ts
create mode 100644 core/modules/WebServer/authLogic.ts
create mode 100644 core/modules/WebServer/ctxTypes.ts
create mode 100644 core/modules/WebServer/getReactIndex.ts
create mode 100644 core/modules/WebServer/index.ts
create mode 100644 core/modules/WebServer/middlewares/authMws.ts
create mode 100644 core/modules/WebServer/middlewares/cacheControlMw.ts
create mode 100644 core/modules/WebServer/middlewares/ctxUtilsMw.ts
create mode 100644 core/modules/WebServer/middlewares/ctxVarsMw.ts
create mode 100644 core/modules/WebServer/middlewares/globalRateLimiter.ts
create mode 100644 core/modules/WebServer/middlewares/httpLoadMonitor.ts
create mode 100644 core/modules/WebServer/middlewares/serveRuntimeMw.ts
create mode 100644 core/modules/WebServer/middlewares/serveStaticMw.ts
create mode 100644 core/modules/WebServer/middlewares/sessionMws.ts
create mode 100644 core/modules/WebServer/middlewares/topLevelMw.ts
create mode 100644 core/modules/WebServer/router.ts
create mode 100644 core/modules/WebServer/webSocket.ts
create mode 100644 core/modules/WebServer/wsRooms/dashboard.ts
create mode 100644 core/modules/WebServer/wsRooms/liveconsole.ts
create mode 100644 core/modules/WebServer/wsRooms/playerlist.ts
create mode 100644 core/modules/WebServer/wsRooms/serverlog.ts
create mode 100644 core/modules/WebServer/wsRooms/status.ts
create mode 100644 core/package.json
create mode 100644 core/routes/adminManager/actions.ts
create mode 100644 core/routes/adminManager/getModal.ts
create mode 100644 core/routes/adminManager/page.ts
create mode 100644 core/routes/advanced/actions.js
create mode 100644 core/routes/advanced/get.js
create mode 100644 core/routes/authentication/addMasterCallback.ts
create mode 100644 core/routes/authentication/addMasterPin.ts
create mode 100644 core/routes/authentication/addMasterSave.ts
create mode 100644 core/routes/authentication/changeIdentifiers.ts
create mode 100644 core/routes/authentication/changePassword.ts
create mode 100644 core/routes/authentication/getIdentifiers.ts
create mode 100644 core/routes/authentication/logout.ts
create mode 100644 core/routes/authentication/oauthMethods.ts
create mode 100644 core/routes/authentication/providerCallback.ts
create mode 100644 core/routes/authentication/providerRedirect.ts
create mode 100644 core/routes/authentication/self.ts
create mode 100644 core/routes/authentication/verifyPassword.ts
create mode 100644 core/routes/banTemplates/getBanTemplates.ts
create mode 100644 core/routes/banTemplates/saveBanTemplates.ts
create mode 100644 core/routes/cfgEditor/get.js
create mode 100644 core/routes/cfgEditor/save.js
create mode 100644 core/routes/deployer/actions.js
create mode 100644 core/routes/deployer/status.js
create mode 100644 core/routes/deployer/stepper.js
create mode 100644 core/routes/devDebug.ts
create mode 100644 core/routes/diagnostics/page.ts
create mode 100644 core/routes/diagnostics/sendReport.ts
create mode 100644 core/routes/fxserver/commands.ts
create mode 100644 core/routes/fxserver/controls.ts
create mode 100644 core/routes/fxserver/downloadLog.js
create mode 100644 core/routes/fxserver/schedule.ts
create mode 100644 core/routes/history/actionModal.ts
create mode 100644 core/routes/history/actions.ts
create mode 100644 core/routes/history/search.ts
create mode 100644 core/routes/history/stats.ts
create mode 100644 core/routes/hostStatus.ts
create mode 100644 core/routes/index.ts
create mode 100644 core/routes/intercom.ts
create mode 100644 core/routes/masterActions/actions.ts
create mode 100644 core/routes/masterActions/getBackup.ts
create mode 100644 core/routes/masterActions/page.ts
create mode 100644 core/routes/perfChart.ts
create mode 100644 core/routes/player/actions.ts
create mode 100644 core/routes/player/checkJoin.ts
create mode 100644 core/routes/player/modal.ts
create mode 100644 core/routes/player/search.ts
create mode 100644 core/routes/player/stats.ts
create mode 100644 core/routes/playerDrops.ts
create mode 100644 core/routes/resources.js
create mode 100644 core/routes/serverLog.js
create mode 100644 core/routes/serverLogPartial.js
create mode 100644 core/routes/settings/getConfigs.ts
create mode 100644 core/routes/settings/resetServerDataPath.ts
create mode 100644 core/routes/settings/saveConfigs.ts
create mode 100644 core/routes/setup/get.ts
create mode 100644 core/routes/setup/post.js
create mode 100644 core/routes/systemLogs.ts
create mode 100644 core/routes/whitelist/actions.ts
create mode 100644 core/routes/whitelist/list.ts
create mode 100644 core/routes/whitelist/page.ts
create mode 100644 core/testing/fileSetup.ts
create mode 100644 core/testing/globalSetup.ts
create mode 100644 core/tsconfig.json
create mode 100644 core/txAdmin.ts
create mode 100644 core/txManager.ts
create mode 100644 core/vitest.config.ts
create mode 100644 docs/banner.png
create mode 100644 docs/custom-server-log.md
create mode 100644 docs/dev-notes.md
create mode 100644 docs/development.md
create mode 100644 docs/discord-status.md
create mode 100644 docs/env-config.md
create mode 100644 docs/events.md
create mode 100644 docs/feature-graveyard.md
create mode 100644 docs/logs.md
create mode 100644 docs/menu.md
create mode 100644 docs/palettes.json
create mode 100644 docs/permissions.md
create mode 100644 docs/recipe.md
create mode 100644 docs/translation.md
create mode 100644 docs/zaphosting.png
create mode 100644 dynamicAds.json
create mode 100644 dynamicAds2.json
create mode 100644 entrypoint.js
create mode 100644 fxmanifest.lua
create mode 100644 locale/ar.json
create mode 100644 locale/bg.json
create mode 100644 locale/bs.json
create mode 100644 locale/cs.json
create mode 100644 locale/da.json
create mode 100644 locale/de.json
create mode 100644 locale/el.json
create mode 100644 locale/en.json
create mode 100644 locale/es.json
create mode 100644 locale/et.json
create mode 100644 locale/fa.json
create mode 100644 locale/fi.json
create mode 100644 locale/fr.json
create mode 100644 locale/hr.json
create mode 100644 locale/hu.json
create mode 100644 locale/id.json
create mode 100644 locale/it.json
create mode 100644 locale/ja.json
create mode 100644 locale/lt.json
create mode 100644 locale/lv.json
create mode 100644 locale/mn.json
create mode 100644 locale/ne.json
create mode 100644 locale/nl.json
create mode 100644 locale/no.json
create mode 100644 locale/pl.json
create mode 100644 locale/pt.json
create mode 100644 locale/ro.json
create mode 100644 locale/ru.json
create mode 100644 locale/sl.json
create mode 100644 locale/sv.json
create mode 100644 locale/th.json
create mode 100644 locale/tr.json
create mode 100644 locale/uk.json
create mode 100644 locale/vi.json
create mode 100644 locale/zh.json
create mode 100644 nui/index.html
create mode 100644 nui/package.json
create mode 100644 nui/public/images/txadmin-redm.png
create mode 100644 nui/public/images/txadmin.png
create mode 100644 nui/public/sounds/announcement.mp3
create mode 100644 nui/public/sounds/message.mp3
create mode 100644 nui/public/sounds/warning_open.mp3
create mode 100644 nui/public/sounds/warning_pulse.mp3
create mode 100644 nui/src/App.css
create mode 100644 nui/src/MenuWrapper.tsx
create mode 100644 nui/src/components/IFramePage/IFramePage.tsx
create mode 100644 nui/src/components/MainPage/MainPageList.tsx
create mode 100644 nui/src/components/MainPage/MenuListItem.tsx
create mode 100644 nui/src/components/MenuRoot.tsx
create mode 100644 nui/src/components/MenuRootContent.tsx
create mode 100644 nui/src/components/PlayerModal/ErrorHandling/PlayerModalErrorBoundary.tsx
create mode 100644 nui/src/components/PlayerModal/ErrorHandling/PlayerModalHasError.tsx
create mode 100644 nui/src/components/PlayerModal/PlayerModal.tsx
create mode 100644 nui/src/components/PlayerModal/Tabs/DialogActionView.tsx
create mode 100644 nui/src/components/PlayerModal/Tabs/DialogBanView.tsx
create mode 100644 nui/src/components/PlayerModal/Tabs/DialogBaseView.tsx
create mode 100644 nui/src/components/PlayerModal/Tabs/DialogHistoryView.tsx
create mode 100644 nui/src/components/PlayerModal/Tabs/DialogIdView.tsx
create mode 100644 nui/src/components/PlayerModal/Tabs/DialogInfoView.tsx
create mode 100644 nui/src/components/PlayerModal/Tabs/DialogLoadError.tsx
create mode 100644 nui/src/components/PlayersPage/PlayerCard.tsx
create mode 100644 nui/src/components/PlayersPage/PlayerPageHeader.tsx
create mode 100644 nui/src/components/PlayersPage/PlayersListEmpty.tsx
create mode 100644 nui/src/components/PlayersPage/PlayersListGrid.tsx
create mode 100644 nui/src/components/PlayersPage/PlayersPage.tsx
create mode 100644 nui/src/components/WarnPage/WarnPage.tsx
create mode 100644 nui/src/components/misc/ButtonXS.tsx
create mode 100644 nui/src/components/misc/HelpTooltip.tsx
create mode 100644 nui/src/components/misc/PageTabs.tsx
create mode 100644 nui/src/components/misc/TextField.tsx
create mode 100644 nui/src/components/misc/TopLevelErrorBoundary.tsx
create mode 100644 nui/src/hooks/useDebouce.tsx
create mode 100644 nui/src/hooks/useExitListener.tsx
create mode 100644 nui/src/hooks/useHudListenersService.tsx
create mode 100644 nui/src/hooks/useKey.ts
create mode 100644 nui/src/hooks/useKeyboardNavigation.tsx
create mode 100644 nui/src/hooks/useListenerForSomething.ts
create mode 100644 nui/src/hooks/useLocale.ts
create mode 100644 nui/src/hooks/useNuiEvent.ts
create mode 100644 nui/src/hooks/useNuiListenersService.tsx
create mode 100644 nui/src/hooks/usePlayerListListener.ts
create mode 100644 nui/src/index.css
create mode 100644 nui/src/index.tsx
create mode 100644 nui/src/provider/DialogProvider.tsx
create mode 100644 nui/src/provider/IFrameProvider.tsx
create mode 100644 nui/src/provider/KeyboardNavProvider.tsx
create mode 100644 nui/src/provider/PlayerModalProvider.tsx
create mode 100644 nui/src/provider/TooltipProvider.tsx
create mode 100644 nui/src/state/healmode.state.ts
create mode 100644 nui/src/state/isRedm.state.ts
create mode 100644 nui/src/state/keys.state.ts
create mode 100644 nui/src/state/page.state.ts
create mode 100644 nui/src/state/permissions.state.ts
create mode 100644 nui/src/state/playerDetails.state.ts
create mode 100644 nui/src/state/playerModal.state.ts
create mode 100644 nui/src/state/playermode.state.ts
create mode 100644 nui/src/state/players.state.ts
create mode 100644 nui/src/state/server.state.ts
create mode 100644 nui/src/state/teleportmode.state.ts
create mode 100644 nui/src/state/vehiclemode.state.ts
create mode 100644 nui/src/state/visibility.state.ts
create mode 100644 nui/src/styles/module-augmentation.d.ts
create mode 100644 nui/src/styles/theme-redm.tsx
create mode 100644 nui/src/styles/theme.tsx
create mode 100644 nui/src/utils/config.json
create mode 100644 nui/src/utils/constants.ts
create mode 100644 nui/src/utils/copyToClipboard.ts
create mode 100644 nui/src/utils/debugData.ts
create mode 100644 nui/src/utils/debugLog.ts
create mode 100644 nui/src/utils/fetchNui.ts
create mode 100644 nui/src/utils/fetchWebPipe.ts
create mode 100644 nui/src/utils/generateMockPlayerData.ts
create mode 100644 nui/src/utils/getNotiDuration.ts
create mode 100644 nui/src/utils/miscUtils.ts
create mode 100644 nui/src/utils/registerDebugFunctions.ts
create mode 100644 nui/src/utils/shouldHelpAlertShow.ts
create mode 100644 nui/src/utils/vehicleSpawnDialogHelper.ts
create mode 100644 nui/tsconfig.json
create mode 100644 nui/tsconfig.node.json
create mode 100644 nui/vite.config.ts
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 panel/.eslintrc.cjs
create mode 100644 panel/components.json
create mode 100644 panel/index.html
create mode 100644 panel/package.json
create mode 100644 panel/postcss.config.js
create mode 100644 panel/public/favicon_default.svg
create mode 100644 panel/public/favicon_offline.svg
create mode 100644 panel/public/favicon_online.svg
create mode 100644 panel/public/favicon_partial.svg
create mode 100644 panel/public/img/discord.png
create mode 100644 panel/public/img/zap_login.png
create mode 100644 panel/public/img/zap_main.png
create mode 100644 panel/src/components/AccountDialog.tsx
create mode 100644 panel/src/components/Avatar.tsx
create mode 100644 panel/src/components/BanForm.tsx
create mode 100644 panel/src/components/BigRadioItem.tsx
create mode 100644 panel/src/components/BreakpointDebugger.tsx
create mode 100644 panel/src/components/CardContentOverlay.tsx
create mode 100644 panel/src/components/ConfirmDialog.tsx
create mode 100644 panel/src/components/DateTimeCorrected.tsx
create mode 100644 panel/src/components/DebouncedResizeContainer.tsx
create mode 100644 panel/src/components/Divider.tsx
create mode 100644 panel/src/components/DynamicNewBadge.tsx
create mode 100644 panel/src/components/ErrorFallback.tsx
create mode 100644 panel/src/components/GenericSpinner.tsx
create mode 100644 panel/src/components/InlineCode.tsx
create mode 100644 panel/src/components/KickIcons.tsx
create mode 100644 panel/src/components/Logos.tsx
create mode 100644 panel/src/components/MainPageLink.tsx
create mode 100644 panel/src/components/MarkdownProse.tsx
create mode 100644 panel/src/components/ModalCentralMessage.tsx
create mode 100644 panel/src/components/MultiIdsList.tsx
create mode 100644 panel/src/components/PageCalloutRow.tsx
create mode 100644 panel/src/components/PromptDialog.tsx
create mode 100644 panel/src/components/SwitchText.tsx
create mode 100644 panel/src/components/ThemeProvider.tsx
create mode 100644 panel/src/components/TimeInputDialog.tsx
create mode 100644 panel/src/components/TxAnchor.tsx
create mode 100644 panel/src/components/TxToaster.tsx
create mode 100644 panel/src/components/dndSortable.tsx
create mode 100644 panel/src/components/dropDownSelect.tsx
create mode 100644 panel/src/components/page-header.tsx
create mode 100644 panel/src/components/serverIcon.tsx
create mode 100644 panel/src/components/ui/alert-dialog.tsx
create mode 100644 panel/src/components/ui/alert.tsx
create mode 100644 panel/src/components/ui/autosize-textarea.tsx
create mode 100644 panel/src/components/ui/avatar.tsx
create mode 100644 panel/src/components/ui/badge.tsx
create mode 100644 panel/src/components/ui/button.tsx
create mode 100644 panel/src/components/ui/card.tsx
create mode 100644 panel/src/components/ui/checkbox.tsx
create mode 100644 panel/src/components/ui/dialog.tsx
create mode 100644 panel/src/components/ui/dropdown-menu.tsx
create mode 100644 panel/src/components/ui/input.tsx
create mode 100644 panel/src/components/ui/label.tsx
create mode 100644 panel/src/components/ui/navigation-menu.tsx
create mode 100644 panel/src/components/ui/radio-group.tsx
create mode 100644 panel/src/components/ui/scroll-area.tsx
create mode 100644 panel/src/components/ui/select.tsx
create mode 100644 panel/src/components/ui/separator.tsx
create mode 100644 panel/src/components/ui/sheet.tsx
create mode 100644 panel/src/components/ui/switch.tsx
create mode 100644 panel/src/components/ui/table.tsx
create mode 100644 panel/src/components/ui/tabs-vertical.tsx
create mode 100644 panel/src/components/ui/tabs.tsx
create mode 100644 panel/src/components/ui/textarea.tsx
create mode 100644 panel/src/components/ui/tooltip.tsx
create mode 100644 panel/src/global.d.ts
create mode 100644 panel/src/globals.css
create mode 100644 panel/src/hooks/actionModal.ts
create mode 100644 panel/src/hooks/auth.ts
create mode 100644 panel/src/hooks/dialogs.ts
create mode 100644 panel/src/hooks/fetch.ts
create mode 100644 panel/src/hooks/pages.ts
create mode 100644 panel/src/hooks/playerModal.ts
create mode 100644 panel/src/hooks/playerlist.ts
create mode 100644 panel/src/hooks/sheets.ts
create mode 100644 panel/src/hooks/status.ts
create mode 100644 panel/src/hooks/theme.ts
create mode 100644 panel/src/hooks/useIdContext.ts
create mode 100644 panel/src/hooks/useWarningBar.ts
create mode 100644 panel/src/layout/ActionModal/ActionIdsTab.tsx
create mode 100644 panel/src/layout/ActionModal/ActionInfoTab.tsx
create mode 100644 panel/src/layout/ActionModal/ActionModal.tsx
create mode 100644 panel/src/layout/ActionModal/ActionModifyTab.tsx
create mode 100644 panel/src/layout/AuthShell.tsx
create mode 100644 panel/src/layout/DesktopNavbar.tsx
create mode 100644 panel/src/layout/Header.tsx
create mode 100644 panel/src/layout/MainRouter.tsx
create mode 100644 panel/src/layout/MainSheets.tsx
create mode 100644 panel/src/layout/MainShell.tsx
create mode 100644 panel/src/layout/MainSocket.tsx
create mode 100644 panel/src/layout/PlayerModal/PlayerBanTab.tsx
create mode 100644 panel/src/layout/PlayerModal/PlayerHistoryTab.tsx
create mode 100644 panel/src/layout/PlayerModal/PlayerIdsTab.tsx
create mode 100644 panel/src/layout/PlayerModal/PlayerInfoTab.tsx
create mode 100644 panel/src/layout/PlayerModal/PlayerModal.tsx
create mode 100644 panel/src/layout/PlayerModal/PlayerModalFooter.tsx
create mode 100644 panel/src/layout/PlayerlistSidebar/Playerlist.tsx
create mode 100644 panel/src/layout/PlayerlistSidebar/PlayerlistSidebar.tsx
create mode 100644 panel/src/layout/PlayerlistSidebar/PlayerlistSummary.tsx
create mode 100644 panel/src/layout/ServerSidebar/ServerControls.tsx
create mode 100644 panel/src/layout/ServerSidebar/ServerMenu.tsx
create mode 100644 panel/src/layout/ServerSidebar/ServerSchedule.tsx
create mode 100644 panel/src/layout/ServerSidebar/ServerSidebar.tsx
create mode 100644 panel/src/layout/ServerSidebar/ServerStatus.tsx
create mode 100644 panel/src/layout/WarningBar.tsx
create mode 100644 panel/src/lib/dateTime.ts
create mode 100644 panel/src/lib/hotkeyEventListener.ts
create mode 100644 panel/src/lib/humanizeDuration.ts
create mode 100644 panel/src/lib/navigation.ts
create mode 100644 panel/src/lib/playerDropCategories.ts
create mode 100644 panel/src/lib/utils.ts
create mode 100644 panel/src/main.tsx
create mode 100644 panel/src/pages/AddLegacyBanPage.tsx
create mode 100644 panel/src/pages/BanTemplates/BanTemplatesInputDialog.tsx
create mode 100644 panel/src/pages/BanTemplates/BanTemplatesListAddButton.tsx
create mode 100644 panel/src/pages/BanTemplates/BanTemplatesListItem.tsx
create mode 100644 panel/src/pages/BanTemplates/BanTemplatesPage.tsx
create mode 100644 panel/src/pages/Dashboard/DashboardPage.tsx
create mode 100644 panel/src/pages/Dashboard/FullPerfCard.tsx
create mode 100644 panel/src/pages/Dashboard/PlayerDropCard.tsx
create mode 100644 panel/src/pages/Dashboard/ServerStatsCard.tsx
create mode 100644 panel/src/pages/Dashboard/ThreadPerfCard.tsx
create mode 100644 panel/src/pages/Dashboard/chartingUtils.test.ts
create mode 100644 panel/src/pages/Dashboard/chartingUtils.ts
create mode 100644 panel/src/pages/Dashboard/dashboardHooks.ts
create mode 100644 panel/src/pages/Dashboard/drawFullPerfChart.ts
create mode 100644 panel/src/pages/History/HistoryPage.tsx
create mode 100644 panel/src/pages/History/HistorySearchBox.tsx
create mode 100644 panel/src/pages/History/HistoryTable.tsx
create mode 100644 panel/src/pages/Iframe.tsx
create mode 100644 panel/src/pages/LiveConsole/LiveConsoleFooter.tsx
create mode 100644 panel/src/pages/LiveConsole/LiveConsoleHeader.tsx
create mode 100644 panel/src/pages/LiveConsole/LiveConsolePage.tsx
create mode 100644 panel/src/pages/LiveConsole/LiveConsoleSaveSheet.tsx
create mode 100644 panel/src/pages/LiveConsole/LiveConsoleSearchBar.tsx
create mode 100644 panel/src/pages/LiveConsole/ScrollDownAddon.ts
create mode 100644 panel/src/pages/LiveConsole/liveConsoleHooks.ts
create mode 100644 panel/src/pages/LiveConsole/liveConsoleMarkers.ts
create mode 100644 panel/src/pages/LiveConsole/liveConsoleUtils.test.ts
create mode 100644 panel/src/pages/LiveConsole/liveConsoleUtils.ts
create mode 100644 panel/src/pages/LiveConsole/xtermOptions.ts
create mode 100644 panel/src/pages/LiveConsole/xtermOverrides.css
create mode 100644 panel/src/pages/NotFound.tsx
create mode 100644 panel/src/pages/PlayerDropsPage/DrilldownCard.tsx
create mode 100644 panel/src/pages/PlayerDropsPage/DrilldownChangesSubcard.tsx
create mode 100644 panel/src/pages/PlayerDropsPage/DrilldownCrashesSubcard.tsx
create mode 100644 panel/src/pages/PlayerDropsPage/DrilldownOverviewSubcard.tsx
create mode 100644 panel/src/pages/PlayerDropsPage/DrilldownResourcesSubcard.tsx
create mode 100644 panel/src/pages/PlayerDropsPage/PlayerDropsGenericSubcards.tsx
create mode 100644 panel/src/pages/PlayerDropsPage/PlayerDropsPage.tsx
create mode 100644 panel/src/pages/PlayerDropsPage/TimelineCard.tsx
create mode 100644 panel/src/pages/PlayerDropsPage/TimelineDropsChart.tsx
create mode 100644 panel/src/pages/PlayerDropsPage/chartingUtils.ts
create mode 100644 panel/src/pages/PlayerDropsPage/drawDropsTimeline.ts
create mode 100644 panel/src/pages/PlayerDropsPage/utils.test.ts
create mode 100644 panel/src/pages/PlayerDropsPage/utils.ts
create mode 100644 panel/src/pages/Players/PlayersPage.tsx
create mode 100644 panel/src/pages/Players/PlayersSearchBox.tsx
create mode 100644 panel/src/pages/Players/PlayersTable.tsx
create mode 100644 panel/src/pages/Settings/SettingsCardShell.tsx
create mode 100644 panel/src/pages/Settings/SettingsPage.tsx
create mode 100644 panel/src/pages/Settings/SettingsTab.tsx
create mode 100644 panel/src/pages/Settings/settingsItems.tsx
create mode 100644 panel/src/pages/Settings/tabCards/_blank.tsx
create mode 100644 panel/src/pages/Settings/tabCards/_template.tsx
create mode 100644 panel/src/pages/Settings/tabCards/bans.tsx
create mode 100644 panel/src/pages/Settings/tabCards/discord.tsx
create mode 100644 panel/src/pages/Settings/tabCards/fxserver.tsx
create mode 100644 panel/src/pages/Settings/tabCards/gameMenu.tsx
create mode 100644 panel/src/pages/Settings/tabCards/gameNotifications.tsx
create mode 100644 panel/src/pages/Settings/tabCards/general.tsx
create mode 100644 panel/src/pages/Settings/tabCards/whitelist.tsx
create mode 100644 panel/src/pages/Settings/utils.ts
create mode 100644 panel/src/pages/SystemLogPage.tsx
create mode 100644 panel/src/pages/TestingPage/TestingPage.tsx
create mode 100644 panel/src/pages/TestingPage/TmpApi.tsx
create mode 100644 panel/src/pages/TestingPage/TmpAuthState.tsx
create mode 100644 panel/src/pages/TestingPage/TmpColors.tsx
create mode 100644 panel/src/pages/TestingPage/TmpDndSortable.tsx
create mode 100644 panel/src/pages/TestingPage/TmpFiller.tsx
create mode 100644 panel/src/pages/TestingPage/TmpHexHslConverter.tsx
create mode 100644 panel/src/pages/TestingPage/TmpJsonEditor.tsx
create mode 100644 panel/src/pages/TestingPage/TmpMarkdown.tsx
create mode 100644 panel/src/pages/TestingPage/TmpPageHeader.tsx
create mode 100644 panel/src/pages/TestingPage/TmpSocket.tsx
create mode 100644 panel/src/pages/TestingPage/TmpSwr.tsx
create mode 100644 panel/src/pages/TestingPage/TmpToasts.tsx
create mode 100644 panel/src/pages/TestingPage/TmpWarningBarState.tsx
create mode 100644 panel/src/pages/UnauthorizedPage.tsx
create mode 100644 panel/src/pages/auth/AddMasterCallback.tsx
create mode 100644 panel/src/pages/auth/AddMasterPin.tsx
create mode 100644 panel/src/pages/auth/CfxreCallback.tsx
create mode 100644 panel/src/pages/auth/Login.tsx
create mode 100644 panel/src/pages/auth/cfxreLoginButton.css
create mode 100644 panel/src/pages/auth/errors.tsx
create mode 100644 panel/src/vite-env.d.ts
create mode 100644 panel/tailwind.config.cjs
create mode 100644 panel/tsconfig.json
create mode 100644 panel/tsconfig.node.json
create mode 100644 panel/vite.config.ts
create mode 100644 resource/cl_logger.lua
create mode 100644 resource/cl_main.lua
create mode 100644 resource/cl_playerlist.lua
create mode 100644 resource/menu/client/cl_base.lua
create mode 100644 resource/menu/client/cl_freeze.lua
create mode 100644 resource/menu/client/cl_functions.lua
create mode 100644 resource/menu/client/cl_instructional_ui.lua
create mode 100644 resource/menu/client/cl_main_page.lua
create mode 100644 resource/menu/client/cl_player_ids.lua
create mode 100644 resource/menu/client/cl_player_mode.lua
create mode 100644 resource/menu/client/cl_ptfx.lua
create mode 100644 resource/menu/client/cl_spectate.lua
create mode 100644 resource/menu/client/cl_trollactions.lua
create mode 100644 resource/menu/client/cl_vehicle.lua
create mode 100644 resource/menu/client/cl_webpipe.lua
create mode 100644 resource/menu/server/sv_freeze_player.lua
create mode 100644 resource/menu/server/sv_functions.lua
create mode 100644 resource/menu/server/sv_main_page.lua
create mode 100644 resource/menu/server/sv_player_modal.lua
create mode 100644 resource/menu/server/sv_player_mode.lua
create mode 100644 resource/menu/server/sv_spectate.lua
create mode 100644 resource/menu/server/sv_trollactions.lua
create mode 100644 resource/menu/server/sv_vehicle.lua
create mode 100644 resource/menu/server/sv_webpipe.lua
create mode 100644 resource/menu/vendor/freecam/INFO.txt
create mode 100644 resource/menu/vendor/freecam/LICENSE.txt
create mode 100644 resource/menu/vendor/freecam/camera.lua
create mode 100644 resource/menu/vendor/freecam/config.lua
create mode 100644 resource/menu/vendor/freecam/main.lua
create mode 100644 resource/menu/vendor/freecam/utils.lua
create mode 100644 resource/shared.lua
create mode 100644 resource/sv_admins.lua
create mode 100644 resource/sv_ctx.lua
create mode 100644 resource/sv_initialData.lua
create mode 100644 resource/sv_logger.lua
create mode 100644 resource/sv_main.lua
create mode 100644 resource/sv_playerlist.lua
create mode 100644 resource/sv_reportHeap.js
create mode 100644 resource/sv_resources.lua
create mode 100644 scripts/build/TxAdminRunner.ts
create mode 100644 scripts/build/config.ts
create mode 100644 scripts/build/dev.ts
create mode 100644 scripts/build/publish.ts
create mode 100644 scripts/build/utils.ts
create mode 100644 scripts/dev/fixStatsFilePlayers.code.ts
create mode 100644 scripts/dev/fixStatsFilePlayers.ts
create mode 100644 scripts/dev/makeOldStatsFile.code.ts
create mode 100644 scripts/dev/makeOldStatsFile.ts
create mode 100644 scripts/dev/quick_playerlist_tester.html
create mode 100644 scripts/lint-formatter.js
create mode 100644 scripts/list-dependencies.js
create mode 100644 scripts/list-licenses.js
create mode 100644 scripts/locale-utils.js
create mode 100644 scripts/test_build.sh
create mode 100644 scripts/typecheck-formatter.js
create mode 100644 shared/authApiTypes.ts
create mode 100644 shared/cleanFullPath.ts
create mode 100644 shared/cleanPlayerName.ts
create mode 100644 shared/consts.ts
create mode 100644 shared/enums.ts
create mode 100644 shared/genericApiTypes.ts
create mode 100644 shared/historyApiTypes.ts
create mode 100644 shared/localeMap.ts
create mode 100644 shared/otherTypes.ts
create mode 100644 shared/package.json
create mode 100644 shared/playerApiTypes.ts
create mode 100644 shared/socketioTypes.ts
create mode 100644 shared/tsconfig.json
create mode 100644 shared/txDevEnv.ts
create mode 100644 web/main/adminManager.ejs
create mode 100644 web/main/advanced.ejs
create mode 100644 web/main/cfgEditor.ejs
create mode 100644 web/main/diagnostics.ejs
create mode 100644 web/main/insights.ejs
create mode 100644 web/main/masterActions.ejs
create mode 100644 web/main/message.ejs
create mode 100644 web/main/resources.ejs
create mode 100644 web/main/serverLog.ejs
create mode 100644 web/main/whitelist.ejs
create mode 100644 web/parts/adminModal.ejs
create mode 100644 web/parts/footer.ejs
create mode 100644 web/parts/header.ejs
create mode 100644 web/public/css/codemirror.css
create mode 100644 web/public/css/codemirror_lucario.css
create mode 100644 web/public/css/coreui.css
create mode 100644 web/public/css/coreui.min.css
create mode 100644 web/public/css/dark.css
create mode 100644 web/public/css/dark.css.map
create mode 100644 web/public/css/dark.scss
create mode 100644 web/public/css/foldgutter.css
create mode 100644 web/public/css/jquery-confirm.min.css
create mode 100644 web/public/css/simple-line-icons.css
create mode 100644 web/public/css/txAdmin.css
create mode 100644 web/public/fonts/Simple-Line-Icons.woff2
create mode 100644 web/public/img/default_avatar.png
create mode 100644 web/public/img/fivem-server-icon.png
create mode 100644 web/public/img/redm-server-icon.png
create mode 100644 web/public/img/tx.png
create mode 100644 web/public/img/txSnaily2anim_320.png
create mode 100644 web/public/img/txadmin.png
create mode 100644 web/public/img/txadmin_beta.png
create mode 100644 web/public/img/unknown-server-icon.png
create mode 100644 web/public/img/zap256_black.png
create mode 100644 web/public/img/zap256_white.png
create mode 100644 web/public/js/bootstrap-notify.min.js
create mode 100644 web/public/js/codeEditor/autorefresh.js
create mode 100644 web/public/js/codeEditor/codemirror.js
create mode 100644 web/public/js/codeEditor/comment.js
create mode 100644 web/public/js/codeEditor/dialog.js
create mode 100644 web/public/js/codeEditor/mode/fivem-cfg.js
create mode 100644 web/public/js/codeEditor/mode/simple.js
create mode 100644 web/public/js/codeEditor/mode/yaml.js
create mode 100644 web/public/js/codeEditor/search.js
create mode 100644 web/public/js/codeEditor/searchcursor.js
create mode 100644 web/public/js/coreui.bundle.min.js
create mode 100644 web/public/js/jquery-confirm.min.js
create mode 100644 web/public/js/socket.io.min.js
create mode 100644 web/public/js/txadmin/base.js
create mode 100644 web/standalone/deployer.ejs
create mode 100644 web/standalone/setup.ejs
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..1eae0cf
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,2 @@
+dist/
+node_modules/
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
new file mode 100644
index 0000000..40a890c
--- /dev/null
+++ b/.eslintrc.cjs
@@ -0,0 +1,11 @@
+module.exports = {
+ env: {
+ node: true,
+ commonjs: true,
+ es2017: true,
+ },
+ parserOptions: {
+ sourceType: 'module',
+ ecmaVersion: 2021,
+ },
+};
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..c6754c0
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @tabarra
diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml
new file mode 100644
index 0000000..dced002
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml
@@ -0,0 +1,50 @@
+---
+name: Feature Request
+description: Request a feature or enhancement
+title: "[FEATURE]: type here a short description"
+labels: [enhancement, feature]
+
+body:
+- type: markdown
+ attributes:
+ value: |
+ ## Thank you for filling out this feature/enhancement request
+ Please fill out this short template to the best of your ability.
+- type: dropdown
+ id: scope
+ attributes:
+ label: Scope
+ description: What part of txAdmin is this feature targeting?
+ multiple: true
+ options:
+ - Web
+ - In-Game Menu
+ - Developer API
+ validations:
+ required: true
+- type: textarea
+ id: feat-description
+ attributes:
+ label: Feature Description
+ description: Please provide a short and concise description of the feature you are suggesting.
+ validations:
+ required: true
+- type: textarea
+ id: use-case
+ attributes:
+ label: Use Case
+ description: |
+ What use-case (a scenario where you want to do "x", but are limited) do
+ you have for requesting this feature?
+ placeholder: Ex. I wanted to do `x` but was unable too...
+- type: textarea
+ id: solution
+ attributes:
+ label: Proposed Solution
+ description: Do you have an idea for a proposed solution. If so, please describe it.
+- type: textarea
+ attributes:
+ label: Additional Info
+ description: |
+ Is there any additional information you would like to provide?
+ placeholder: Screenshots, logs, etc
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..a5c3000
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,30 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: "[BUG] type here a short description"
+labels: ''
+assignees: ''
+
+---
+
+**txAdmin/FXServer versions:**
+You can see that at the bottom of the txAdmin page, or in the terminal as soon as you start fxserver.
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Additional context**
+Add any other context about the problem here, like for example if it's a server issue, which OS is fxserver hosted on.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..da4f836
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: General Support
+ url: https://discord.gg/NsXGTszYjK
+ about: Please ask general support questions on our Discord!
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..d747cab
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,6 @@
+The `!NC` comments in code are tags used by a git pre-commit hook to prevent committing the lines containing it, and are generally used to mark TO-DOs that are required to be done before committing the changes.
+Import `suite, it, expect` from vitest for writing tests, whith each method having one `suite()` and a list of tests using the `it()` for its definition.
+Prefer implicit over explicit function return types.
+Except for React components, prefer arrow functions.
+Prefer using for..of instead of forEach.
+Prefer single quotes over doble quotes.
diff --git a/.github/workflows/locale-pull-request.yml b/.github/workflows/locale-pull-request.yml
new file mode 100644
index 0000000..5c1692c
--- /dev/null
+++ b/.github/workflows/locale-pull-request.yml
@@ -0,0 +1,45 @@
+name: Check Locale PR
+
+on:
+ pull_request:
+ paths:
+ - 'locale/**'
+
+jobs:
+ check-locale-pr:
+ runs-on: ubuntu-latest
+ permissions:
+ 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:
+ node-version: 22
+
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run locale:check
+ id: locale-check
+ run: npm run locale:check
diff --git a/.github/workflows/publish-tagged.yml b/.github/workflows/publish-tagged.yml
new file mode 100644
index 0000000..21560b0
--- /dev/null
+++ b/.github/workflows/publish-tagged.yml
@@ -0,0 +1,60 @@
+name: Publish Tagged Build
+
+on:
+ push:
+ tags:
+ - "v[0-9]+.[0-9]+.[0-9]+*"
+
+jobs:
+ build:
+ name: "Build Changelog & Release Prod"
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Use Node.js 22
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Download all modules
+ run: npm ci
+
+ # Not truly necessary for build, but for now the vite config requires it
+ - name: Create .env file
+ run: |
+ echo TXDEV_FXSERVER_PATH=$(pwd)/fxserver > .env
+ echo TXDEV_VITE_URL='http://localhost:40122' >> .env
+
+ - name: Build project
+ run: |
+ npm run build
+ cat .github/.cienv >> $GITHUB_ENV
+
+ - name: Compress build output with zip
+ run: |
+ cd dist
+ zip -r ../monitor.zip .
+ cd ..
+ sha256sum monitor.zip
+
+ - name: Attest build provenance
+ id: attest_build_provenance
+ uses: actions/attest-build-provenance@v1
+ with:
+ subject-path: monitor.zip
+
+ - name: Create and Upload Release
+ uses: "marvinpinto/action-automatic-releases@v1.2.1"
+ with:
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
+ prerelease: ${{ env.TX_IS_PRERELEASE }}
+ files: monitor.zip
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
new file mode 100644
index 0000000..69b3fdb
--- /dev/null
+++ b/.github/workflows/run-tests.yml
@@ -0,0 +1,38 @@
+name: Run Tests for Workspaces
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ branches:
+ - "**"
+
+jobs:
+ run-tests:
+ name: "Run Unit Testing"
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Use Node.js 22
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Download all modules
+ run: npm ci
+
+ - name: Create .env file
+ run: |
+ echo TXDEV_FXSERVER_PATH=$(pwd)/fxserver > .env
+ echo TXDEV_VITE_URL='http://localhost:40122' >> .env
+
+ - name: Run Tests
+ env:
+ CI: true
+ run: npm run test --workspaces
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0600f8d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,82 @@
+## Custom:
+db/*
+cache/*
+dist/*
+.reports/*
+license_report/*
+.tsc/*
+*.ignore.*
+/start_*.bat
+monitor-*.zip
+bundle_size_report.html
+.github/.cienv
+scripts/**/*.local.*
+.runtime/
+
+# IDE Specific
+.idea
+
+## Github's default node gitignore
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# TypeScript v1 declaration files
+typings/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn
+yarn.lock
+.yarn.installed
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+
+# next.js build output
+.next
diff --git a/.husky/.gitignore b/.husky/.gitignore
new file mode 100644
index 0000000..31354ec
--- /dev/null
+++ b/.husky/.gitignore
@@ -0,0 +1 @@
+_
diff --git a/.husky/commit-msg b/.husky/commit-msg
new file mode 100644
index 0000000..fe4c17a
--- /dev/null
+++ b/.husky/commit-msg
@@ -0,0 +1,4 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+npx --no-install commitlint --edit ""
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..52800f2
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,15 @@
+#!/bin/sh
+
+# Rejects commits with the !NC flag is present in the changes
+# The !NC flag is used to mark code that should not be commited to the repository
+# It's useful to avoid commiting debug code, test code, etc.
+
+# Check if the !NC flag is present in the changes
+if git diff --staged --unified=0 --no-color | grep '^+' | grep -q '!NC'; then
+ echo -e "COMMIT REJECTED: Found the !NC flag in your changes.\nMake sure you didn't accidently staged something you shouldn't!"
+ echo "Flags found:"
+ git diff --staged --unified=0 --no-color | grep -C 2 '!NC'
+ exit 1
+fi
+
+exit 0
diff --git a/.license-reportrc b/.license-reportrc
new file mode 100644
index 0000000..d018ea5
--- /dev/null
+++ b/.license-reportrc
@@ -0,0 +1,11 @@
+{
+ "output": "html",
+ "fields": [
+ "name",
+ "licenseType",
+ "link",
+ "installedVersion",
+ "definedVersion",
+ "author"
+ ]
+}
diff --git a/.npm-upgrade.json b/.npm-upgrade.json
new file mode 100644
index 0000000..5ad2e42
--- /dev/null
+++ b/.npm-upgrade.json
@@ -0,0 +1,8 @@
+{
+ "ignore": {
+ "@types/node": {
+ "versions": ">16.9.1",
+ "reason": "fixed to fxserver's node version"
+ }
+ }
+}
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8e98ecd
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2019-2025 André Tabarra
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..402cdc6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,87 @@
+
+
+
+
+
+ In 2019 txAdmin was created, with the objective of making FiveM server management accessible to everyone – no matter their skill level!
+ Today, txAdmin is the full featured web panel & in-game menu to Manage & Monitor your FiveM/RedM Server, in use by over 29.000 servers worldwide at any given time!
+
+
+ Join our Discord Server:
+
+
+
+
+
+
+
+
+## Main Features
+- Recipe-based Server Deployer: create a server in under 60 seconds! ([docs/recipe.md](docs/recipe.md))
+- Start/Stop/Restart your server instance or resources
+- Full-featured in-game admin menu:
+ - Player Mode: NoClip, God, SuperJump
+ - Teleport: waypoint, coords and back
+ - Vehicle: Spawn, Fix, Delete, Boost
+ - Heal: yourself, everyone
+ - Send Announcements
+ - Reset World Area
+ - Show player IDs
+ - Player search/sort by distance, ID, name
+ - Player interactions: Go To, Bring, Spectate, Freeze
+ - Player troll: make drunk, set fire, wild attack
+ - Player ban/warn/dm
+- Access control:
+ - Login via Cfx.re or password
+ - Admin permission system ([docs/permissions.md](docs/permissions.md))
+ - Action logging
+- Discord Integration:
+ - Server configurable, persistent, auto-updated status embed
+ - Command to whitelist players
+ - Command to display player infos
+- Monitoring:
+ - Auto Restart FXServer on crash or hang
+ - Server’s CPU/RAM consumption
+ - Live Console (with log file, command history and search)
+ - Server threads performance chart with player count
+ - Server Activity Log (connections/disconnections, kills, chat, explosions and [custom commands](docs/custom-server-log.md))
+- Player Manager:
+ - [Warning system](https://www.youtube.com/watch?v=DeE0-5vtZ4E) & Ban system
+ - Whitelist system (Discord member, Discord Role, Approved License, Admin-only)
+ - Take notes about players
+ - Keep track of player's play and session time
+ - Self-contained player database (no MySQL required!)
+ - Clean/Optimize the database by removing old players, or bans/warns/whitelists
+- Real-time playerlist
+- Scheduled restarts with warning announcements and custom events ([docs/events.md](docs/events.md))
+- Translated into over 30 languages ([docs/translation.md](docs/translation.md))
+- FiveM's Server CFG editor & validator
+- Responsive web interface with Dark Mode 😎
+- And much more...
+
+Also, check our [Feature Graveyard](docs/feature-graveyard.md) for the features that are no longer among us (😔 RIP).
+
+## Running txAdmin
+- Since early 2020, **txAdmin is a component of FXServer, so there is no need to downloading or installing anything**.
+- To start txAdmin, simply run FXServer **without** any `+exec server.cfg` launch argument, and FXServer will automatically start txAdmin.
+- On first boot, a `txData` directory will be created to store txAdmin files, and you will need to open the URL provided in the console to configure your account and server.
+
+
+## Configuration & Integrations
+- Most configuration can be done inside the txAdmin settings page, but some configs (such as TCP interface & port) are only available through Environment Variables, please see [docs/env-config.md](docs/env-config.md).
+- You can listen to server events broadcasted by txAdmin to allow for custom behavior in your resources, please see [docs/events.md](docs/events.md).
+
+
+## Contributing & Development
+- All PRs should be based on the develop branch, including translation PRs.
+- Before putting effort for any significant PR, make sure to join our discord and talk to us, since the change you want to do might not have been done for a reason or there might be some required context.
+- If you want to build it or run from source, please check [docs/development.md](docs/development.md).
+
+
+## License, Credits and Thanks
+- This project is licensed under the [MIT License](https://github.com/tabarra/txAdmin/blob/master/LICENSE);
+- ["Kick" button icons](https://www.flaticon.com/free-icon/users-avatar_8188385) made by __SeyfDesigner__ from [www.flaticon.com](https://www.flaticon.com);
+- Warning Sounds ([1](https://freesound.org/people/Ultranova105/sounds/136756/)/[2](https://freesound.org/people/Ultranova105/sounds/136754/)) made by __Ultranova105__ are licensed under [CC 3.0 BY](http://creativecommons.org/licenses/by/3.0/);
+- [Announcement Sound](https://freesound.org/people/IENBA/sounds/545495/) made by __IENBA__ is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/);
+- [Message Sound](https://freesound.org/people/Divinux/sounds/198414/) made by __Divinux__ is licensed under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/);
+- Especial thanks to everyone that contributed to this project, especially the very fine Discord folks that provide support for others;
diff --git a/commitlint.config.cjs b/commitlint.config.cjs
new file mode 100644
index 0000000..1c4a4b9
--- /dev/null
+++ b/commitlint.config.cjs
@@ -0,0 +1,25 @@
+const types = [
+ 'build',
+ 'chore',
+ 'ci',
+ 'docs',
+ 'feat',
+ 'fix',
+ 'perf',
+ 'refactor',
+ 'revert',
+ 'style',
+ 'test',
+
+ //custom
+ 'tweak',
+ 'wip',
+ 'locale',
+];
+
+module.exports = {
+ extends: ['@commitlint/config-conventional'],
+ rules: {
+ 'type-enum': [2, 'always', types],
+ },
+};
diff --git a/core/.eslintrc.cjs b/core/.eslintrc.cjs
new file mode 100644
index 0000000..2a38a9c
--- /dev/null
+++ b/core/.eslintrc.cjs
@@ -0,0 +1,83 @@
+module.exports = {
+ env: {
+ node: true,
+ commonjs: true,
+ es2017: true,
+ },
+ globals: {
+ GlobalData: 'writable',
+ ExecuteCommand: 'readonly',
+ GetConvar: 'readonly',
+ GetCurrentResourceName: 'readonly',
+ GetPasswordHash: 'readonly',
+ GetResourceMetadata: 'readonly',
+ GetResourcePath: 'readonly',
+ IsDuplicityVersion: 'readonly',
+ VerifyPasswordHash: 'readonly',
+ },
+ extends: [],
+ ignorePatterns: [
+ '*.ignore.*',
+ ],
+ rules: {
+ //Review these
+ 'no-control-regex': 'off',
+ 'no-empty': ['error', { allowEmptyCatch: true }],
+ 'no-prototype-builtins': 'off',
+ 'no-unused-vars': ['warn', {
+ varsIgnorePattern: '^_\\w*',
+ vars: 'all',
+ args: 'none', //diff
+ ignoreRestSiblings: true,
+ }],
+
+ //From Airbnb, fixed them already
+ 'keyword-spacing': ['error', {
+ before: true,
+ after: true,
+ overrides: {
+ return: { after: true },
+ throw: { after: true },
+ case: { after: true },
+ },
+ }],
+ 'space-before-blocks': 'error',
+ quotes: ['error', 'single', { allowTemplateLiterals: true }],
+ semi: ['error', 'always'],
+ 'no-trailing-spaces': ['error', {
+ skipBlankLines: false,
+ ignoreComments: false,
+ }],
+ 'space-infix-ops': 'error',
+ 'comma-dangle': ['error', {
+ arrays: 'always-multiline',
+ objects: 'always-multiline',
+ imports: 'always-multiline',
+ exports: 'always-multiline',
+ functions: 'always-multiline',
+ }],
+ 'padded-blocks': ['error', {
+ blocks: 'never',
+ classes: 'never',
+ switches: 'never',
+ }, {
+ allowSingleLineBlocks: true,
+ }],
+ 'comma-spacing': ['error', { before: false, after: true }],
+ 'arrow-spacing': ['error', { before: true, after: true }],
+ 'arrow-parens': ['error', 'always'],
+ 'operator-linebreak': ['error', 'before', { overrides: { '=': 'none' } }],
+
+ // Custom
+ indent: ['error', 4],
+
+ // FIXME: re-enable it somewhen
+ 'linebreak-style': 'off',
+ 'spaced-comment': 'off',
+ 'object-curly-spacing': 'off', //maybe keep this disabled?
+ 'arrow-body-style': 'off', //maybe keep this disabled?
+
+ // Check it:
+ // 'object-curly-newline': ['error', 'never'],
+ },
+};
diff --git a/core/.npm-upgrade.json b/core/.npm-upgrade.json
new file mode 100644
index 0000000..0afa310
--- /dev/null
+++ b/core/.npm-upgrade.json
@@ -0,0 +1,48 @@
+{
+ "ignore": {
+ "open": {
+ "versions": ">7.1.0",
+ "reason": "Doesn't work when powershell is not in PATH or something like that."
+ },
+ "fs-extra": {
+ "versions": ">9",
+ "reason": "recipe errors on some OSs"
+ },
+ "execa": {
+ "versions": "^5",
+ "reason": "following @sindresorhus/windows-release"
+ },
+ "windows-release": {
+ "versions": "^5.1.1",
+ "reason": "inlined to transform it into async"
+ },
+ "nanoid": {
+ "versions": ">4",
+ "reason": "dropped support for node 16"
+ },
+ "got": {
+ "versions": ">13",
+ "reason": "dropped support for node 16"
+ },
+ "jose": {
+ "versions": ">4",
+ "reason": "dropped support for node 16"
+ },
+ "lowdb": {
+ "versions": ">6",
+ "reason": "dropped support for node 16"
+ },
+ "slug": {
+ "versions": ">8",
+ "reason": "dropped support for node 16"
+ },
+ "boxen": {
+ "versions": ">7",
+ "reason": "dropped support for node 16"
+ },
+ "discord.js": {
+ "versions": ">14.11.0",
+ "reason": "undici sub-dependency dropped support for node 16"
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/boot/checkPreRelease.ts b/core/boot/checkPreRelease.ts
new file mode 100644
index 0000000..326f08a
--- /dev/null
+++ b/core/boot/checkPreRelease.ts
@@ -0,0 +1,48 @@
+import consoleFactory from '@lib/console';
+import fatalError from '@lib/fatalError';
+import { chalkInversePad, msToDuration } from '@lib/misc';
+const console = consoleFactory('ATTENTION');
+
+
+//@ts-ignore esbuild will replace TX_PRERELEASE_EXPIRATION with a string
+const PRERELEASE_EXPIRATION = parseInt(TX_PRERELEASE_EXPIRATION)
+
+const expiredError = [
+ 'This pre-release version has expired, please update your txAdmin.',
+ 'Bye bye 👋',
+]
+
+const printExpirationBanner = (timeUntilExpiration: number) => {
+ const timeLeft = msToDuration(timeUntilExpiration)
+ console.error('This is a pre-release version of txAdmin!');
+ console.error('This build is meant to be used by txAdmin beta testers.');
+ console.error('txAdmin will automatically shut down when this pre-release expires.');
+ console.error(`Time until expiration: ${chalkInversePad(timeLeft)}.`);
+ console.error('For more information: https://discord.gg/txAdmin.');
+}
+
+const cronCheckExpiration = () => {
+ if (isNaN(PRERELEASE_EXPIRATION) || PRERELEASE_EXPIRATION === 0) return;
+
+ const timeUntilExpiration = PRERELEASE_EXPIRATION - Date.now();
+ if (timeUntilExpiration < 0) {
+ fatalError.Boot(11, expiredError);
+ } else if (timeUntilExpiration < 24 * 60 * 60 * 1000) {
+ printExpirationBanner(timeUntilExpiration);
+ }
+}
+
+export default () => {
+ if (isNaN(PRERELEASE_EXPIRATION) || PRERELEASE_EXPIRATION === 0) return;
+
+ const timeUntilExpiration = PRERELEASE_EXPIRATION - Date.now();
+ if (timeUntilExpiration < 0) {
+ fatalError.Boot(10, expiredError);
+ }
+
+ //First warning
+ printExpirationBanner(timeUntilExpiration);
+
+ //Check every 15 minutes
+ setInterval(cronCheckExpiration, 15 * 60 * 1000);
+};
diff --git a/core/boot/getHostVars.ts b/core/boot/getHostVars.ts
new file mode 100644
index 0000000..c60970c
--- /dev/null
+++ b/core/boot/getHostVars.ts
@@ -0,0 +1,116 @@
+import path from 'node:path';
+import { z } from "zod";
+import { fromZodError } from "zod-validation-error";
+import fatalError from '@lib/fatalError';
+import consts from '@shared/consts';
+
+
+/**
+ * Schemas for the TXHOST_ env variables
+ */
+export const hostEnvVarSchemas = {
+ //General
+ API_TOKEN: z.union([
+ z.literal('disabled'),
+ z.string().regex(
+ /^[A-Za-z0-9_-]{16,48}$/,
+ 'Token must be alphanumeric, underscores or dashes only, and between 16 and 48 characters long.'
+ ),
+ ]),
+ DATA_PATH: z.string().min(1).refine(
+ (val) => path.isAbsolute(val),
+ 'DATA_PATH must be an absolute path'
+ ),
+ GAME_NAME: z.enum(
+ ['fivem', 'redm'],
+ { message: 'GAME_NAME must be either "gta5", "rdr3", or undefined' }
+ ),
+ MAX_SLOTS: z.coerce.number().int().positive(),
+ QUIET_MODE: z.preprocess((val) => val === 'true', z.boolean()),
+
+ //Networking
+ TXA_URL: z.string().url(),
+ TXA_PORT: z.coerce.number().int().positive().refine(
+ (val) => val !== 30120,
+ 'TXA_PORT cannot be 30120'
+ ),
+ FXS_PORT: z.coerce.number().int().positive().refine(
+ (val) => val < 40120 || val > 40150,
+ 'FXS_PORT cannot be between 40120 and 40150'
+ ),
+ INTERFACE: z.string().ip({ version: "v4" }),
+
+ //Provider
+ PROVIDER_NAME: z.string()
+ .regex(
+ /^[a-zA-Z0-9_.\- ]{2,16}$/,
+ 'Provider name must be between 2 and 16 characters long and can only contain letters, numbers, underscores, periods, hyphens and spaces.'
+ )
+ .refine(
+ x => !/[_.\- ]{2,}/.test(x),
+ 'Provider name cannot contain consecutive special characters.'
+ )
+ .refine(
+ x => /^[a-zA-Z0-9].*[a-zA-Z0-9]$/.test(x),
+ 'Provider name must start and end with a letter or number.'
+ ),
+ PROVIDER_LOGO: z.string().url(),
+
+ //Defaults (no reason to coerce or check, except the cfxkey)
+ DEFAULT_DBHOST: z.string(),
+ DEFAULT_DBPORT: z.string(),
+ DEFAULT_DBUSER: z.string(),
+ DEFAULT_DBPASS: z.string(),
+ DEFAULT_DBNAME: z.string(),
+ DEFAULT_ACCOUNT: z.string().refine(
+ (val) => {
+ const parts = val.split(':').length;
+ return parts === 2 || parts === 3;
+ },
+ 'The account needs to be in the username:fivemId or username:fivemId:bcrypt format',
+ ),
+ DEFAULT_CFXKEY: z.string().refine(
+ //apparently zap still uses the old format?
+ (val) => consts.regexSvLicenseNew.test(val) || consts.regexSvLicenseOld.test(val),
+ 'The key needs to be in the cfxk_xxxxxxxxxxxxxxxxxxxx_yyyyy format'
+ ),
+} as const;
+
+export type HostEnvVars = {
+ [K in keyof typeof hostEnvVarSchemas]: z.infer | undefined;
+}
+
+
+/**
+ * Parses the TXHOST_ env variables
+ */
+export const getHostVars = () => {
+ const txHostEnv: any = {};
+ for (const partialKey of Object.keys(hostEnvVarSchemas)) {
+ const keySchema = hostEnvVarSchemas[partialKey as keyof HostEnvVars];
+ const fullKey = `TXHOST_` + partialKey;
+ const value = process.env[fullKey];
+ if (value === undefined || value === '') continue;
+ if(/^['"]|['"]$/.test(value)) {
+ fatalError.GlobalData(20, [
+ 'Invalid value for a TXHOST environment variable.',
+ 'The value is surrounded by quotes (" or \'), and you must remove them.',
+ 'This is likely a mistake in how you declared this env var.',
+ ['Key', fullKey],
+ ['Value', String(value)],
+ 'For more information: https://aka.cfx.re/txadmin-env-config',
+ ]);
+ }
+ const res = keySchema.safeParse(value);
+ if (!res.success) {
+ fatalError.GlobalData(20, [
+ 'Invalid value for a TXHOST environment variable.',
+ ['Key', fullKey],
+ ['Value', String(value)],
+ 'For more information: https://aka.cfx.re/txadmin-env-config',
+ ], fromZodError(res.error, { prefix: null }));
+ }
+ txHostEnv[partialKey] = res.data;
+ }
+ return txHostEnv as HostEnvVars;
+}
diff --git a/core/boot/getNativeVars.ts b/core/boot/getNativeVars.ts
new file mode 100644
index 0000000..6fca4c3
--- /dev/null
+++ b/core/boot/getNativeVars.ts
@@ -0,0 +1,85 @@
+import path from 'node:path';
+
+
+//Helper function to get convars WITHOUT a fallback value
+const undefinedKey = 'UNDEFINED:CONVAR:' + Math.random().toString(36).substring(2, 15);
+const getConvarString = (convarName: string) => {
+ const cvar = GetConvar(convarName, undefinedKey);
+ return (cvar !== undefinedKey) ? cvar.trim() : undefined;
+};
+
+//Helper to clean up the resource native responses which apparently might be 'null'
+const cleanNativeResp = (resp: any) => {
+ return (typeof resp === 'string' && resp !== 'null' && resp.length) ? resp : undefined;
+};
+
+//Warning for convar usage
+let anyWarnSent = false;
+const replacedConvarWarning = (convarName: string, newName: string) => {
+ console.warn(`WARNING: The '${convarName}' ConVar is deprecated and will be removed in the next update.`);
+ console.warn(` Please use the '${newName}' environment variable instead.`);
+ anyWarnSent = true;
+}
+
+
+/**
+ * Native variables that are required for the boot process.
+ * This file is not supposed to validate or default any of the values.
+ */
+export const getNativeVars = (ignoreDeprecatedConfigs: boolean) => {
+ //FXServer
+ const fxsVersion = getConvarString('version');
+ const fxsCitizenRoot = getConvarString('citizen_root');
+
+ //Resource
+ const resourceName = cleanNativeResp(GetCurrentResourceName());
+ if (!resourceName) throw new Error('GetCurrentResourceName() failed');
+ const txaResourceVersion = cleanNativeResp(GetResourceMetadata(resourceName, 'version', 0));
+ const txaResourcePath = cleanNativeResp(GetResourcePath(resourceName));
+
+ //Profile Convar - with warning
+ const txAdminProfile = getConvarString('serverProfile');
+ if (txAdminProfile) {
+ console.warn(`WARNING: The 'serverProfile' ConVar is deprecated and will be removed in a future update.`);
+ console.warn(` To create multiple servers, set up a different TXHOST_DATA_PATH instead.`);
+ anyWarnSent = true;
+ }
+
+ //Convars replaced by TXHOST_* env vars
+ let txDataPath, txAdminPort, txAdminInterface;
+ if (!ignoreDeprecatedConfigs) {
+ txDataPath = getConvarString('txDataPath');
+ if (txDataPath) {
+ replacedConvarWarning('txDataPath', 'TXHOST_DATA_PATH');
+ //As it used to support relative paths, we need to resolve it
+ if (!path.isAbsolute(txDataPath)) {
+ txDataPath = path.resolve(txDataPath);
+ console.error(`WARNING: The 'txDataPath' ConVar is not an absolute path, please update it to:`);
+ console.error(` TXHOST_DATA_PATH=${txDataPath}`);
+ }
+ }
+ txAdminPort = getConvarString('txAdminPort');
+ if (txAdminPort) replacedConvarWarning('txAdminPort', 'TXHOST_TXA_PORT');
+ txAdminInterface = getConvarString('txAdminInterface');
+ if (txAdminInterface) replacedConvarWarning('txAdminInterface', 'TXHOST_INTERFACE');
+ }
+
+ if (anyWarnSent) {
+ console.warn(`WARNING: For more information: https://aka.cfx.re/txadmin-env-config`);
+ }
+
+ //Final object
+ return {
+ fxsVersion,
+ fxsCitizenRoot,
+ resourceName,
+ txaResourceVersion,
+ txaResourcePath,
+
+ //custom vars
+ txAdminProfile,
+ txDataPath,
+ txAdminPort,
+ txAdminInterface,
+ };
+}
diff --git a/core/boot/getZapVars.ts b/core/boot/getZapVars.ts
new file mode 100644
index 0000000..31ba6e8
--- /dev/null
+++ b/core/boot/getZapVars.ts
@@ -0,0 +1,73 @@
+import fs from 'node:fs';
+
+
+//Keeping the typo mostly so I can remember the old usage types
+type ZapConfigVars = {
+ providerName: string;
+ forceInterface: string | undefined;
+ forceFXServerPort: number | undefined;
+ txAdminPort: number | undefined;
+ loginPageLogo: string | undefined;
+ defaultMasterAccount?: {
+ name: string,
+ password_hash: string
+ };
+ deployerDefaults: {
+ license?: string,
+ maxClients?: number,
+ mysqlHost?: string,
+ mysqlPort?: string,
+ mysqlUser?: string,
+ mysqlPassword?: string,
+ mysqlDatabase?: string,
+ };
+}
+
+const allowType = (type: 'string' | 'number', value: any) => typeof value === type ? value : undefined;
+
+
+/**
+ * Gets & parses the txAdminZapConfig.json variables
+ */
+export const getZapVars = (zapCfgFilePath: string): ZapConfigVars | undefined => {
+ if (!fs.existsSync(zapCfgFilePath)) return;
+ console.warn(`WARNING: The 'txAdminZapConfig.json' file has been deprecated and this feature will be removed in the next update.`);
+ console.warn(` Please use the 'TXHOST_' environment variables instead.`);
+ console.warn(` For more information: https://aka.cfx.re/txadmin-env-config.`);
+ const cfgFileData = JSON.parse(fs.readFileSync(zapCfgFilePath, 'utf8'));
+
+ const zapVars: ZapConfigVars = {
+ providerName: 'ZAP-Hosting',
+
+ forceInterface: allowType('string', cfgFileData.interface),
+ forceFXServerPort: allowType('number', cfgFileData.fxServerPort),
+ txAdminPort: allowType('number', cfgFileData.txAdminPort),
+ loginPageLogo: allowType('string', cfgFileData.loginPageLogo),
+
+ deployerDefaults: {
+ license: allowType('string', cfgFileData.defaults.license),
+ maxClients: allowType('number', cfgFileData.defaults.maxClients),
+ mysqlHost: allowType('string', cfgFileData.defaults.mysqlHost),
+ mysqlUser: allowType('string', cfgFileData.defaults.mysqlUser),
+ mysqlPassword: allowType('string', cfgFileData.defaults.mysqlPassword),
+ mysqlDatabase: allowType('string', cfgFileData.defaults.mysqlDatabase),
+ },
+ }
+
+ //Port is a special case because the cfg is likely int, but we want string
+ if(typeof cfgFileData.defaults.mysqlPort === 'string') {
+ zapVars.deployerDefaults.mysqlPort = cfgFileData.defaults.mysqlPort;
+ } else if (typeof cfgFileData.defaults.mysqlPort === 'number') {
+ zapVars.deployerDefaults.mysqlPort = String(cfgFileData.defaults.mysqlPort);
+ }
+
+ //Validation is done in the globalData file
+ if (cfgFileData.customer) {
+ zapVars.defaultMasterAccount = {
+ name: allowType('string', cfgFileData.customer.name),
+ password_hash: allowType('string', cfgFileData.customer.password_hash),
+ };
+ }
+
+ return zapVars;
+}
diff --git a/core/boot/globalPlaceholder.ts b/core/boot/globalPlaceholder.ts
new file mode 100644
index 0000000..6ba70b8
--- /dev/null
+++ b/core/boot/globalPlaceholder.ts
@@ -0,0 +1,52 @@
+import { txDevEnv } from "@core/globalData";
+import consoleFactory from "@lib/console";
+import fatalError from "@lib/fatalError";
+const console = consoleFactory('GlobalPlaceholder');
+
+//Messages
+const MSG_VIOLATION = 'Global Proxy Access Violation!';
+const MSG_BOOT_FAIL = 'Failed to boot due to Module Race Condition.';
+const MSG_CONTACT_DEV = 'This error should never happen, please report it to the developers.';
+const MSG_ERR_PARTIAL = 'Attempted to access txCore before it was initialized!';
+
+
+/**
+ * Returns a Proxy that will throw a fatalError when accessing an uninitialized property
+ */
+export const getCoreProxy = (refSrc: any) => {
+ return new Proxy(refSrc, {
+ get: function (target, prop) {
+ // if (!txDevEnv.ENABLED && Reflect.has(target, prop)) {
+ // if (console.isVerbose) {
+ // console.majorMultilineError([
+ // MSG_VIOLATION,
+ // MSG_CONTACT_DEV,
+ // `Getter for ${String(prop)}`,
+ // ]);
+ // }
+ // return Reflect.get(target, prop).deref();
+ // }
+ fatalError.Boot(
+ 22,
+ [
+ MSG_BOOT_FAIL,
+ MSG_CONTACT_DEV,
+ ['Getter for', String(prop)],
+ ],
+ new Error(MSG_ERR_PARTIAL)
+ );
+ },
+ set: function (target, prop, value) {
+ fatalError.Boot(
+ 23,
+ [
+ MSG_BOOT_FAIL,
+ MSG_CONTACT_DEV,
+ ['Setter for', String(prop)],
+ ],
+ new Error(MSG_ERR_PARTIAL)
+ );
+ return true;
+ }
+ });
+}
diff --git a/core/boot/setup.ts b/core/boot/setup.ts
new file mode 100644
index 0000000..bff1951
--- /dev/null
+++ b/core/boot/setup.ts
@@ -0,0 +1,67 @@
+import path from 'node:path';
+import fs from 'node:fs';
+
+import fatalError from '@lib/fatalError';
+import { txEnv } from '@core/globalData';
+import ConfigStore from '@modules/ConfigStore';
+import { chalkInversePad } from '@lib/misc';
+
+
+/**
+ * Ensure the profile subfolders exist
+ */
+export const ensureProfileStructure = () => {
+ const dataPath = path.join(txEnv.profilePath, 'data');
+ if (!fs.existsSync(dataPath)) {
+ fs.mkdirSync(dataPath);
+ }
+
+ const logsPath = path.join(txEnv.profilePath, 'logs');
+ if (!fs.existsSync(logsPath)) {
+ fs.mkdirSync(logsPath);
+ }
+}
+
+
+/**
+ * Setup the profile folder structure
+ */
+export const setupProfile = () => {
+ //Create new profile folder
+ try {
+ fs.mkdirSync(txEnv.profilePath);
+ const configStructure = ConfigStore.getEmptyConfigFile();
+ fs.writeFileSync(
+ path.join(txEnv.profilePath, 'config.json'),
+ JSON.stringify(configStructure, null, 2)
+ );
+ ensureProfileStructure();
+ } catch (error) {
+ fatalError.Boot(4, [
+ 'Failed to set up data folder structure.',
+ ['Path', txEnv.profilePath],
+ ], error);
+ }
+ console.log(`Server data will be saved in ${chalkInversePad(txEnv.profilePath)}`);
+
+ //Saving start.bat (yes, I also wish this didn't exist)
+ if (txEnv.isWindows && txEnv.profileName !== 'default') {
+ const batFilename = `start_${txEnv.fxsVersion}_${txEnv.profileName}.bat`;
+ try {
+ const fxsPath = path.join(txEnv.fxsPath, 'FXServer.exe');
+ const batLines = [
+ //TODO: add note to not add any server convars in here
+ `@echo off`,
+ `"${fxsPath}" +set serverProfile "${txEnv.profileName}"`,
+ `pause`
+ ];
+ const batFolder = path.resolve(txEnv.fxsPath, '..');
+ const batPath = path.join(batFolder, batFilename);
+ fs.writeFileSync(batPath, batLines.join('\r\n'));
+ console.ok(`You can use ${chalkInversePad(batPath)} to start this profile.`);
+ } catch (error) {
+ console.warn(`Failed to create '${batFilename}' with error:`);
+ console.dir(error);
+ }
+ }
+};
diff --git a/core/boot/setupProcessHandlers.ts b/core/boot/setupProcessHandlers.ts
new file mode 100644
index 0000000..6f79ade
--- /dev/null
+++ b/core/boot/setupProcessHandlers.ts
@@ -0,0 +1,37 @@
+import { txDevEnv } from "@core/globalData";
+import consoleFactory from "@lib/console";
+const console = consoleFactory('ProcessHandlers');
+
+
+export default function setupProcessHandlers() {
+ //Handle any stdio error
+ process.stdin.on('error', (data) => { });
+ process.stdout.on('error', (data) => { });
+ process.stderr.on('error', (data) => { });
+
+ //Handling warnings (ignoring some)
+ Error.stackTraceLimit = 25;
+ process.removeAllListeners('warning'); //FIXME: this causes errors in Bun
+ process.on('warning', (warning) => {
+ //totally ignoring the warning, we know this is bad and shouldn't happen
+ if (warning.name === 'UnhandledPromiseRejectionWarning') return;
+
+ if (warning.name !== 'ExperimentalWarning' || txDevEnv.ENABLED) {
+ console.verbose.dir(warning, { multilineError: true });
+ }
+ });
+
+ //Handle "the unexpected"
+ process.on('unhandledRejection', (err: Error) => {
+ //We are handling this inside the DiscordBot component
+ if (err.message === 'Used disallowed intents') return;
+
+ console.error('Ohh nooooo - unhandledRejection');
+ console.dir(err);
+ });
+ process.on('uncaughtException', function (err: Error) {
+ console.error('Ohh nooooo - uncaughtException');
+ console.error(err.message);
+ console.dir(err.stack);
+ });
+}
diff --git a/core/boot/startReadyWatcher.ts b/core/boot/startReadyWatcher.ts
new file mode 100644
index 0000000..ad93f0b
--- /dev/null
+++ b/core/boot/startReadyWatcher.ts
@@ -0,0 +1,193 @@
+import boxen, { type Options as BoxenOptions } from 'boxen';
+import chalk from 'chalk';
+import open from 'open';
+import { shuffle } from 'd3-array';
+import { z } from 'zod';
+
+import got from '@lib/got';
+import getOsDistro from '@lib/host/getOsDistro.js';
+import { txDevEnv, txEnv, txHostConfig } from '@core/globalData';
+import consoleFactory from '@lib/console';
+import { addLocalIpAddress } from '@lib/host/isIpAddressLocal';
+import { chalkInversePad } from '@lib/misc';
+const console = consoleFactory();
+
+
+const getPublicIp = async () => {
+ const zIpValidator = z.string().ip();
+ const reqOptions = {
+ timeout: { request: 2000 },
+ };
+ const httpGetter = async (url: string, jsonPath: string) => {
+ const res = await got(url, reqOptions).json();
+ return zIpValidator.parse((res as any)[jsonPath]);
+ };
+
+ const allApis = shuffle([
+ ['https://api.ipify.org?format=json', 'ip'],
+ ['https://api.myip.com', 'ip'],
+ ['https://ipv4.jsonip.com/', 'ip'],
+ ['https://api.my-ip.io/v2/ip.json', 'ip'],
+ ['https://www.l2.io/ip.json', 'ip'],
+ ]);
+ for await (const [url, jsonPath] of allApis) {
+ try {
+ return await httpGetter(url, jsonPath);
+ } catch (error) { }
+ }
+ return false;
+};
+
+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.',
+ ];
+ 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();
+ return (distro && distro.includes('Linux') || distro.includes('Server'))
+ ? serverMessage
+ : winWorkstationMessage;
+};
+
+const awaitHttp = new Promise((resolve, reject) => {
+ const tickLimit = 100; //if over 15 seconds
+ let counter = 0;
+ let interval: NodeJS.Timeout;
+ const check = () => {
+ counter++;
+ if (txCore.webServer && txCore.webServer.isListening && txCore.webServer.isServing) {
+ clearInterval(interval);
+ resolve(true);
+ } else if (counter == tickLimit) {
+ clearInterval(interval);
+ interval = setInterval(check, 2500);
+ } else if (counter > tickLimit) {
+ console.warn('The WebServer is taking too long to start:', {
+ module: !!txCore.webServer,
+ listening: txCore?.webServer?.isListening,
+ serving: txCore?.webServer?.isServing,
+ });
+ }
+ };
+ interval = setInterval(check, 150);
+});
+
+const awaitMasterPin = new Promise((resolve, reject) => {
+ const tickLimit = 100; //if over 15 seconds
+ let counter = 0;
+ let interval: NodeJS.Timeout;
+ const check = () => {
+ counter++;
+ if (txCore.adminStore && txCore.adminStore.admins !== null) {
+ clearInterval(interval);
+ const pin = (txCore.adminStore.admins === false) ? txCore.adminStore.addMasterPin : false;
+ resolve(pin);
+ } else if (counter == tickLimit) {
+ clearInterval(interval);
+ interval = setInterval(check, 2500);
+ } else if (counter > tickLimit) {
+ console.warn('The AdminStore is taking too long to start:', {
+ module: !!txCore.adminStore,
+ admins: txCore?.adminStore?.admins === null ? 'null' : 'not null',
+ });
+ }
+ };
+ interval = setInterval(check, 150);
+});
+
+const awaitDatabase = new Promise((resolve, reject) => {
+ const tickLimit = 100; //if over 15 seconds
+ let counter = 0;
+ let interval: NodeJS.Timeout;
+ const check = () => {
+ counter++;
+ if (txCore.database && txCore.database.isReady) {
+ clearInterval(interval);
+ resolve(true);
+ } else if (counter == tickLimit) {
+ clearInterval(interval);
+ interval = setInterval(check, 2500);
+ } else if (counter > tickLimit) {
+ console.warn('The Database is taking too long to start:', {
+ module: !!txCore.database,
+ ready: !!txCore?.database?.isReady,
+ });
+ }
+ };
+ interval = setInterval(check, 150);
+});
+
+
+export const startReadyWatcher = async (cb: () => void) => {
+ const [publicIpResp, msgRes, adminPinRes] = await Promise.allSettled([
+ getPublicIp(),
+ getOSMessage(),
+ awaitMasterPin as Promise,
+ awaitHttp,
+ awaitDatabase,
+ ]);
+
+ //Addresses
+ let detectedUrls;
+ if (txHostConfig.netInterface && txHostConfig.netInterface !== '0.0.0.0') {
+ detectedUrls = [txHostConfig.netInterface];
+ } else {
+ detectedUrls = [
+ (txEnv.isWindows) ? 'localhost' : 'your-public-ip',
+ ];
+ if ('value' in publicIpResp && publicIpResp.value) {
+ detectedUrls.push(publicIpResp.value);
+ addLocalIpAddress(publicIpResp.value);
+ }
+ }
+ const bannerUrls = txHostConfig.txaUrl
+ ? [txHostConfig.txaUrl]
+ : detectedUrls.map((addr) => `http://${addr}:${txHostConfig.txaPort}/`);
+
+ //Admin PIN
+ const adminMasterPin = 'value' in adminPinRes && adminPinRes.value ? adminPinRes.value : false;
+ const adminPinLines = !adminMasterPin ? [] : [
+ '',
+ 'Use the PIN below to register:',
+ chalk.inverse(` ${adminMasterPin} `),
+ ];
+
+ //Printing stuff
+ const boxOptions = {
+ padding: 1,
+ margin: 1,
+ align: 'center',
+ borderStyle: 'bold',
+ borderColor: 'cyan',
+ } satisfies BoxenOptions;
+ const boxLines = [
+ 'All ready! Please access:',
+ ...bannerUrls.map(chalkInversePad),
+ ...adminPinLines,
+ ];
+ console.multiline(boxen(boxLines.join('\n'), boxOptions), chalk.bgGreen);
+ if (!txDevEnv.ENABLED && !txHostConfig.netInterface && 'value' in msgRes && msgRes.value) {
+ console.multiline(msgRes.value, chalk.bgBlue);
+ }
+
+ //Opening page
+ if (txEnv.isWindows && adminMasterPin && bannerUrls[0]) {
+ const linkUrl = new URL(bannerUrls[0]);
+ linkUrl.pathname = '/addMaster/pin';
+ linkUrl.hash = adminMasterPin;
+ open(linkUrl.href);
+ }
+
+ //Callback
+ cb();
+};
diff --git a/core/deployer/index.js b/core/deployer/index.js
new file mode 100644
index 0000000..9c5aacb
--- /dev/null
+++ b/core/deployer/index.js
@@ -0,0 +1,216 @@
+const modulename = 'Deployer';
+import path from 'node:path';
+import { cloneDeep } from 'lodash-es';
+import fse from 'fs-extra';
+import open from 'open';
+import getOsDistro from '@lib/host/getOsDistro.js';
+import { txEnv } from '@core/globalData';
+import recipeEngine from './recipeEngine.js';
+import consoleFactory from '@lib/console.js';
+import recipeParser from './recipeParser.js';
+import { getTimeHms } from '@lib/misc.js';
+import { makeTemplateRecipe } from './utils.js';
+const console = consoleFactory(modulename);
+
+
+//Constants
+export const RECIPE_DEPLOYER_VERSION = 3;
+
+
+/**
+ * The deployer class is responsible for running the recipe and handling status and errors
+ */
+export class Deployer {
+ /**
+ * @param {string|false} originalRecipe
+ * @param {string} deployPath
+ * @param {boolean} isTrustedSource
+ * @param {object} customMetaData
+ */
+ constructor(originalRecipe, deploymentID, deployPath, isTrustedSource, customMetaData = {}) {
+ console.log('Deployer instance ready.');
+
+ //Setup variables
+ this.step = 'review'; //FIXME: transform into an enum
+ this.deployFailed = false;
+ this.deployPath = deployPath;
+ this.isTrustedSource = isTrustedSource;
+ this.originalRecipe = originalRecipe;
+ this.deploymentID = deploymentID;
+ this.progress = 0;
+ this.serverName = customMetaData.serverName || txConfig.general.serverName || '';
+ this.logLines = [];
+
+ //Load recipe
+ const impRecipe = (originalRecipe !== false)
+ ? originalRecipe
+ : makeTemplateRecipe(customMetaData.serverName, customMetaData.author);
+ try {
+ this.recipe = recipeParser(impRecipe);
+ } catch (error) {
+ console.verbose.dir(error);
+ throw new Error(`Recipe Error: ${error.message}`);
+ }
+ }
+
+ //Dumb helpers - don't care enough to make this less bad
+ customLog(str) {
+ this.logLines.push(`[${getTimeHms()}] ${str}`);
+ console.log(str);
+ }
+ customLogError(str) {
+ this.logLines.push(`[${getTimeHms()}] ${str}`);
+ console.error(str);
+ }
+ getDeployerLog() {
+ return this.logLines.join('\n');
+ }
+
+ /**
+ * Confirms the recipe and goes to the input stage
+ * @param {string} userRecipe
+ */
+ async confirmRecipe(userRecipe) {
+ if (this.step !== 'review') throw new Error('expected review step');
+
+ //Parse/set recipe
+ try {
+ this.recipe = recipeParser(userRecipe);
+ } catch (error) {
+ throw new Error(`Cannot start() deployer due to a Recipe Error: ${error.message}`);
+ }
+
+ //Ensure deployment path
+ try {
+ await fse.ensureDir(this.deployPath);
+ } catch (error) {
+ console.verbose.dir(error);
+ throw new Error(`Failed to create ${this.deployPath} with error: ${error.message}`);
+ }
+
+ this.step = 'input';
+ }
+
+ /**
+ * Returns the recipe variables for the deployer run step
+ */
+ getRecipeVars() {
+ if (this.step !== 'input') throw new Error('expected input step');
+ return cloneDeep(this.recipe.variables);
+ //TODO: ?? Object.keys pra montar varname: {type: 'string'}?
+ }
+
+ /**
+ * Starts the deployment process
+ * @param {string} userInputs
+ */
+ start(userInputs) {
+ if (this.step !== 'input') throw new Error('expected input step');
+ Object.assign(this.recipe.variables, userInputs);
+ this.logLines = [];
+ this.customLog(`Starting deployment of ${this.recipe.name}.`);
+ this.deployFailed = false;
+ this.progress = 0;
+ this.step = 'run';
+ this.runTasks();
+ }
+
+ /**
+ * Marks the deploy as failed
+ */
+ async markFailedDeploy() {
+ this.deployFailed = true;
+ try {
+ const filePath = path.join(this.deployPath, '_DEPLOY_FAILED_DO_NOT_USE');
+ await fse.outputFile(filePath, 'This deploy has failed, please do not use these files.');
+ } catch (error) { }
+ }
+
+ /**
+ * (Private) Run the tasks in a sequential way.
+ */
+ async runTasks() {
+ if (this.step !== 'run') throw new Error('expected run step');
+ const contextVariables = cloneDeep(this.recipe.variables);
+ contextVariables.deploymentID = this.deploymentID;
+ contextVariables.serverName = this.serverName;
+ contextVariables.recipeName = this.recipe.name;
+ contextVariables.recipeAuthor = this.recipe.author;
+ contextVariables.recipeDescription = this.recipe.description;
+
+ //Run all the tasks
+ for (let index = 0; index < this.recipe.tasks.length; index++) {
+ this.progress = Math.round((index / this.recipe.tasks.length) * 100);
+ const task = this.recipe.tasks[index];
+ const taskID = `[task${index + 1}:${task.action}]`;
+ this.customLog(`Running ${taskID}...`);
+ const taskTimeoutSeconds = task.timeoutSeconds ?? recipeEngine[task.action].timeoutSeconds;
+
+ try {
+ contextVariables.$step = `loading task ${task.action}`;
+ await Promise.race([
+ recipeEngine[task.action].run(task, this.deployPath, contextVariables),
+ new Promise((resolve, reject) => {
+ setTimeout(() => {
+ reject(new Error(`timed out after ${taskTimeoutSeconds}s.`));
+ }, taskTimeoutSeconds * 1000);
+ }),
+ ]);
+ this.logLines[this.logLines.length - 1] += ' ✔️';
+ } catch (error) {
+ this.logLines[this.logLines.length - 1] += ' ❌';
+ let msg = `Task Failed: ${error.message}\n`
+ + 'Options: \n'
+ + JSON.stringify(task, null, 2);
+ if (contextVariables.$step) {
+ msg += '\nDebug/Status: '
+ + JSON.stringify([
+ txEnv.txaVersion,
+ await getOsDistro(),
+ contextVariables.$step
+ ]);
+ }
+ this.customLogError(msg);
+ return await this.markFailedDeploy();
+ }
+ }
+
+ //Set progress
+ this.progress = 100;
+ this.customLog('All tasks completed.');
+
+ //Check deploy folder validity (resources + server.cfg)
+ try {
+ if (!fse.existsSync(path.join(this.deployPath, 'resources'))) {
+ throw new Error('this recipe didn\'t create a \'resources\' folder.');
+ } else if (!fse.existsSync(path.join(this.deployPath, 'server.cfg'))) {
+ throw new Error('this recipe didn\'t create a \'server.cfg\' file.');
+ }
+ } catch (error) {
+ this.customLogError(`Deploy validation error: ${error.message}`);
+ return await this.markFailedDeploy();
+ }
+
+ //Replace all vars in the server.cfg
+ try {
+ const task = {
+ mode: 'all_vars',
+ file: './server.cfg',
+ };
+ await recipeEngine['replace_string'].run(task, this.deployPath, contextVariables);
+ this.customLog('Replacing all vars in server.cfg... ✔️');
+ } catch (error) {
+ this.customLogError(`Failed to replace all vars in server.cfg: ${error.message}`);
+ return await this.markFailedDeploy();
+ }
+
+ //Else: success :)
+ this.customLog('Deploy finished and folder validated. All done!');
+ this.step = 'configure';
+ if (txEnv.isWindows) {
+ try {
+ await open(path.normalize(this.deployPath), { app: 'explorer' });
+ } catch (error) { }
+ }
+ }
+}
diff --git a/core/deployer/recipeEngine.js b/core/deployer/recipeEngine.js
new file mode 100644
index 0000000..2a27550
--- /dev/null
+++ b/core/deployer/recipeEngine.js
@@ -0,0 +1,565 @@
+const modulename = 'RecipeEngine';
+import { promisify } from 'node:util';
+import fse from 'fs-extra';
+import fsp from 'node:fs/promises';
+import path from 'node:path';
+import stream from 'node:stream';
+import StreamZip from 'node-stream-zip';
+import { cloneDeep, escapeRegExp } from 'lodash-es';
+import mysql from 'mysql2/promise';
+import got from '@lib/got';
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+
+
+//Helper functions
+const safePath = (base, suffix) => {
+ const safeSuffix = path.normalize(suffix).replace(/^(\.\.(\/|\\|$))+/, '');
+ return path.join(base, safeSuffix);
+};
+const isPathLinear = (pathInput) => {
+ return pathInput.match(/(\.\.(\/|\\|$))+/g) === null;
+};
+const isPathRoot = (pathInput) => {
+ return /^\.[/\\]*$/.test(pathInput);
+};
+const pathCleanTrail = (pathInput) => {
+ return pathInput.replace(/[/\\]+$/, '');
+};
+const isPathValid = (pathInput, acceptRoot = true) => {
+ return (
+ typeof pathInput == 'string'
+ && pathInput.length
+ && isPathLinear(pathInput)
+ && (acceptRoot || !isPathRoot(pathInput))
+ );
+};
+const replaceVars = (inputString, deployerCtx) => {
+ const allVars = Object.keys(deployerCtx);
+ for (const varName of allVars) {
+ const varNameReplacer = new RegExp(escapeRegExp(`{{${varName}}}`), 'g');
+ inputString = inputString.replace(varNameReplacer, deployerCtx[varName].toString());
+ }
+ return inputString;
+};
+
+
+/**
+ * Downloads a file to a target path using streams
+ */
+const validatorDownloadFile = (options) => {
+ return (
+ typeof options.url == 'string'
+ && isPathValid(options.path)
+ );
+};
+const taskDownloadFile = async (options, basePath, deployerCtx) => {
+ if (!validatorDownloadFile(options)) throw new Error('invalid options');
+ if (options.path.endsWith('/')) throw new Error('target filename not specified'); //FIXME: this should be on the validator
+
+ //Process and create target file/path
+ const destPath = safePath(basePath, options.path);
+ await fse.outputFile(destPath, 'file save attempt, please ignore or remove');
+
+ //Start file download and create write stream
+ deployerCtx.$step = 'before stream';
+ const gotOptions = {
+ timeout: { request: 150e3 },
+ retry: { limit: 5 },
+ };
+ const gotStream = got.stream(options.url, gotOptions);
+ gotStream.on('downloadProgress', (progress) => {
+ deployerCtx.$step = `downloading ${Math.round(progress.percent * 100)}%`;
+ });
+ const pipeline = promisify(stream.pipeline);
+ await pipeline(
+ gotStream,
+ fse.createWriteStream(destPath),
+ );
+ deployerCtx.$step = 'after stream';
+};
+
+
+/**
+ * Downloads a github repository with an optional reference (branch, tag, commit hash) or subpath.
+ * If the directory structure does not exist, it is created.
+ */
+const githubRepoSourceRegex = /^((https?:\/\/github\.com\/)?|@)?([\w.\-_]+)\/([\w.\-_]+).*$/;
+const validatorDownloadGithub = (options) => {
+ return (
+ typeof options.src == 'string'
+ && isPathValid(options.dest, false)
+ && (typeof options.ref == 'string' || typeof options.ref == 'undefined')
+ && (typeof options.subpath == 'string' || typeof options.subpath == 'undefined')
+ );
+};
+const taskDownloadGithub = async (options, basePath, deployerCtx) => {
+ if (!validatorDownloadGithub(options)) throw new Error('invalid options');
+ //FIXME: caso seja eperm, tentar criar um arquivo na pasta e checar se funciona
+
+ //Parsing source
+ deployerCtx.$step = 'task start';
+ const srcMatch = options.src.match(githubRepoSourceRegex);
+ if (!srcMatch || !srcMatch[3] || !srcMatch[4]) throw new Error('invalid repository');
+ const repoOwner = srcMatch[3];
+ const repoName = srcMatch[4];
+
+ //Setting git ref
+ let reference;
+ if (options.ref) {
+ reference = options.ref;
+ } else {
+ const data = await got.get(
+ `https://api.github.com/repos/${repoOwner}/${repoName}`,
+ {
+ timeout: { request: 15e3 }
+ }
+ ).json();
+ if (typeof data !== 'object' || !data.default_branch) {
+ throw new Error('reference not set, and wasn ot able to detect using github\'s api');
+ }
+ reference = data.default_branch;
+ }
+ deployerCtx.$step = 'ref set';
+
+ //Preparing vars
+ const downURL = `https://api.github.com/repos/${repoOwner}/${repoName}/zipball/${reference}`;
+ const tmpFilePath = path.join(basePath, `.${(Date.now() % 100000000).toString(36)}.download`);
+ const destPath = safePath(basePath, options.dest);
+
+ //Downloading file
+ deployerCtx.$step = 'before stream';
+ const gotOptions = {
+ timeout: { request: 150e3 },
+ retry: { limit: 5 },
+ };
+ const gotStream = got.stream(downURL, gotOptions);
+ gotStream.on('downloadProgress', (progress) => {
+ deployerCtx.$step = `downloading ${Math.round(progress.percent * 100)}%`;
+ });
+ const pipeline = promisify(stream.pipeline);
+ await pipeline(
+ gotStream,
+ fse.createWriteStream(tmpFilePath),
+ );
+ deployerCtx.$step = 'after stream';
+
+ //Extracting files
+ const zip = new StreamZip.async({ file: tmpFilePath });
+ const entries = Object.values(await zip.entries());
+ if (!entries.length || !entries[0].isDirectory) throw new Error('unexpected zip structure');
+ const zipSubPath = path.posix.join(entries[0].name, options.subpath || '');
+ deployerCtx.$step = 'zip parsed';
+ await fsp.mkdir(destPath, { recursive: true });
+ deployerCtx.$step = 'dest path created';
+ await zip.extract(zipSubPath, destPath);
+ deployerCtx.$step = 'zip extracted';
+ await zip.close();
+ deployerCtx.$step = 'zip closed';
+
+ //Removing temp path
+ await fse.remove(tmpFilePath);
+ deployerCtx.$step = 'task finished';
+};
+
+
+/**
+ * Removes a file or directory. The directory can have contents. If the path does not exist, silently does nothing.
+ */
+const validatorRemovePath = (options) => {
+ return (
+ isPathValid(options.path, false)
+ );
+};
+const taskRemovePath = async (options, basePath, deployerCtx) => {
+ if (!validatorRemovePath(options)) throw new Error('invalid options');
+
+ //Process and create target file/path
+ const targetPath = safePath(basePath, options.path);
+
+ //NOTE: being extra safe about not deleting itself
+ const cleanBasePath = pathCleanTrail(path.normalize(basePath));
+ if (cleanBasePath == targetPath) throw new Error('cannot remove base folder');
+ await fse.remove(targetPath);
+};
+
+
+/**
+ * Ensures that the directory exists. If the directory structure does not exist, it is created.
+ */
+const validatorEnsureDir = (options) => {
+ return (
+ isPathValid(options.path, false)
+ );
+};
+const taskEnsureDir = async (options, basePath, deployerCtx) => {
+ if (!validatorEnsureDir(options)) throw new Error('invalid options');
+
+ //Process and create target file/path
+ const destPath = safePath(basePath, options.path);
+ await fse.ensureDir(destPath);
+};
+
+
+/**
+ * Extracts a ZIP file to a targt folder.
+ * NOTE: wow that was not easy to pick a library!
+ * - tar: no zip files
+ * - minizlib: terrible docs, probably too low level
+ * - yauzl: deprecation warning, slow
+ * - extract-zip: deprecation warning, slow due to yauzl
+ * - jszip: it's more a browser thing than node, doesn't appear to have an extract option
+ * - archiver: no extract
+ * - zip-stream: no extract
+ * - adm-zip: 50ms the old one, shitty
+ * - node-stream-zip: 180ms, acceptable
+ * - unzip: last update 7 years ago
+ * - unzipper: haven't tested
+ * - fflate: haven't tested
+ * - decompress-zip: haven't tested
+ */
+const validatorUnzip = (options) => {
+ return (
+ isPathValid(options.src, false)
+ && isPathValid(options.dest)
+ );
+};
+const taskUnzip = async (options, basePath, deployerCtx) => {
+ if (!validatorUnzip(options)) throw new Error('invalid options');
+
+ const srcPath = safePath(basePath, options.src);
+ const destPath = safePath(basePath, options.dest);
+ await fsp.mkdir(destPath, { recursive: true });
+
+ const zip = new StreamZip.async({ file: srcPath });
+ const count = await zip.extract(null, destPath);
+ console.log(`Extracted ${count} entries`);
+ await zip.close();
+};
+
+
+/**
+ * Moves a file or directory. The directory can have contents.
+ */
+const validatorMovePath = (options) => {
+ return (
+ isPathValid(options.src, false)
+ && isPathValid(options.dest, false)
+ );
+};
+const taskMovePath = async (options, basePath, deployerCtx) => {
+ if (!validatorMovePath(options)) throw new Error('invalid options');
+
+ const srcPath = safePath(basePath, options.src);
+ const destPath = safePath(basePath, options.dest);
+ await fse.move(srcPath, destPath, {
+ overwrite: (options.overwrite === 'true' || options.overwrite === true),
+ });
+};
+
+
+/**
+ * Copy a file or directory. The directory can have contents.
+ * TODO: add a filter property and use a glob lib in the fse.copy filter function
+ */
+const validatorCopyPath = (options) => {
+ return (
+ isPathValid(options.src)
+ && isPathValid(options.dest)
+ );
+};
+const taskCopyPath = async (options, basePath, deployerCtx) => {
+ if (!validatorCopyPath(options)) throw new Error('invalid options');
+
+ const srcPath = safePath(basePath, options.src);
+ const destPath = safePath(basePath, options.dest);
+ await fse.copy(srcPath, destPath, {
+ overwrite: (typeof options.overwrite !== 'undefined' && (options.overwrite === 'true' || options.overwrite === true)),
+ });
+};
+
+
+/**
+ * Writes or appends data to a file. If not in the append mode, the file will be overwritten and the directory structure will be created if it doesn't exists.
+ */
+const validatorWriteFile = (options) => {
+ return (
+ typeof options.data == 'string'
+ && options.data.length
+ && isPathValid(options.file, false)
+ );
+};
+const taskWriteFile = async (options, basePath, deployerCtx) => {
+ if (!validatorWriteFile(options)) throw new Error('invalid options');
+
+ const filePath = safePath(basePath, options.file);
+ if (options.append === 'true' || options.append === true) {
+ await fse.appendFile(filePath, options.data);
+ } else {
+ await fse.outputFile(filePath, options.data);
+ }
+};
+
+
+/**
+ * Replaces a string in the target file or files array based on a search string.
+ * Modes:
+ * - template: (default) target string will be processed for vars
+ * - literal: normal string search/replace without any vars
+ * - all_vars: all vars.toString() will be replaced. The search option will be ignored
+ */
+const validatorReplaceString = (options) => {
+ //Validate file
+ const fileList = (Array.isArray(options.file)) ? options.file : [options.file];
+ if (fileList.some((s) => !isPathValid(s, false))) {
+ return false;
+ }
+
+ //Validate mode
+ if (
+ typeof options.mode == 'undefined'
+ || options.mode == 'template'
+ || options.mode == 'literal'
+ ) {
+ return (
+ typeof options.search == 'string'
+ && options.search.length
+ && typeof options.replace == 'string'
+ );
+ } else if (options.mode == 'all_vars') {
+ return true;
+ } else {
+ return false;
+ }
+};
+const taskReplaceString = async (options, basePath, deployerCtx) => {
+ if (!validatorReplaceString(options)) throw new Error('invalid options');
+
+ const fileList = (Array.isArray(options.file)) ? options.file : [options.file];
+ for (let i = 0; i < fileList.length; i++) {
+ const filePath = safePath(basePath, fileList[i]);
+ const original = await fse.readFile(filePath, 'utf8');
+ let changed;
+ if (typeof options.mode == 'undefined' || options.mode == 'template') {
+ changed = original.replace(new RegExp(options.search, 'g'), replaceVars(options.replace, deployerCtx));
+ } else if (options.mode == 'all_vars') {
+ changed = replaceVars(original, deployerCtx);
+ } else if (options.mode == 'literal') {
+ changed = original.replace(new RegExp(options.search, 'g'), options.replace);
+ }
+ await fse.writeFile(filePath, changed);
+ }
+};
+
+
+/**
+ * Connects to a MySQL/MariaDB server and creates a database if the dbName variable is null.
+ */
+const validatorConnectDatabase = (options) => {
+ return true;
+};
+const taskConnectDatabase = async (options, basePath, deployerCtx) => {
+ if (!validatorConnectDatabase(options)) throw new Error('invalid options');
+ if (typeof deployerCtx.dbHost !== 'string') throw new Error('invalid dbHost');
+ if (typeof deployerCtx.dbPort !== 'number') throw new Error('invalid dbPort, should be number');
+ if (typeof deployerCtx.dbUsername !== 'string') throw new Error('invalid dbUsername');
+ if (typeof deployerCtx.dbPassword !== 'string') throw new Error('dbPassword should be a string');
+ if (typeof deployerCtx.dbName !== 'string') throw new Error('dbName should be a string');
+ if (typeof deployerCtx.dbDelete !== 'boolean') throw new Error('dbDelete should be a boolean');
+ //Connect to the database
+ const mysqlOptions = {
+ host: deployerCtx.dbHost,
+ port: deployerCtx.dbPort,
+ user: deployerCtx.dbUsername,
+ password: deployerCtx.dbPassword,
+ multipleStatements: true,
+ };
+ deployerCtx.dbConnection = await mysql.createConnection(mysqlOptions);
+ const escapedDBName = mysql.escapeId(deployerCtx.dbName);
+ if (deployerCtx.dbDelete) {
+ await deployerCtx.dbConnection.query(`DROP DATABASE IF EXISTS ${escapedDBName}`);
+ }
+ await deployerCtx.dbConnection.query(`CREATE DATABASE IF NOT EXISTS ${escapedDBName} CHARACTER SET utf8 COLLATE utf8_general_ci`);
+ await deployerCtx.dbConnection.query(`USE ${escapedDBName}`);
+};
+
+
+/**
+ * Runs a SQL query in the previously connected database. This query can be a file path or a string.
+ */
+const validatorQueryDatabase = (options) => {
+ if (typeof options.file !== 'undefined' && typeof options.query !== 'undefined') return false;
+ if (typeof options.file == 'string') return isPathValid(options.file, false);
+ if (typeof options.query == 'string') return options.query.length;
+ return false;
+};
+const taskQueryDatabase = async (options, basePath, deployerCtx) => {
+ if (!validatorQueryDatabase(options)) throw new Error('invalid options');
+ if (!deployerCtx.dbConnection) throw new Error('Database connection not found. Run connect_database before query_database');
+
+ let sql;
+ if (options.file) {
+ const filePath = safePath(basePath, options.file);
+ sql = await fse.readFile(filePath, 'utf8');
+ } else {
+ sql = options.query;
+ }
+ await deployerCtx.dbConnection.query(sql);
+};
+
+
+/**
+ * Loads variables from a json file to the context.
+ */
+const validatorLoadVars = (options) => {
+ return isPathValid(options.src, false);
+};
+const taskLoadVars = async (options, basePath, deployerCtx) => {
+ if (!validatorLoadVars(options)) throw new Error('invalid options');
+
+ const srcPath = safePath(basePath, options.src);
+ const rawData = await fse.readFile(srcPath, 'utf8');
+ const inData = JSON.parse(rawData);
+ inData.dbConnection = undefined;
+ Object.assign(deployerCtx, inData);
+};
+
+
+/**
+ * DEBUG Just wastes time /shrug
+ */
+const validatorWasteTime = (options) => {
+ return (typeof options.seconds == 'number');
+};
+const taskWasteTime = (options, basePath, deployerCtx) => {
+ return new Promise((resolve, reject) => {
+ setTimeout(() => {
+ resolve(true);
+ }, options.seconds * 1000);
+ });
+};
+
+
+/**
+ * DEBUG Fail fail fail :o
+ */
+const taskFailTest = async (options, basePath, deployerCtx) => {
+ throw new Error('test error :p');
+};
+
+
+/**
+ * DEBUG logs all ctx vars
+ */
+const taskDumpVars = async (options, basePath, deployerCtx) => {
+ const toDump = cloneDeep(deployerCtx);
+ toDump.dbConnection = toDump?.dbConnection?.constructor?.name;
+ console.dir(toDump);
+};
+
+
+/*
+DONE:
+ - download_file
+ - remove_path (file or folder)
+ - ensure_dir
+ - unzip
+ - move_path (file or folder)
+ - copy_path (file or folder)
+ - write_file (with option to append only)
+ - replace_string (single or array)
+ - connect_database (connects to mysql, creates db if not set)
+ - query_database (file or string)
+ - download_github (with ref and subpath)
+ - load_vars
+
+DEBUG:
+ - waste_time
+ - fail_test
+ - dump_vars
+
+TODO:
+ - ??????
+*/
+
+
+//Exports
+export default {
+ download_file: {
+ validate: validatorDownloadFile,
+ run: taskDownloadFile,
+ timeoutSeconds: 180,
+ },
+ download_github: {
+ validate: validatorDownloadGithub,
+ run: taskDownloadGithub,
+ timeoutSeconds: 180,
+ },
+ remove_path: {
+ validate: validatorRemovePath,
+ run: taskRemovePath,
+ timeoutSeconds: 15,
+ },
+ ensure_dir: {
+ validate: validatorEnsureDir,
+ run: taskEnsureDir,
+ timeoutSeconds: 15,
+ },
+ unzip: {
+ validate: validatorUnzip,
+ run: taskUnzip,
+ timeoutSeconds: 180,
+ },
+ move_path: {
+ validate: validatorMovePath,
+ run: taskMovePath,
+ timeoutSeconds: 180,
+ },
+ copy_path: {
+ validate: validatorCopyPath,
+ run: taskCopyPath,
+ timeoutSeconds: 180,
+ },
+ write_file: {
+ validate: validatorWriteFile,
+ run: taskWriteFile,
+ timeoutSeconds: 15,
+ },
+ replace_string: {
+ validate: validatorReplaceString,
+ run: taskReplaceString,
+ timeoutSeconds: 15,
+ },
+ connect_database: {
+ validate: validatorConnectDatabase,
+ run: taskConnectDatabase,
+ timeoutSeconds: 30,
+ },
+ query_database: {
+ validate: validatorQueryDatabase,
+ run: taskQueryDatabase,
+ timeoutSeconds: 90,
+ },
+ load_vars: {
+ validate: validatorLoadVars,
+ run: taskLoadVars,
+ timeoutSeconds: 5,
+ },
+
+ //DEBUG only
+ waste_time: {
+ validate: validatorWasteTime,
+ run: taskWasteTime,
+ timeoutSeconds: 300,
+ },
+ fail_test: {
+ validate: (() => true),
+ run: taskFailTest,
+ timeoutSeconds: 300,
+ },
+ dump_vars: {
+ validate: (() => true),
+ run: taskDumpVars,
+ timeoutSeconds: 5,
+ },
+};
diff --git a/core/deployer/recipeParser.ts b/core/deployer/recipeParser.ts
new file mode 100644
index 0000000..60a64f1
--- /dev/null
+++ b/core/deployer/recipeParser.ts
@@ -0,0 +1,126 @@
+const modulename = 'Deployer';
+import YAML from 'js-yaml';
+import { txEnv } from '@core/globalData';
+import { default as untypedRecipeEngine } from './recipeEngine.js';
+import consoleFactory from '@lib/console.js';
+import { RECIPE_DEPLOYER_VERSION } from './index.js'; //FIXME: circular_dependency
+const console = consoleFactory(modulename);
+
+
+//Types
+type YamlRecipeTaskType = {
+ action: string;
+ [key: string]: any;
+}
+type YamlRecipeType = Partial<{
+ $engine: number;
+ $minFxVersion: number;
+ $onesync: string;
+ $steamRequired: boolean;
+
+ name: string;
+ author: string;
+ description: string;
+
+ variables: Record;
+ tasks: YamlRecipeTaskType[];
+}>;
+type ParsedRecipeType = {
+ raw: string;
+ name: string;
+ author: string;
+ description: string;
+ variables: Record; //TODO: define this
+ tasks: YamlRecipeTaskType[];
+ onesync?: string;
+ fxserverMinVersion?: number;
+ recipeEngineVersion?: number;
+ steamRequired?: boolean;
+ requireDBConfig: boolean;
+}
+
+
+//FIXME: move to the recipeEngine.js file after typescript migration
+type RecipeEngineTask = {
+ validate: (task: YamlRecipeTaskType) => boolean;
+ run: (options: YamlRecipeTaskType, basePath: string, deployerCtx: unknown) => Promise;
+ timeoutSeconds: number;
+};
+type RecipeEngine = Record;
+const recipeEngine = untypedRecipeEngine as RecipeEngine;
+
+
+/**
+ * Validates a Recipe file
+ * FIXME: use Zod for schema validaiton
+ */
+const recipeParser = (rawRecipe: string) => {
+ if (typeof rawRecipe !== 'string') throw new Error('not a string');
+
+ //Loads YAML
+ let recipe: YamlRecipeType;
+ try {
+ recipe = YAML.load(rawRecipe, { schema: YAML.JSON_SCHEMA }) as YamlRecipeType;
+ } catch (error) {
+ console.verbose.dir(error);
+ throw new Error('invalid yaml');
+ }
+
+ //Basic validation
+ if (typeof recipe !== 'object') throw new Error('invalid YAML, couldn\'t resolve to object');
+ if (!Array.isArray(recipe.tasks)) throw new Error('no tasks array found');
+
+ //Preparing output
+ const outRecipe: ParsedRecipeType = {
+ raw: rawRecipe.trim(),
+ name: (recipe.name ?? 'unnamed').trim(),
+ author: (recipe.author ?? 'unknown').trim(),
+ description: (recipe.description ?? '').trim(),
+ variables: {},
+ tasks: [],
+ requireDBConfig: false,
+ };
+
+ //Checking/parsing meta tag requirements
+ if (typeof recipe.$onesync == 'string') {
+ const onesync = recipe.$onesync.trim();
+ if (!['off', 'legacy', 'on'].includes(onesync)) throw new Error(`the onesync option selected required for this recipe ("${onesync}") is not supported by this FXServer version.`);
+ outRecipe.onesync = onesync;
+ }
+ if (typeof recipe.$minFxVersion == 'number') {
+ if (recipe.$minFxVersion > txEnv.fxsVersion) throw new Error(`this recipe requires FXServer v${recipe.$minFxVersion} or above`);
+ outRecipe.fxserverMinVersion = recipe.$minFxVersion; //NOTE: currently no downstream use
+ }
+ if (typeof recipe.$engine == 'number') {
+ if (recipe.$engine < RECIPE_DEPLOYER_VERSION) throw new Error(`unsupported '$engine' version ${recipe.$engine}`);
+ outRecipe.recipeEngineVersion = recipe.$engine; //NOTE: currently no downstream use
+ }
+ if (recipe.$steamRequired === true) {
+ outRecipe.steamRequired = true;
+ }
+
+ //Validate tasks
+ if (!Array.isArray(recipe.tasks)) throw new Error('no tasks array found');
+ recipe.tasks.forEach((task, index) => {
+ if (typeof task.action !== 'string') throw new Error(`[task${index + 1}] no action specified`);
+ if (typeof recipeEngine[task.action] === 'undefined') throw new Error(`[task${index + 1}] unknown action '${task.action}'`);
+ if (!recipeEngine[task.action].validate(task)) throw new Error(`[task${index + 1}:${task.action}] invalid parameters`);
+ outRecipe.tasks.push(task);
+ });
+
+ //Process inputs
+ outRecipe.requireDBConfig = recipe.tasks.some((t) => t.action.includes('database'));
+ const protectedVarNames = ['licenseKey', 'dbHost', 'dbUsername', 'dbPassword', 'dbName', 'dbConnection', 'dbPort'];
+ if (typeof recipe.variables == 'object' && recipe.variables !== null) {
+ const varNames = Object.keys(recipe.variables);
+ if (varNames.some((n) => protectedVarNames.includes(n))) {
+ throw new Error('One or more of the variables declared in the recipe are not allowed.');
+ }
+ Object.assign(outRecipe.variables, recipe.variables);
+ }
+
+ //Output
+ return outRecipe;
+};
+
+export default recipeParser;
diff --git a/core/deployer/utils.ts b/core/deployer/utils.ts
new file mode 100644
index 0000000..88140e9
--- /dev/null
+++ b/core/deployer/utils.ts
@@ -0,0 +1,40 @@
+import { canWriteToPath, getPathFiles } from '@lib/fs';
+
+//File created up to v7.3.2
+const EMPTY_FILE_NAME = '.empty';
+
+
+/**
+ * Perform deployer local target path permission/emptiness checking.
+ */
+export const validateTargetPath = async (deployPath: string) => {
+ const canCreateFolder = await canWriteToPath(deployPath);
+ if(!canCreateFolder) {
+ throw new Error('Path is not writable due to missing permissions or invalid path.');
+ }
+ try {
+ const pathFiles = await getPathFiles(deployPath);
+ if (pathFiles.some((x) => x.name !== EMPTY_FILE_NAME)) {
+ throw new Error('This folder already exists and is not empty!');
+ }
+ } catch (error) {
+ if ((error as any).code !== 'ENOENT') throw error;
+ }
+ return true as const;
+};
+
+
+/**
+ * Create a template recipe file
+ */
+export const makeTemplateRecipe = (serverName: string, author: string) => [
+ `name: ${serverName}`,
+ `author: ${author}`,
+ '',
+ '# This is just a placeholder, please don\'t use it!',
+ 'tasks: ',
+ ' - action: waste_time',
+ ' seconds: 5',
+ ' - action: waste_time',
+ ' seconds: 5',
+].join('\n');
diff --git a/core/global.d.ts b/core/global.d.ts
new file mode 100644
index 0000000..1a9145a
--- /dev/null
+++ b/core/global.d.ts
@@ -0,0 +1,66 @@
+//NOTE: don't import anything at the root of this file or it breaks the type definitions
+
+/**
+ * MARK: txAdmin stuff
+ */
+type RefreshConfigFunc = import('@modules/ConfigStore/').RefreshConfigFunc;
+interface GenericTxModuleInstance {
+ public handleConfigUpdate?: RefreshConfigFunc;
+ public handleShutdown?: () => void;
+ public timers?: NodeJS.Timer[];
+ // public measureMemory?: () => { [key: string]: number };
+}
+declare interface GenericTxModule {
+ new(): InstanceType & GenericTxModuleInstance;
+ static readonly configKeysWatched?: string[];
+}
+
+declare type TxConfigs = import('@modules/ConfigStore/schema').TxConfigs
+declare const txConfig: TxConfigs;
+
+declare type TxCoreType = import('./txAdmin').TxCoreType;
+declare const txCore: TxCoreType;
+
+declare type TxManagerType = import('./txManager').TxManagerType;
+declare const txManager: TxManagerType;
+
+declare type TxConsole = import('./lib/console').TxConsole;
+declare namespace globalThis {
+ interface Console extends TxConsole { }
+}
+
+
+/**
+ * MARK: Natives
+ * Natives extracted from https://www.npmjs.com/package/@citizenfx/server
+ * I prefer extracting than importing the whole package because it's
+ * easier to keep track of what natives are being used.
+ *
+ * To use the package, add the following line to the top of the file:
+ * ///
+ */
+declare function ExecuteCommand(commandString: string): void;
+declare function GetConvar(varName: string, default_: string): string;
+declare function GetCurrentResourceName(): string;
+declare function GetPasswordHash(password: string): string;
+declare function GetResourceMetadata(resourceName: string, metadataKey: string, index: number): string;
+declare function GetResourcePath(resourceName: string): string;
+declare function IsDuplicityVersion(): boolean;
+declare function PrintStructuredTrace(payload: string): void;
+declare function RegisterCommand(commandName: string, handler: Function, restricted: boolean): void;
+declare function ScanResourceRoot(rootPath: string, callback: (data: object) => void): boolean;
+declare function VerifyPasswordHash(password: string, hash: string): boolean;
+
+
+/**
+ * MARK: Fixes
+ */
+declare module 'unicode-emoji-json/data-ordered-emoji' {
+ const emojis: string[];
+ export = emojis;
+}
+
+//FIXME: checar se eu preciso disso
+// interface ProcessEnv {
+// [x: string]: string | undefined;
+// }
diff --git a/core/globalData.ts b/core/globalData.ts
new file mode 100644
index 0000000..e4ae077
--- /dev/null
+++ b/core/globalData.ts
@@ -0,0 +1,578 @@
+import os from 'node:os';
+import fsp from 'node:fs/promises';
+import path from 'node:path';
+import slash from 'slash';
+
+import consoleFactory, { setConsoleEnvData } from '@lib/console';
+import { addLocalIpAddress } from '@lib/host/isIpAddressLocal';
+import { parseFxserverVersion } from '@lib/fxserver/fxsVersionParser';
+import { parseTxDevEnv, TxDevEnvType } from '@shared/txDevEnv';
+import { Overwrite } from 'utility-types';
+import fatalError from '@lib/fatalError';
+import { getNativeVars } from './boot/getNativeVars';
+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';
+const console = consoleFactory();
+
+
+/**
+ * MARK: GETTING VARIABLES
+ */
+//Get OSType
+const osTypeVar = os.type();
+let isWindows;
+if (osTypeVar === 'Windows_NT') {
+ isWindows = true;
+} else if (osTypeVar === 'Linux') {
+ isWindows = false;
+} else {
+ fatalError.GlobalData(0, `OS type not supported: ${osTypeVar}`);
+}
+
+//Simple env vars
+const ignoreDeprecatedConfigs = process.env?.TXHOST_IGNORE_DEPRECATED_CONFIGS === 'true';
+
+
+/**
+ * MARK: HELPERS
+ */
+const cleanPath = (x: string) => slash(path.normalize(x));
+const handleMultiVar = (
+ name: string,
+ schema: T,
+ procenv: z.infer | undefined,
+ zapcfg: string | number | undefined,
+ convar: any,
+): z.infer | undefined => {
+ const alt = zapcfg ?? convar;
+ if (alt === undefined) {
+ return procenv;
+ }
+ const whichAlt = zapcfg !== undefined ? 'txAdminZapConfig.json' : 'ConVar';
+ if (procenv !== undefined) {
+ console.warn(`WARNING: Both the environment variable 'TXHOST_${name}' and the ${whichAlt} equivalent are set. The environment variable will be prioritized.`);
+ return procenv;
+ }
+ const parsed = schema.safeParse(alt);
+ if (!parsed.success) {
+ fatalError.GlobalData(20, [
+ `Invalid value for the TXHOST_${name}-equivalent config in ${whichAlt}.`,
+ ['Value', alt],
+ 'For more information: https://aka.cfx.re/txadmin-env-config',
+ ], fromZodError(parsed.error, { prefix: null }));
+ }
+ return parsed.data;
+}
+
+
+/**
+ * MARK: DEV ENV
+ */
+type TxDevEnvEnabledType = Overwrite;
+type TxDevEnvDisabledType = Overwrite;
+let _txDevEnv: TxDevEnvEnabledType | TxDevEnvDisabledType;
+const devVars = parseTxDevEnv();
+if (devVars.ENABLED) {
+ console.debug('Starting txAdmin in DEV mode.');
+ if (!devVars.SRC_PATH || !devVars.VITE_URL) {
+ fatalError.GlobalData(8, 'Missing TXDEV_VITE_URL and/or TXDEV_SRC_PATH env variables.');
+ }
+ _txDevEnv = devVars as TxDevEnvEnabledType;
+} else {
+ _txDevEnv = {
+ ...devVars,
+ SRC_PATH: undefined,
+ VITE_URL: undefined,
+ } as TxDevEnvDisabledType;
+}
+
+
+/**
+ * MARK: CHECK HOST VARS
+ */
+const nativeVars = getNativeVars(ignoreDeprecatedConfigs);
+
+//Getting fxserver version
+//4380 = GetVehicleType was exposed server-side
+//4548 = more or less when node v16 was added
+//4574 = add missing PRINT_STRUCTURED_TRACE declaration
+//4574 = add resource field to PRINT_STRUCTURED_TRACE
+//5894 = CREATE_VEHICLE_SERVER_SETTER
+//6185 = added ScanResourceRoot (not yet in use)
+//6508 = unhandledRejection is now handlable, we need this due to discord.js's bug
+//8495 = changed prometheus::Histogram::BucketBoundaries
+//9423 = feat(server): add more infos to playerDropped event
+//9655 = Fixed ScanResourceRoot + latent events
+const minFxsVersion = 5894;
+const fxsVerParsed = parseFxserverVersion(nativeVars.fxsVersion);
+const fxsVersion = fxsVerParsed.valid ? fxsVerParsed.build : 99999;
+if (!fxsVerParsed.valid) {
+ console.error('It looks like you are running a custom build of fxserver.');
+ console.error('And because of that, there is no guarantee that txAdmin will work properly.');
+ console.error(`Convar: ${nativeVars.fxsVersion}`);
+ console.error(`Parsed Build: ${fxsVerParsed.build}`);
+ console.error(`Parsed Branch: ${fxsVerParsed.branch}`);
+ console.error(`Parsed Platform: ${fxsVerParsed.platform}`);
+} else if (fxsVerParsed.build < minFxsVersion) {
+ fatalError.GlobalData(2, [
+ 'This version of FXServer is too outdated and NOT compatible with txAdmin',
+ ['Current FXServer version', fxsVerParsed.build.toString()],
+ ['Minimum required version', minFxsVersion.toString()],
+ 'Please update your FXServer to a newer version.',
+ ]);
+} else if (fxsVerParsed.branch !== 'master') {
+ console.warn(`You are running a custom branch of FXServer: ${fxsVerParsed.branch}`);
+}
+
+//Getting txAdmin version
+if (!nativeVars.txaResourceVersion) {
+ fatalError.GlobalData(3, [
+ 'txAdmin version not set or in the wrong format.',
+ ['Detected version', nativeVars.txaResourceVersion],
+ ]);
+}
+const txaVersion = nativeVars.txaResourceVersion;
+
+//Get txAdmin Resource Path
+if (!nativeVars.txaResourcePath) {
+ fatalError.GlobalData(4, [
+ 'Could not resolve txAdmin resource path.',
+ ['Convar', nativeVars.txaResourcePath],
+ ]);
+}
+const txaPath = cleanPath(nativeVars.txaResourcePath);
+
+//Get citizen Root
+if (!nativeVars.fxsCitizenRoot) {
+ fatalError.GlobalData(5, [
+ 'citizen_root convar not set',
+ ['Convar', nativeVars.fxsCitizenRoot],
+ ]);
+}
+const fxsPath = cleanPath(nativeVars.fxsCitizenRoot as string);
+
+//Check if server is inside WinRar's temp folder
+if (isWindows && /Temp[\\/]+Rar\$/i.test(fxsPath)) {
+ fatalError.GlobalData(12, [
+ 'It looks like you ran FXServer inside WinRAR without extracting it first.',
+ 'Please extract the server files to a proper folder before running it.',
+ ['Server path', fxsPath.replace(/\\/g, '/').replace(/\/$/, '')],
+ ]);
+}
+
+
+//Setting the variables in console without it having to importing from here (circular dependency)
+setConsoleEnvData(
+ txaVersion,
+ txaPath,
+ _txDevEnv.ENABLED,
+ _txDevEnv.VERBOSE
+);
+
+
+/**
+ * MARK: TXDATA & PROFILE
+ */
+const hostVars = getHostVars();
+//Setting data path
+let hasCustomDataPath = false;
+let dataPath = cleanPath(path.join(
+ fxsPath,
+ isWindows ? '..' : '../../../',
+ 'txData'
+));
+const dataPathVar = handleMultiVar(
+ 'DATA_PATH',
+ hostEnvVarSchemas.DATA_PATH,
+ hostVars.DATA_PATH,
+ undefined,
+ nativeVars.txDataPath,
+);
+if (dataPathVar) {
+ hasCustomDataPath = true;
+ dataPath = cleanPath(dataPathVar);
+}
+
+//Check paths for non-ASCII characters
+//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]+/;
+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],
+ ]);
+}
+
+//Profile - not available as env var
+let profileVar = nativeVars.txAdminProfile;
+if (profileVar) {
+ profileVar = profileVar.replace(/[^a-z0-9._-]/gi, '');
+ if (profileVar.endsWith('.base')) {
+ fatalError.GlobalData(13, [
+ ['Invalid server profile name', profileVar],
+ 'Profile names cannot end with ".base".',
+ 'It looks like you are trying to point to a server folder instead of a profile.',
+ ]);
+ }
+ if (!profileVar.length) {
+ fatalError.GlobalData(14, [
+ 'Invalid server profile name.',
+ 'If you are using Google Translator on the instructions page,',
+ 'make sure there are no additional spaces in your command.',
+ ]);
+ }
+}
+const profileName = profileVar ?? 'default';
+const profilePath = cleanPath(path.join(dataPath, profileName));
+
+
+/**
+ * MARK: ZAP & NETWORKING
+ */
+let zapVars: ReturnType | undefined;
+if (!ignoreDeprecatedConfigs) {
+ //FIXME: ZAP doesn't need this anymore, remove ASAP
+ const zapCfgFilePath = path.join(dataPath, 'txAdminZapConfig.json');
+ try {
+ zapVars = getZapVars(zapCfgFilePath);
+ if (!_txDevEnv.ENABLED) fsp.unlink(zapCfgFilePath).catch(() => { });
+ } catch (error) {
+ fatalError.GlobalData(9, 'Failed to load with ZAP-Hosting configuration.', error);
+ }
+}
+
+//No default, no convar/zap cfg
+const txaUrl = hostVars.TXA_URL;
+
+//txAdmin port
+const txaPort = handleMultiVar(
+ 'TXA_PORT',
+ hostEnvVarSchemas.TXA_PORT,
+ hostVars.TXA_PORT,
+ zapVars?.txAdminPort,
+ nativeVars.txAdminPort,
+) ?? 40120;
+
+//fxserver port
+const fxsPort = handleMultiVar(
+ 'FXS_PORT',
+ hostEnvVarSchemas.FXS_PORT,
+ hostVars.FXS_PORT,
+ zapVars?.forceFXServerPort,
+ undefined,
+);
+
+//Forced interface
+const netInterface = handleMultiVar(
+ 'INTERFACE',
+ hostEnvVarSchemas.INTERFACE,
+ hostVars.INTERFACE,
+ zapVars?.forceInterface,
+ nativeVars.txAdminInterface,
+);
+if (netInterface) {
+ addLocalIpAddress(netInterface);
+}
+
+
+/**
+ * MARK: GENERAL
+ */
+const forceGameName = hostVars.GAME_NAME;
+const hostApiToken = hostVars.API_TOKEN;
+
+const forceMaxClients = handleMultiVar(
+ 'MAX_SLOTS',
+ hostEnvVarSchemas.MAX_SLOTS,
+ hostVars.MAX_SLOTS,
+ zapVars?.deployerDefaults?.maxClients,
+ undefined,
+);
+
+const forceQuietMode = handleMultiVar(
+ 'QUIET_MODE',
+ hostEnvVarSchemas.QUIET_MODE,
+ hostVars.QUIET_MODE,
+ zapVars?.deployerDefaults?.maxClients,
+ undefined,
+) ?? false;
+
+
+/**
+ * MARK: PROVIDER
+ */
+const providerName = handleMultiVar(
+ 'PROVIDER_NAME',
+ hostEnvVarSchemas.PROVIDER_NAME,
+ hostVars.PROVIDER_NAME,
+ zapVars?.providerName,
+ undefined,
+);
+const providerLogo = handleMultiVar(
+ 'PROVIDER_LOGO',
+ hostEnvVarSchemas.PROVIDER_LOGO,
+ hostVars.PROVIDER_LOGO,
+ zapVars?.loginPageLogo,
+ undefined,
+);
+
+
+/**
+ * MARK: DEFAULTS
+ */
+const defaultDbHost = handleMultiVar(
+ 'DEFAULT_DBHOST',
+ hostEnvVarSchemas.DEFAULT_DBHOST,
+ hostVars.DEFAULT_DBHOST,
+ zapVars?.deployerDefaults?.mysqlHost,
+ undefined,
+);
+const defaultDbPort = handleMultiVar(
+ 'DEFAULT_DBPORT',
+ hostEnvVarSchemas.DEFAULT_DBPORT,
+ hostVars.DEFAULT_DBPORT,
+ zapVars?.deployerDefaults?.mysqlPort,
+ undefined,
+);
+const defaultDbUser = handleMultiVar(
+ 'DEFAULT_DBUSER',
+ hostEnvVarSchemas.DEFAULT_DBUSER,
+ hostVars.DEFAULT_DBUSER,
+ zapVars?.deployerDefaults?.mysqlUser,
+ undefined,
+);
+const defaultDbPass = handleMultiVar(
+ 'DEFAULT_DBPASS',
+ hostEnvVarSchemas.DEFAULT_DBPASS,
+ hostVars.DEFAULT_DBPASS,
+ zapVars?.deployerDefaults?.mysqlPassword,
+ undefined,
+);
+const defaultDbName = handleMultiVar(
+ 'DEFAULT_DBNAME',
+ hostEnvVarSchemas.DEFAULT_DBNAME,
+ hostVars.DEFAULT_DBNAME,
+ zapVars?.deployerDefaults?.mysqlDatabase,
+ undefined,
+);
+
+//Default Master Account
+type DefaultMasterAccount = {
+ username: string;
+ fivemId?: string;
+ password?: string;
+} | {
+ username: string;
+ password: string;
+} | undefined;
+let defaultMasterAccount: DefaultMasterAccount;
+const bcryptRegex = /^\$2[aby]\$[0-9]{2}\$[A-Za-z0-9./]{53}$/;
+if (hostVars.DEFAULT_ACCOUNT) {
+ let [username, fivemId, password] = hostVars.DEFAULT_ACCOUNT.split(':') as (string | undefined)[];
+ if (username === '') username = undefined;
+ if (fivemId === '') fivemId = undefined;
+ if (password === '') password = undefined;
+
+ const errArr: [string, any][] = [
+ ['Username', username],
+ ['FiveM ID', fivemId],
+ ['Password', password],
+ ];
+ if (!username || !consts.regexValidFivemUsername.test(username)) {
+ fatalError.GlobalData(21, [
+ 'Invalid default account username.',
+ 'It should be a valid FiveM username.',
+ ...errArr,
+ ]);
+ }
+ if (fivemId && !consts.validIdentifierParts.fivem.test(fivemId)) {
+ fatalError.GlobalData(22, [
+ 'Invalid default account FiveM ID.',
+ 'It should match the number in the fivem:0000000 game identifier.',
+ ...errArr,
+ ]);
+ }
+ if (password && !bcryptRegex.test(password)) {
+ fatalError.GlobalData(23, [
+ 'Invalid default account password.',
+ 'Expected bcrypt hash.',
+ ...errArr,
+ ]);
+ }
+ if (!fivemId && !password) {
+ fatalError.GlobalData(24, [
+ 'Invalid default account.',
+ 'Expected at least the FiveM ID or password to be present.',
+ ...errArr,
+ ]);
+ }
+ defaultMasterAccount = {
+ username,
+ fivemId,
+ password,
+ };
+} else if (zapVars?.defaultMasterAccount) {
+ const username = zapVars.defaultMasterAccount?.name;
+ const password = zapVars.defaultMasterAccount?.password_hash;
+ if (!consts.regexValidFivemUsername.test(username)) {
+ fatalError.GlobalData(25, [
+ 'Invalid default account username.',
+ 'It should be a valid FiveM username.',
+ ['Username', username],
+ ]);
+ }
+ if (!bcryptRegex.test(password)) {
+ fatalError.GlobalData(26, [
+ 'Invalid default account password.',
+ 'Expected bcrypt hash.',
+ ['Hash', password],
+ ]);
+ }
+ defaultMasterAccount = {
+ username: username,
+ password: password,
+ };
+}
+
+//Default cfx key
+const defaultCfxKey = handleMultiVar(
+ 'DEFAULT_CFXKEY',
+ hostEnvVarSchemas.DEFAULT_CFXKEY,
+ hostVars.DEFAULT_CFXKEY,
+ zapVars?.deployerDefaults?.license,
+ undefined,
+);
+
+
+/**
+ * MARK: FINAL SETUP
+ */
+if (ignoreDeprecatedConfigs) {
+ console.verbose.debug('TXHOST_IGNORE_DEPRECATED_CONFIGS is set to true. Ignoring deprecated configs.');
+}
+
+const isPterodactyl = !isWindows && process.env?.TXADMIN_ENABLE === '1';
+const isZapHosting = providerName === 'ZAP-Hosting';
+
+//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';
+} else if (isPterodactyl) {
+ fxsVersionTag += '/Ptero';
+} else if (isWindows && fxsVerParsed.platform === 'windows') {
+ fxsVersionTag += '/Win';
+} else if (!isWindows && fxsVerParsed.platform === 'linux') {
+ fxsVersionTag += '/Lin';
+} else {
+ fxsVersionTag += '/Unk';
+}
+
+
+/**
+ * MARK: Exports
+ */
+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,
+
+ //Natives
+ fxsVersionTag,
+ fxsVersion,
+ txaVersion,
+ txaPath,
+ fxsPath,
+
+ //ConVar
+ profileName,
+ profilePath, //FIXME: replace by profileSubPath in most places
+ profileSubPath: (...parts: string[]) => path.join(profilePath, ...parts),
+});
+
+export const txHostConfig = Object.freeze({
+ //General
+ dataPath,
+ dataSubPath: (...parts: string[]) => path.join(dataPath, ...parts),
+ hasCustomDataPath,
+ forceGameName,
+ forceMaxClients,
+ forceQuietMode,
+ hostApiToken,
+
+ //Networking
+ txaUrl,
+ txaPort,
+ fxsPort,
+ netInterface,
+
+ //Provider
+ providerName,
+ providerLogo,
+ sourceName: providerName ?? 'Host Config',
+
+ //Defaults
+ defaults: {
+ account: defaultMasterAccount,
+ cfxKey: defaultCfxKey,
+ dbHost: defaultDbHost,
+ dbPort: defaultDbPort,
+ dbUser: defaultDbUser,
+ dbPass: defaultDbPass,
+ dbName: defaultDbName,
+ },
+});
+
+
+//DEBUG
+// console.dir(txEnv, { compact: true });
+// console.dir(txDevEnv, { compact: true });
+// console.dir(txHostConfig, { compact: true });
diff --git a/core/index.ts b/core/index.ts
new file mode 100644
index 0000000..21589e7
--- /dev/null
+++ b/core/index.ts
@@ -0,0 +1,83 @@
+//NOTE: must be imported first to setup the environment
+import { txEnv, txHostConfig } from './globalData';
+import consoleFactory, { setTTYTitle } from '@lib/console';
+
+//Can be imported after
+import fs from 'node:fs';
+import checkPreRelease from './boot/checkPreRelease';
+import fatalError from '@lib/fatalError';
+import { ensureProfileStructure, setupProfile } from './boot/setup';
+import setupProcessHandlers from './boot/setupProcessHandlers';
+import bootTxAdmin from './txAdmin';
+const console = consoleFactory();
+
+
+//Early process stuff
+try {
+ process.title = 'txAdmin'; //doesn't work for now
+ setupProcessHandlers();
+ setTTYTitle();
+ checkPreRelease();
+} catch (error) {
+ fatalError.Boot(0, 'Failed early process setup.', error);
+}
+console.log(`Starting txAdmin v${txEnv.txaVersion}/b${txEnv.fxsVersionTag}...`);
+
+
+//Setting up txData & Profile
+try {
+ if (!fs.existsSync(txHostConfig.dataPath)) {
+ fs.mkdirSync(txHostConfig.dataPath);
+ }
+} catch (error) {
+ fatalError.Boot(1, [
+ `Failed to check or create the data folder.`,
+ ['Path', txHostConfig.dataPath],
+ ], error);
+}
+let isNewProfile = false;
+try {
+ if (fs.existsSync(txEnv.profilePath)) {
+ ensureProfileStructure();
+ } else {
+ setupProfile();
+ isNewProfile = true;
+ }
+} catch (error) {
+ fatalError.Boot(2, [
+ `Failed to check or create the txAdmin profile folder.`,
+ ['Data Path', txHostConfig.dataPath],
+ ['Profile Name', txEnv.profileName],
+ ['Profile Path', txEnv.profilePath],
+ ], error);
+}
+if (isNewProfile && txEnv.profileName !== 'default') {
+ console.log(`Profile path: ${txEnv.profilePath}`);
+}
+
+
+//Start txAdmin (have fun 😀)
+try {
+ bootTxAdmin();
+} catch (error) {
+ fatalError.Boot(3, 'Failed to start txAdmin.', error);
+}
+
+
+//Freeze detector - starts after 10 seconds due to the initial bootup lag
+const bootGracePeriod = 15_000;
+const loopInterval = 500;
+const loopElapsedLimit = 2_000;
+setTimeout(() => {
+ let hdTimer = Date.now();
+ setInterval(() => {
+ const now = Date.now();
+ if (now - hdTimer > loopElapsedLimit) {
+ console.majorMultilineError([
+ 'Major VPS freeze/lag detected!',
+ 'THIS IS NOT AN ERROR CAUSED BY TXADMIN!',
+ ]);
+ }
+ hdTimer = now;
+ }, loopInterval);
+}, bootGracePeriod);
diff --git a/core/lib/MemCache.ts b/core/lib/MemCache.ts
new file mode 100644
index 0000000..40dd93a
--- /dev/null
+++ b/core/lib/MemCache.ts
@@ -0,0 +1,47 @@
+import { cloneDeep } from 'lodash-es';
+
+export default class MemCache {
+ public readonly ttl: number;
+ public dataTimestamp: number | undefined;
+ private data: T | undefined;
+
+ constructor(ttlSeconds = 60) {
+ this.ttl = ttlSeconds * 1000; //converting to ms
+ }
+
+ /**
+ * Checks if the data is still valid or wipes it
+ */
+ isValid() {
+ if (this.dataTimestamp === undefined) return false;
+ if (this.dataTimestamp < Date.now() - this.ttl) {
+ this.dataTimestamp = undefined;
+ this.data = undefined;
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Sets the cache
+ */
+ set(data: T) {
+ this.dataTimestamp = Date.now();
+ this.data = data;
+ }
+
+ /**
+ * Returns the cache if valid, or undefined
+ */
+ get() {
+ if (this.dataTimestamp === undefined || this.data === undefined) {
+ return undefined;
+ }
+
+ if (this.isValid()) {
+ return cloneDeep(this.data);
+ } else {
+ return undefined;
+ }
+ }
+};
diff --git a/core/lib/console.test.ts b/core/lib/console.test.ts
new file mode 100644
index 0000000..b4d191a
--- /dev/null
+++ b/core/lib/console.test.ts
@@ -0,0 +1,48 @@
+import { suite, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { processStdioWriteRaw, processStdioEnsureEol } from './console';
+
+
+suite('processStdioWriteRaw & processStdioEnsureEol', () => {
+ let writeSpy: ReturnType;
+
+ beforeEach(() => {
+ writeSpy = vi.spyOn(process.stdout as any, 'write').mockImplementation(() => true);
+ });
+
+ afterEach(() => {
+ writeSpy.mockRestore();
+ });
+
+ it('should write a non-newline string and then add a newline', () => {
+ processStdioWriteRaw("Hello");
+ expect(writeSpy).toHaveBeenCalledWith("Hello");
+ processStdioEnsureEol();
+ expect(writeSpy).toHaveBeenCalledWith('\n');
+ });
+
+ it('should write a string ending in newline without adding an extra one', () => {
+ processStdioWriteRaw("Hello\n");
+ expect(writeSpy).toHaveBeenCalledWith("Hello\n");
+ writeSpy.mockClear();
+ processStdioEnsureEol();
+ expect(writeSpy).not.toHaveBeenCalled();
+ });
+
+ it('should write Uint8Array without trailing newline and then add one', () => {
+ const buffer = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
+ processStdioWriteRaw(buffer);
+ expect(writeSpy).toHaveBeenCalledWith(buffer);
+ processStdioEnsureEol();
+ expect(writeSpy).toHaveBeenCalledWith('\n');
+ });
+
+ it('should write Uint8Array with trailing newline and not add an extra one', () => {
+ const newline = 10;
+ const buffer = new Uint8Array([72, 101, 108, 108, 111, newline]); // "Hello\n"
+ processStdioWriteRaw(buffer);
+ expect(writeSpy).toHaveBeenCalledWith(buffer);
+ writeSpy.mockClear();
+ processStdioEnsureEol();
+ expect(writeSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/core/lib/console.ts b/core/lib/console.ts
new file mode 100644
index 0000000..9e16911
--- /dev/null
+++ b/core/lib/console.ts
@@ -0,0 +1,388 @@
+//NOTE: due to the monkey patching of the console, this should be imported before anything else
+// which means in this file you cannot import anything from inside txAdmin to prevent cyclical dependencies
+import { Console } from 'node:console';
+import { InspectOptions } from 'node:util';
+import { Writable } from 'node:stream';
+import path from 'node:path';
+import chalk, { ChalkInstance } from 'chalk';
+import slash from 'slash';
+import ErrorStackParser from 'error-stack-parser';
+import sourceMapSupport from 'source-map-support';
+
+
+//Buffer handler
+//NOTE: the buffer will take between 64~72kb
+const headBufferLimit = 8 * 1024; //4kb
+const bodyBufferLimit = 64 * 1024; //64kb
+const bodyTrimSliceSize = 8 * 1024;
+const BUFFER_CUT_WARNING = chalk.bgRgb(255, 69, 0)('[!] The log body was sliced to prevent memory exhaustion. [!]');
+const DEBUG_COLOR = chalk.bgHex('#FF45FF');
+let headBuffer = '';
+let bodyBuffer = '';
+
+const writeToBuffer = (chunk: string) => {
+ //if head not full yet
+ if (headBuffer.length + chunk.length < headBufferLimit) {
+ headBuffer += chunk;
+ return;
+ }
+
+ //write to body and trim if needed
+ bodyBuffer += chunk;
+ if (bodyBuffer.length > bodyBufferLimit) {
+ let trimmedBody = bodyBuffer.slice(bodyTrimSliceSize - bodyBufferLimit);
+ trimmedBody = trimmedBody.substring(trimmedBody.indexOf('\n'));
+ bodyBuffer = `\n${BUFFER_CUT_WARNING}\n${trimmedBody}`;
+ }
+}
+
+export const getLogBuffer = () => headBuffer + bodyBuffer;
+
+
+//Variables
+const header = 'tx';
+let stackPathAlias: { path: string, alias: string } | undefined;
+let _txAdminVersion: string | undefined;
+let _verboseFlag = false;
+
+export const setConsoleEnvData = (
+ txAdminVersion: string,
+ txAdminResourcePath: string,
+ isDevMode: boolean,
+ isVerbose: boolean,
+) => {
+ _txAdminVersion = txAdminVersion;
+ _verboseFlag = isVerbose;
+ if (isDevMode) {
+ sourceMapSupport.install();
+ //for some reason when using sourcemap it ends up with core/core/
+ stackPathAlias = {
+ path: txAdminResourcePath + '/core',
+ alias: '@monitor',
+ }
+ } else {
+ stackPathAlias = {
+ path: txAdminResourcePath,
+ alias: '@monitor',
+ }
+ }
+}
+
+
+/**
+ * STDOUT EOL helper
+ */
+let stdioEolPending = false;
+export const processStdioWriteRaw = (buffer: Uint8Array | string) => {
+ if (!buffer.length) return;
+ const comparator = typeof buffer === 'string' ? '\n' : 10;
+ stdioEolPending = buffer[buffer.length - 1] !== comparator;
+ process.stdout.write(buffer);
+}
+export const processStdioEnsureEol = () => {
+ if (stdioEolPending) {
+ process.stdout.write('\n');
+ stdioEolPending = false;
+ }
+}
+
+
+/**
+ * New console and streams
+ */
+const defaultStream = new Writable({
+ decodeStrings: true,
+ defaultEncoding: 'utf8',
+ highWaterMark: 64 * 1024,
+ write(chunk, encoding, callback) {
+ writeToBuffer(chunk)
+ process.stdout.write(chunk);
+ callback();
+ },
+});
+const verboseStream = new Writable({
+ decodeStrings: true,
+ defaultEncoding: 'utf8',
+ highWaterMark: 64 * 1024,
+ write(chunk, encoding, callback) {
+ writeToBuffer(chunk)
+ if (_verboseFlag) process.stdout.write(chunk);
+ callback();
+ },
+});
+const defaultConsole = new Console({
+ //@ts-ignore some weird change from node v16 to v22, check after update
+ stdout: defaultStream,
+ stderr: defaultStream,
+ colorMode: true,
+});
+const verboseConsole = new Console({
+ //@ts-ignore some weird change from node v16 to v22, check after update
+ stdout: verboseStream,
+ stderr: verboseStream,
+ colorMode: true,
+});
+
+
+/**
+ * Returns current ts in h23 format
+ * FIXME: same thing as utils/misc.ts getTimeHms
+ */
+export const getTimestamp = () => (new Date).toLocaleString(
+ undefined,
+ { timeStyle: 'medium', hourCycle: 'h23' }
+);
+
+
+/**
+ * Generated the colored log prefix (ts+tags)
+ */
+export const genLogPrefix = (currContext: string, color: ChalkInstance) => {
+ return color.black(`[${getTimestamp()}][${currContext}]`);
+}
+
+
+//Dir helpers
+const cleanPath = (x: string) => slash(path.normalize(x));
+const ERR_STACK_PREFIX = chalk.redBright(' => ');
+const DIVIDER_SIZE = 60;
+const DIVIDER_CHAR = '=';
+const DIVIDER = DIVIDER_CHAR.repeat(DIVIDER_SIZE);
+const DIR_DIVIDER = chalk.cyan(DIVIDER);
+const specialsColor = chalk.rgb(255, 228, 181).italic;
+const lawngreenColor = chalk.rgb(124, 252, 0);
+const orangeredColor = chalk.rgb(255, 69, 0);
+
+
+/**
+ * Parses an error and returns string with prettified error and stack
+ * The stack filters out node modules and aliases monitor folder
+ */
+const getPrettyError = (error: Error, multilineError?: boolean) => {
+ const out: string[] = [];
+ const prefixStr = `[${getTimestamp()}][tx]`;
+ let prefixColor = chalk.redBright;
+ let nameColor = chalk.redBright;
+ if (error.name === 'ExperimentalWarning') {
+ prefixColor = chalk.bgYellow.black;
+ nameColor = chalk.yellowBright;
+ } else if (multilineError) {
+ prefixColor = chalk.bgRed.black;
+ }
+ const prefix = prefixColor(prefixStr) + ' ';
+
+ //banner
+ out.push(prefix + nameColor(`${error.name}: `) + error.message);
+ if ('type' in error) out.push(prefix + nameColor('Type:') + ` ${error.type}`);
+ if ('code' in error) out.push(prefix + nameColor('Code:') + ` ${error.code}`);
+
+ //stack
+ if (typeof error.stack === 'string') {
+ const stackPrefix = multilineError ? prefix : ERR_STACK_PREFIX;
+ try {
+ for (const line of ErrorStackParser.parse(error)) {
+ if (line.fileName && line.fileName.startsWith('node:')) continue;
+ let outPath = cleanPath(line.fileName ?? 'unknown');
+ if(stackPathAlias){
+ outPath = outPath.replace(stackPathAlias.path, stackPathAlias.alias);
+ }
+ const outPos = chalk.blueBright(`${line.lineNumber}:${line.columnNumber}`);
+ const outName = chalk.yellowBright(line.functionName || '');
+ if (!outPath.startsWith('@monitor/core')) {
+ out.push(chalk.dim(`${stackPrefix}${outPath} > ${outPos} > ${outName}`));
+ } else {
+ out.push(`${stackPrefix}${outPath} > ${outPos} > ${outName}`);
+ }
+ }
+ } catch (error) {
+ out.push(`${prefix} Unnable to parse error stack.`);
+ }
+ } else {
+ out.push(`${prefix} Error stack not available.`);
+ }
+ return out.join('\n');
+}
+
+
+/**
+ * Drop-in replacement for console.dir
+ */
+const dirHandler = (data: any, options?: TxInspectOptions, consoleInstance?: Console) => {
+ if (!consoleInstance) consoleInstance = defaultConsole;
+
+ if (data instanceof Error) {
+ consoleInstance.log(getPrettyError(data, options?.multilineError));
+ if (!options?.multilineError) consoleInstance.log();
+ } else {
+ consoleInstance.log(DIR_DIVIDER);
+ if (data === undefined) {
+ consoleInstance.log(specialsColor('> undefined'));
+ } else if (data === null) {
+ consoleInstance.log(specialsColor('> null'));
+ } else if (data instanceof Promise) {
+ consoleInstance.log(specialsColor('> Promise'));
+ } else if (typeof data === 'boolean') {
+ consoleInstance.log(data ? lawngreenColor('true') : orangeredColor('false'));
+ } else {
+ consoleInstance.dir(data, options);
+ }
+ consoleInstance.log(DIR_DIVIDER);
+ }
+}
+
+type TxInspectOptions = InspectOptions & {
+ multilineError?: boolean;
+}
+
+
+/**
+ * Cleans the terminal
+ */
+export const cleanTerminal = () => {
+ process.stdout.write('.\n'.repeat(80) + '\x1B[2J\x1B[H');
+}
+
+/**
+ * Sets terminal title
+ */
+export const setTTYTitle = (title?: string) => {
+ const txVers = _txAdminVersion ? `txAdmin v${_txAdminVersion}` : 'txAdmin';
+ const out = title ? `${title} - txAdmin` : txVers;
+ process.stdout.write(`\x1B]0;${out}\x07`);
+}
+
+
+/**
+ * Generates a custom log function with custom context and specific Console
+ */
+const getLogFunc = (
+ currContext: string,
+ color: ChalkInstance,
+ consoleInstance?: Console,
+): LogFunction => {
+ return (message?: any, ...optParams: any) => {
+ if (!consoleInstance) consoleInstance = defaultConsole;
+ const prefix = genLogPrefix(currContext, color);
+ if (typeof message === 'string') {
+ return consoleInstance.log.call(null, `${prefix} ${message}`, ...optParams);
+ } else {
+ return consoleInstance.log.call(null, prefix, message, ...optParams);
+ }
+ }
+}
+
+//Reused types
+type LogFunction = typeof Console.prototype.log;
+type DirFunction = (data: any, options?: TxInspectOptions) => void;
+interface TxBaseLogTypes {
+ debug: LogFunction;
+ log: LogFunction;
+ ok: LogFunction;
+ warn: LogFunction;
+ error: LogFunction;
+ dir: DirFunction;
+}
+
+
+/**
+ * Factory for console.log drop-ins
+ */
+const consoleFactory = (ctx?: string, subCtx?: string): CombinedConsole => {
+ const currContext = [header, ctx, subCtx].filter(x => x).join(':');
+ const baseLogs: TxBaseLogTypes = {
+ debug: getLogFunc(currContext, DEBUG_COLOR),
+ log: getLogFunc(currContext, chalk.bgBlue),
+ ok: getLogFunc(currContext, chalk.bgGreen),
+ warn: getLogFunc(currContext, chalk.bgYellow),
+ error: getLogFunc(currContext, chalk.bgRed),
+ dir: (data: any, options?: TxInspectOptions & {}) => dirHandler.call(null, data, options),
+ };
+
+ return {
+ ...defaultConsole,
+ ...baseLogs,
+ tag: (subCtx: string) => consoleFactory(ctx, subCtx),
+ multiline: (text: string | string[], color: ChalkInstance) => {
+ if (!Array.isArray(text)) text = text.split('\n');
+ const prefix = genLogPrefix(currContext, color);
+ for (const line of text) {
+ defaultConsole.log(prefix, line);
+ }
+ },
+
+ /**
+ * Prints a multiline error message with a red background
+ * @param text
+ */
+ majorMultilineError: (text: string | (string | null)[]) => {
+ if (!Array.isArray(text)) text = text.split('\n');
+ const prefix = genLogPrefix(currContext, chalk.bgRed);
+ defaultConsole.log(prefix, DIVIDER);
+ for (const line of text) {
+ if (line) {
+ defaultConsole.log(prefix, line);
+ } else {
+ defaultConsole.log(prefix, DIVIDER);
+ }
+ }
+ defaultConsole.log(prefix, DIVIDER);
+ },
+
+ //Returns a set of log functions that will be executed after a delay
+ defer: (ms = 250) => ({
+ debug: (...args) => setTimeout(() => baseLogs.debug(...args), ms),
+ log: (...args) => setTimeout(() => baseLogs.log(...args), ms),
+ ok: (...args) => setTimeout(() => baseLogs.ok(...args), ms),
+ warn: (...args) => setTimeout(() => baseLogs.warn(...args), ms),
+ error: (...args) => setTimeout(() => baseLogs.error(...args), ms),
+ dir: (...args) => setTimeout(() => baseLogs.dir(...args), ms),
+ }),
+
+ //Log functions that will output tothe verbose stream
+ verbose: {
+ debug: getLogFunc(currContext, DEBUG_COLOR, verboseConsole),
+ log: getLogFunc(currContext, chalk.bgBlue, verboseConsole),
+ ok: getLogFunc(currContext, chalk.bgGreen, verboseConsole),
+ warn: getLogFunc(currContext, chalk.bgYellow, verboseConsole),
+ error: getLogFunc(currContext, chalk.bgRed, verboseConsole),
+ dir: (data, options) => dirHandler.call(null, data, options, verboseConsole)
+ },
+
+ //Verbosity getter and explicit setter
+ get isVerbose() {
+ return _verboseFlag
+ },
+ setVerbose: (state: boolean) => {
+ _verboseFlag = !!state;
+ },
+
+ //Consts used by the fatalError util
+ DIVIDER,
+ DIVIDER_CHAR,
+ DIVIDER_SIZE,
+ };
+};
+export default consoleFactory;
+
+interface CombinedConsole extends TxConsole, Console {
+ dir: DirFunction;
+}
+
+export interface TxConsole extends TxBaseLogTypes {
+ tag: (subCtx: string) => TxConsole;
+ multiline: (text: string | string[], color: ChalkInstance) => void;
+ majorMultilineError: (text: string | (string | null)[]) => void;
+ defer: (ms?: number) => TxBaseLogTypes;
+ verbose: TxBaseLogTypes;
+ readonly isVerbose: boolean;
+ setVerbose: (state: boolean) => void;
+ DIVIDER: string;
+ DIVIDER_CHAR: string;
+ DIVIDER_SIZE: number;
+}
+
+
+/**
+ * Replaces the global console with the new one
+ */
+global.console = consoleFactory();
diff --git a/core/lib/diagnostics.ts b/core/lib/diagnostics.ts
new file mode 100644
index 0000000..e7b9a1b
--- /dev/null
+++ b/core/lib/diagnostics.ts
@@ -0,0 +1,331 @@
+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';
+const console = consoleFactory(modulename);
+
+
+//Helpers
+const MEGABYTE = 1024 * 1024;
+type HostStaticDataType = {
+ nodeVersion: string,
+ username: string,
+ osDistro: string,
+ cpu: {
+ manufacturer: string;
+ brand: string;
+ speedMin: number;
+ speedMax: number;
+ physicalCores: number;
+ cores: number;
+ clockWarning: string;
+ },
+};
+type HostDynamicDataType = {
+ cpuUsage: number;
+ memory: {
+ usage: number;
+ used: number;
+ total: number;
+ },
+};
+type HostDataReturnType = {
+ static: HostStaticDataType,
+ dynamic?: HostDynamicDataType
+} | { error: string };
+let _hostStaticDataCache: HostStaticDataType;
+
+
+/**
+ * Gets the Processes Data.
+ * 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[] = [];
+ 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];
+ 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';
+ } else {
+ procName = 'FXServer';
+ }
+
+ procList.push({
+ pid: currPidInt,
+ ppid: (curr.ppid === txProcessId) ? `${txProcessId} (txAdmin)` : curr.ppid,
+ name: procName,
+ cpu: curr.cpu,
+ memory: curr.memory / MEGABYTE,
+ order: order,
+ });
+ });
+ } catch (error) {
+ if ((error as any).code = 'ENOENT') {
+ console.error('Failed to get processes tree usage data.');
+ if (txEnv.isWindows) {
+ console.error('This is probably because the `wmic` command is not available in your system.');
+ console.error('If you are on Windows 11 or Windows Server 2025, you can enable it in the "Windows Features" settings.');
+ } else {
+ console.error('This is probably because the `ps` command is not available in your system.');
+ console.error('This command is part of the `procps` package in most Linux distributions.');
+ }
+ } else {
+ console.error('Error getting processes tree usage data.');
+ console.verbose.dir(error);
+ }
+ }
+
+ //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.
+ */
+export const getHostData = async (): Promise => {
+ //Get and cache static information
+ if (!_hostStaticDataCache) {
+ //This errors out on pterodactyl egg
+ let osUsername = 'unknown';
+ try {
+ const userInfo = os.userInfo();
+ osUsername = userInfo.username;
+ } catch (error) { }
+
+ 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,
+ osDistro: await getOsDistro(),
+ cpu: {
+ manufacturer: cpuStats.manufacturer,
+ brand: cpuStats.brand,
+ speedMin: cpuSpeed,
+ 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)' };
+ }
+ }
+
+ //Get dynamic info (mem/cpu usage) and prepare output
+ try {
+ const stats = await Promise.race([
+ getHostUsage(),
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2500))
+ ]);
+ if (stats) {
+ return {
+ static: _hostStaticDataCache,
+ dynamic: {
+ cpuUsage: stats.cpu.usage,
+ memory: {
+ usage: stats.memory.usage,
+ used: stats.memory.used,
+ total: stats.memory.total,
+ }
+ }
+ };
+ } else {
+ return {
+ static: _hostStaticDataCache,
+ };
+ }
+ } 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)' };
+ }
+}
+
+
+/**
+ * Gets the Host Static Data from cache.
+ */
+export const getHostStaticData = (): HostStaticDataType => {
+ if (!_hostStaticDataCache) {
+ throw new Error(`hostStaticDataCache not yet ready`);
+ }
+ return _hostStaticDataCache;
+}
+
+
+/**
+ * Gets txAdmin Data
+ */
+export const getTxAdminData = async () => {
+ const stats = txCore.metrics.txRuntime; //shortcut
+ const memoryUsage = getHeapStatistics();
+
+ let hostApiTokenState = 'not configured';
+ if (txHostConfig.hostApiToken === 'disabled') {
+ hostApiTokenState = 'disabled';
+ } else if (txHostConfig.hostApiToken) {
+ hostApiTokenState = 'configured';
+ }
+
+ const defaultFlags = Object.entries(txHostConfig.defaults).filter(([k, v]) => Boolean(v)).map(([k, v]) => k);
+ return {
+ //Stats
+ uptime: msToDuration(process.uptime() * 1000),
+ databaseFileSize: bytes(txCore.database.fileSize),
+ txHostConfig: {
+ ...txHostConfig,
+ dataSubPath: undefined,
+ 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,
+ }
+ },
+ 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,
+ },
+ logger: {
+ storageSize: (await txCore.logger.getStorageSize()).total,
+ 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_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),
+ },
+ };
+}
diff --git a/core/lib/fatalError.ts b/core/lib/fatalError.ts
new file mode 100644
index 0000000..036ee75
--- /dev/null
+++ b/core/lib/fatalError.ts
@@ -0,0 +1,90 @@
+
+import chalk from "chalk";
+import consoleFactory from "./console";
+import quitProcess from "./quitProcess";
+const console = consoleFactory();
+
+type ErrorLineSkipType = null | undefined | false;
+type ErrorLineType = string | [desc: string, value: any] | ErrorLineSkipType;
+type ErrorMsgType = ErrorLineType | ErrorLineType[];
+
+const padStartEnd = (str: string): string => {
+ str = ` ${str} `;
+ const padStart = Math.ceil((console.DIVIDER_SIZE + str.length) / 2);
+ return str.padStart(padStart, '-').padEnd(console.DIVIDER_SIZE, '-');
+}
+
+const printSingleLine = (line: ErrorLineType): void => {
+ if (Array.isArray(line)) {
+ if (line.length === 2 && typeof line[0] === 'string') {
+ let value = typeof line[1] === 'string' ? line[1] : String(line[1]);
+ console.error(`${line[0]}: ${chalk.dim(value)}`);
+ } else {
+ console.error(JSON.stringify(line));
+ }
+ } else if (typeof line === 'string') {
+ console.error(line);
+ }
+}
+
+function fatalError(code: number, msg: ErrorMsgType, err?: any): never {
+ console.error(console.DIVIDER);
+ console.error(chalk.inverse(
+ padStartEnd(`FATAL ERROR: E${code}`)
+ ));
+ console.error(console.DIVIDER);
+ if (Array.isArray(msg)) {
+ for (const line of msg) {
+ printSingleLine(line);
+ }
+ } else {
+ printSingleLine(msg);
+ }
+ if (err) {
+ console.error('-'.repeat(console.DIVIDER_SIZE));
+ console.dir(err, { multilineError: true });
+ }
+ console.error(console.DIVIDER);
+ console.error(chalk.inverse(
+ padStartEnd('For support: https://discord.gg/txAdmin')
+ ));
+ console.error(console.DIVIDER);
+ quitProcess(code);
+}
+
+
+/*
+NOTE: Going above 1000 to avoid collision with default nodejs error codes
+ref: https://nodejs.org/docs/latest-v22.x/api/process.html#exit-codes
+
+1000 - global data
+2000 - boot
+ 2001 - txdata
+ 2002 - setup profile throw
+ 2003 - boot throw
+ 2010 - expired
+ 2011 - expired cron
+ 2022 - txCore placeholder getter error
+ 2023 - txCore placeholder setter error
+
+5100 - config store
+5300 - admin store
+5400 - fxrunner
+5600 - database
+5700 - stats txruntime
+5800 - webserver
+*/
+
+
+fatalError.GlobalData = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(1000 + code, msg, err);
+fatalError.Boot = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(2000 + code, msg, err);
+
+fatalError.ConfigStore = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(5100 + code, msg, err);
+fatalError.Translator = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(5200 + code, msg, err);
+fatalError.AdminStore = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(5300 + code, msg, err);
+// fatalError.FxRunner = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(5400 + code, msg, err);
+fatalError.Database = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(5600 + code, msg, err);
+fatalError.StatsTxRuntime = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(5700 + code, msg, err);
+fatalError.WebServer = (code: number, msg: ErrorMsgType, err?: any): never => fatalError(5800 + code, msg, err);
+
+export default fatalError;
diff --git a/core/lib/fs.ts b/core/lib/fs.ts
new file mode 100644
index 0000000..e20c160
--- /dev/null
+++ b/core/lib/fs.ts
@@ -0,0 +1,71 @@
+import fs from 'node:fs';
+import fsp from 'node:fs/promises';
+import type { Dirent } from 'node:fs';
+import { txEnv } from '@core/globalData';
+import path from 'node:path';
+
+
+/**
+ * Check if its possible to create a file in a folder
+ */
+export const canWriteToPath = async (targetPath: string) => {
+ try {
+ await fsp.access(path.dirname(targetPath), fs.constants.W_OK);
+ return true;
+ } catch (error) {
+ return false;
+ }
+}
+
+
+/**
+ * Returns an array of directory entries (files and directories) from the specified root path.
+ */
+export const getPathContent = async (root: string, filter?: (entry: Dirent) => boolean) => {
+ const stats = await fsp.stat(root);
+ if (!stats.isDirectory()) {
+ throw new Error(`Path '${root}' is not a directory`);
+ }
+ const allEntries = await fsp.readdir(root, { withFileTypes: true });
+ return allEntries.filter((entry) => (
+ (entry.isFile() || entry.isDirectory())
+ && filter ? filter(entry) : true
+ ));
+}
+
+
+/**
+ * Returns an array of file entries from the specified root path.
+ */
+export const getPathFiles = async (root: string, filter?: (entry: Dirent) => boolean) => {
+ const entries = await getPathContent(root, filter);
+ return entries.filter((entry) => entry.isFile());
+}
+
+
+/**
+ * Returns an array of subdirectory entries from the specified root path.
+ */
+export const getPathSubdirs = async (root: string, filter?: (entry: Dirent) => boolean) => {
+ const entries = await getPathContent(root, filter);
+ return entries.filter((entry) => entry.isDirectory());
+}
+
+
+/**
+ * Generates a user-friendly markdown error message for filesystem operations.
+ * Handles common cases like Windows paths on Linux and permission issues.
+ */
+export const getFsErrorMdMessage = (error: any, targetPath: string) => {
+ if(typeof error.message !== 'string') return 'unknown error';
+
+ if (!txEnv.isWindows && /^[a-zA-Z]:[\\/]/.test(targetPath)) {
+ return `Looks like you're using a Windows path on a Linux server.\nThis likely means you are attempting to use a path from your computer on a remote server.\nIf you want to use your local files, you will first need to upload them to the server.`;
+ } else if (error.message?.includes('ENOENT')) {
+ return `The path provided does not exist:\n\`${targetPath}\``;
+ } else if (error.message?.includes('EACCES') || error.message?.includes('EPERM')) {
+ return `The path provided is not accessible:\n\`${targetPath}\``;
+ }
+
+ return error.message as string;
+}
diff --git a/core/lib/fxserver/fxsConfigHelper.ts b/core/lib/fxserver/fxsConfigHelper.ts
new file mode 100644
index 0000000..fee029d
--- /dev/null
+++ b/core/lib/fxserver/fxsConfigHelper.ts
@@ -0,0 +1,776 @@
+import fs from 'node:fs';
+import fsp from 'node:fs/promises';
+import path from 'node:path';
+import isLocalhost from 'is-localhost-ip';
+import { txHostConfig } from '@core/globalData';
+import consoleFactory from '@lib/console';
+const console = consoleFactory();
+
+
+/**
+ * Detect the dominant newline character of a string.
+ * Extracted from https://www.npmjs.com/package/detect-newline
+ */
+const detectNewline = (str: string) => {
+ if (typeof str !== 'string') {
+ throw new TypeError('Expected a string');
+ }
+
+ const newlines = str.match(/(?:\r?\n)/g) || [];
+
+ if (newlines.length === 0) {
+ return;
+ }
+
+ const crlf = newlines.filter((newline) => newline === '\r\n').length;
+ const lf = newlines.length - crlf;
+
+ return crlf > lf ? '\r\n' : '\n';
+};
+
+
+/**
+ * Helper function to store commands
+ */
+class Command {
+ readonly command: string;
+ readonly args: string[];
+ readonly file: string;
+ readonly line: number;
+
+ constructor(tokens: string[], filePath: string, fileLine: number) {
+ if (!Array.isArray(tokens) || tokens.length < 1) {
+ throw new Error('Invalid command format');
+ }
+ if (typeof tokens[0] === 'string' && tokens[0].length) {
+ this.command = tokens[0].toLocaleLowerCase();
+ } else {
+ this.command = 'invalid_empty_command';
+ }
+ this.args = tokens.slice(1);
+ this.file = filePath;
+ this.line = fileLine;
+ }
+
+ //Kinda confusing name, but it returns the value of a set if it's for that ne var
+ isConvarSetterFor(varname: string) {
+ if (
+ ['set', 'sets', 'setr'].includes(this.command)
+ && this.args.length === 2
+ && this.args[0].toLowerCase() === varname.toLowerCase()
+ ) {
+ return this.args[1];
+ }
+
+ if (
+ this.command === varname.toLowerCase()
+ && this.args.length === 1
+ ) {
+ return this.args[0];
+ }
+
+ return false;
+ }
+}
+
+
+/**
+ * Helper function to store exec errors
+ */
+class ExecRecursionError {
+ constructor(readonly file: string, readonly message: string, readonly line: number) { }
+}
+
+/**
+ * Helper class to store file TODOs, errors and warnings
+ */
+class FilesInfoList {
+ readonly store: Record = {};
+
+ add(file: string, line: number | false, msg: string) {
+ if (Array.isArray(this.store[file])) {
+ this.store[file].push([line, msg]);
+ } else {
+ this.store[file] = [[line, msg]];
+ }
+ }
+ count() {
+ return Object.keys(this.store).length;
+ }
+ toJSON() {
+ return this.store;
+ }
+ toMarkdown(hasHostConfig = false) {
+ const files = Object.keys(this.store);
+ if (!files) return null;
+
+ const msgLines = [];
+ for (const file of files) {
+ const fileInfos = this.store[file];
+ msgLines.push(`\`${file}\`:`);
+ for (const [line, msg] of fileInfos) {
+ const linePrefix = line ? `Line ${line}: ` : '';
+ const indentedMsg = msg.replaceAll(/\n\t/gm, '\n\t- ');
+ msgLines.push(`- ${linePrefix}${indentedMsg}`);
+ }
+ }
+ if (hasHostConfig) {
+ msgLines.push(''); //blank line so the warning doesn't join the list
+ msgLines.push(`**Some of the configuration above is controlled by ${txHostConfig.sourceName}.**`);
+ }
+ return msgLines.join('\n');
+ }
+}
+
+
+/**
+ * Returns the first likely server.cfg given a server data path, or false
+ */
+export const findLikelyCFGPath = (serverDataPath: string) => {
+ const commonCfgFileNames = [
+ 'server.cfg',
+ 'server.cfg.txt',
+ 'server.cfg.cfg',
+ 'server.txt',
+ 'server',
+ ];
+
+ for (const cfgFileName of commonCfgFileNames) {
+ const absoluteCfgPath = path.join(serverDataPath, cfgFileName);
+ try {
+ if (fs.lstatSync(absoluteCfgPath).isFile()) {
+ return cfgFileName;
+ }
+ } catch (error) { }
+ }
+ return false;
+}
+
+
+/**
+ * Returns the absolute path of the given CFG Path
+ */
+export const resolveCFGFilePath = (cfgPath: string, dataPath: string) => {
+ return (path.isAbsolute(cfgPath)) ? path.normalize(cfgPath) : path.resolve(dataPath, cfgPath);
+};
+
+
+/**
+ * Reads CFG Path and return the file contents, or throw error if:
+ * - the path is not valid (must be absolute)
+ * - cannot read the file data
+ */
+export const readRawCFGFile = async (cfgPath: string) => {
+ //Validating if the path is absolute
+ if (!path.isAbsolute(cfgPath)) {
+ throw new Error('File path must be absolute.');
+ }
+
+ //Validating file existence
+ if (!fs.existsSync(cfgPath)) {
+ throw new Error("File doesn't exist or its unreadable.");
+ }
+
+ //Validating if its actually a file
+ if (!fs.lstatSync(cfgPath).isFile()) {
+ throw new Error("File doesn't exist or its unreadable. Make sure to include the CFG file in the path, and not just the directory that contains it.");
+ }
+
+ //Reading file
+ try {
+ return await fsp.readFile(cfgPath, 'utf8');
+ } catch (error) {
+ throw new Error('Cannot read CFG file.');
+ }
+};
+
+
+/**
+ * Parse a cfg/console line and return an array of commands with tokens.
+ * Notable difference: we don't handle inline block comment
+ * Original Line Parser:
+ * fivem/code/client/citicore/console/Console.cpp > ProgramArguments Tokenize
+ */
+export const readLineCommands = (input: string) => {
+ let inQuote = false;
+ let inEscape = false;
+ const prevCommands = [];
+ let currCommand = [];
+ let currToken = '';
+ for (let i = 0; i < input.length; i++) {
+ if (inEscape) {
+ if (input[i] === '"' || input[i] === '\\') {
+ currToken += input[i];
+ }
+ inEscape = false;
+ continue;
+ }
+
+ if (!currToken.length) {
+ if (
+ input.slice(i, i + 2) === '//'
+ || input[i] === '#'
+ ) {
+ break;
+ }
+ }
+
+ if (!inQuote && input.charCodeAt(i) <= 32) {
+ if (currToken.length) {
+ currCommand.push(currToken);
+ currToken = '';
+ }
+ continue;
+ }
+
+ if (input[i] === '"') {
+ if (inQuote) {
+ currCommand.push(currToken);
+ currToken = '';
+ inQuote = false;
+ } else {
+ inQuote = true;
+ }
+ continue;
+ }
+
+ if (input[i] === '\\') {
+ inEscape = true;
+ continue;
+ }
+
+ if (!inQuote && input[i] === ';') {
+ if (currToken.length) {
+ currCommand.push(currToken);
+ currToken = '';
+ }
+ prevCommands.push(currCommand);
+ currCommand = [];
+ continue;
+ };
+
+ currToken += input[i];
+ }
+ if (currToken.length) {
+ currCommand.push(currToken);
+ }
+ prevCommands.push(currCommand);
+
+ return prevCommands;
+};
+//NOTE: tests for the parser above
+// import chalk from 'chalk';
+// const testCommands = [
+// ' \x1B ONE_ARG_WITH_SPACE "part1 part2"',
+// 'TWO_ARGS arg1 arg2',
+// 'ONE_ARG_WITH_SPACE_SEMICOLON "arg mid;cut"',
+// 'ESCAPED_QUOTE "aa\\"bb"',
+// 'ESCAPED_ESCAPE "aa\\\\bb"',
+// 'ESCAPED_X "aa\\xbb"',
+// // 'NO_CLOSING_QUOTE "aa',
+// // 'SHOW_AB_C aaa#bbb ccc',
+// // 'COMMENT //anything noshow',
+// 'COMMENT #anything noshow',
+// 'noshow2',
+// ];
+// const parsed = readLineCommands(testCommands.join(';'));
+// for (const commandTokens of parsed) {
+// console.log(`${commandTokens[0]}:`);
+// commandTokens.slice(1).forEach((token) => {
+// console.log(chalk.inverse(token));
+// });
+// console.log('\n');
+// }
+
+
+/**
+ * Recursively parse server.cfg files losely based on the FXServer original parser.
+ * Notable differences: we have recursivity depth limit, and no json parsing
+ * Original CFG (console) parser:
+ * fivem/code/client/citicore/console/Console.cpp > Context::ExecuteBuffer
+ *
+ * FIXME: support `@resource/whatever.cfg` syntax
+ */
+export const parseRecursiveConfig = async (
+ cfgInputString: string | null, //cfg string, or null to load from file
+ cfgAbsolutePath: string,
+ serverDataPath: string,
+ stack?: string[]
+) => {
+ if (typeof cfgInputString !== 'string' && cfgInputString !== null) {
+ throw new Error('cfgInputString expected to be string or null');
+ }
+
+ // Ensure safe stack
+ const MAX_DEPTH = 5;
+ if (!Array.isArray(stack)) {
+ stack = [];
+ } else if (stack.length >= MAX_DEPTH) {
+ throw new Error(`cfg 'exec' command depth above ${MAX_DEPTH}`);
+ } else if (stack.includes(cfgAbsolutePath)) {
+ throw new Error(`cfg cyclical 'exec' command detected to file ${cfgAbsolutePath}`); //should block
+ }
+ stack.push(cfgAbsolutePath);
+
+ // Read raw config and split lines
+ const cfgData = cfgInputString ?? await readRawCFGFile(cfgAbsolutePath);
+ const cfgLines = cfgData.split('\n');
+
+ // Parse CFG lines
+ const parsedCommands: (Command | ExecRecursionError)[] = [];
+ for (let i = 0; i < cfgLines.length; i++) {
+ const lineString = cfgLines[i].trim();
+ const lineNumber = i + 1;
+ const lineCommands = readLineCommands(lineString);
+
+ // For each command in that line
+ for (const cmdTokens of lineCommands) {
+ if (!cmdTokens.length) continue;
+ const cmdObject = new Command(cmdTokens, cfgAbsolutePath, lineNumber);
+ parsedCommands.push(cmdObject);
+
+ // If exec command, process recursively then flatten the output
+ if (cmdObject.command === 'exec' && typeof cmdObject.args[0] === 'string') {
+ //FIXME: temporarily disable resoure references
+ if (!cmdObject.args[0].startsWith('@')) {
+ const recursiveCfgAbsolutePath = resolveCFGFilePath(cmdObject.args[0], serverDataPath);
+ try {
+ const extractedCommands = await parseRecursiveConfig(null, recursiveCfgAbsolutePath, serverDataPath, stack);
+ parsedCommands.push(...extractedCommands);
+ } catch (error) {
+ parsedCommands.push(new ExecRecursionError(cfgAbsolutePath, (error as Error).message, lineNumber));
+ }
+ }
+ }
+ }
+ }
+
+ stack.pop();
+ return parsedCommands;
+};
+
+type EndpointsObjectType = Record
+
+/**
+ * Validates a list of parsed commands to return endpoints, errors, warnings and lines to comment out
+ */
+const validateCommands = async (parsedCommands: (ExecRecursionError | Command)[]) => {
+ const checkedInterfaces = new Map();
+ let detectedGameName: string | undefined;
+ const requiredGameName = txHostConfig.forceGameName
+ ? txHostConfig.forceGameName === 'fivem' ? 'gta5' : 'rdr3'
+ : undefined;
+
+ //To return
+ let hasHostConfigMessage = false;
+ let hasEndpointCommand = false;
+ const endpoints: EndpointsObjectType = {};
+ const errors = new FilesInfoList();
+ const warnings = new FilesInfoList();
+ const toCommentOut = new FilesInfoList();
+
+ for (const cmd of parsedCommands) {
+ //In case of error
+ if (cmd instanceof ExecRecursionError) {
+ warnings.add(cmd.file, cmd.line, cmd.message);
+ continue;
+ }
+
+ //Check for +set
+ if (['+set', '+setr', '+setr'].includes(cmd.command)) {
+ const msg = `Line ${cmd.line}: remove the '+' from '${cmd.command}', as this is not an launch parameter.`;
+ warnings.add(cmd.file, cmd.line, msg);
+ continue;
+ }
+
+ //Check for start/stop/ensure txAdmin/txAdminClient/monitor
+ if (
+ ['start', 'stop', 'ensure'].includes(cmd.command)
+ && cmd.args.length >= 1
+ && ['txadmin', 'txadminclient', 'monitor'].includes(cmd.args[0].toLowerCase())
+ ) {
+ toCommentOut.add(
+ cmd.file,
+ cmd.line,
+ 'you MUST NOT start/stop/ensure txadmin resources.',
+ );
+ continue;
+ }
+
+ //Check sv_maxClients against TXHOST config
+ const isMaxClientsString = cmd.isConvarSetterFor('sv_maxclients');
+ if (
+ txHostConfig.forceMaxClients
+ && isMaxClientsString
+ ) {
+ const maxClients = parseInt(isMaxClientsString);
+ if (maxClients > txHostConfig.forceMaxClients) {
+ hasHostConfigMessage = true;
+ errors.add(
+ cmd.file,
+ cmd.line,
+ `your 'sv_maxclients' MUST be <= ${txHostConfig.forceMaxClients}.`
+ );
+ continue;
+ }
+ }
+
+ //Check gamename against TXHOST config
+ const isGameNameString = cmd.isConvarSetterFor('gamename');
+ if (isGameNameString && detectedGameName) {
+ errors.add(
+ cmd.file,
+ cmd.line,
+ `you already set the 'gamename' to '${detectedGameName}', please remove this line.`
+ );
+ continue;
+ }
+ if (
+ txHostConfig.forceGameName
+ && isGameNameString
+ ) {
+ detectedGameName = isGameNameString;
+ if (isGameNameString !== requiredGameName) {
+ hasHostConfigMessage = true;
+ errors.add(
+ cmd.file,
+ cmd.line,
+ `your 'gamename' MUST be '${requiredGameName}'.`
+ );
+ continue;
+ }
+ }
+
+ //Comment out any onesync sets
+ if (cmd.isConvarSetterFor('onesync')) {
+ toCommentOut.add(
+ cmd.file,
+ cmd.line,
+ 'onesync MUST only be set in the txAdmin settings page.',
+ );
+ continue;
+ }
+
+ //FIXME: add isConvarSetterFor for all "Settings page only" convars
+
+ //Extract & process endpoint validity
+ if (cmd.command === 'endpoint_add_tcp' || cmd.command === 'endpoint_add_udp') {
+ hasEndpointCommand = true;
+
+ //Validating args length
+ if (cmd.args.length !== 1) {
+ warnings.add(
+ cmd.file,
+ cmd.line,
+ `the \`endpoint_add_*\` commands MUST have exactly 1 argument (received ${cmd.args.length})`
+ );
+ continue;
+ }
+
+ //Extracting parts & validating format
+ const endpointsRegex = /^\[?(([0-9.]{7,15})|([a-z0-9:]{2,29}))\]?:(\d{1,5})$/gi;
+ const matches = [...cmd.args[0].matchAll(endpointsRegex)];
+ if (!Array.isArray(matches) || !matches.length) {
+ errors.add(
+ cmd.file,
+ cmd.line,
+ `the \`${cmd.args[0]}\` is not in a valid \`ip:port\` format.`
+ );
+ continue;
+ }
+ const [_matchedString, iface, ipv4, ipv6, portString] = matches[0];
+
+ //Checking if that interface is available to binding
+ let canBind = checkedInterfaces.get(iface);
+ if (typeof canBind === 'undefined') {
+ canBind = await isLocalhost(iface, true);
+ checkedInterfaces.set(iface, canBind);
+ }
+ if (canBind === false) {
+ errors.add(
+ cmd.file,
+ cmd.line,
+ `the \`${cmd.command}\` interface \`${iface}\` is not available for this host.`
+ );
+ continue;
+ }
+ if (txHostConfig.netInterface && iface !== txHostConfig.netInterface) {
+ hasHostConfigMessage = true;
+ errors.add(
+ cmd.file,
+ cmd.line,
+ `the \`${cmd.command}\` interface MUST be \`${txHostConfig.netInterface}\`.`
+ );
+ continue;
+ }
+
+ //Validating port
+ const port = parseInt(portString);
+ if (port >= 40120 && port <= 40150) {
+ errors.add(
+ cmd.file,
+ cmd.line,
+ `the \`${cmd.command}\` port \`${port}\` is dedicated for txAdmin and CAN NOT be used for FXServer.`
+ );
+ continue;
+ }
+ if (port === txHostConfig.txaPort) {
+ errors.add(
+ cmd.file,
+ cmd.line,
+ `the \`${cmd.command}\` port \`${port}\` is being used by txAdmin and CAN NOT be used for FXServer at the same time.`
+ );
+ continue;
+ }
+ if (txHostConfig.fxsPort && port !== txHostConfig.fxsPort) {
+ hasHostConfigMessage = true;
+ errors.add(
+ cmd.file,
+ cmd.line,
+ `the \`${cmd.command}\` port MUST be \`${txHostConfig.fxsPort}\`.`
+ );
+ continue;
+ }
+
+ //Add to the endpoint list and check duplicity
+ const endpoint = (ipv4) ? `${ipv4}:${port}` : `[${ipv6}]:${port}`;
+ const protocol = (cmd.command === 'endpoint_add_tcp') ? 'tcp' : 'udp';
+ if (typeof endpoints[endpoint] === 'undefined') {
+ endpoints[endpoint] = {};
+ }
+ if (endpoints[endpoint][protocol]) {
+ errors.add(
+ cmd.file,
+ cmd.line,
+ `you CANNOT execute \`${cmd.command}\` twice for the interface \`${endpoint}\`.`
+ );
+ continue;
+ } else {
+ endpoints[endpoint][protocol] = true;
+ }
+ }
+ }
+
+ //Since gta5 is the default, we need to check TXHOST for redm
+ if (txHostConfig.forceGameName === 'redm' && detectedGameName !== 'rdr3') {
+ const initFile = parsedCommands[0]?.file ?? 'unknown';
+ hasHostConfigMessage = true;
+ errors.add(
+ initFile,
+ false,
+ `your config MUST have a 'gamename' set to '${requiredGameName}'.`
+ );
+ }
+
+ return {
+ endpoints,
+ hasEndpointCommand,
+ hasHostConfigMessage,
+ errors,
+ warnings,
+ toCommentOut,
+ };
+};
+
+
+/**
+ * Process endpoints object, checks validity, and then returns a connection string
+ */
+const getConnectEndpoint = (endpoints: EndpointsObjectType, hasEndpointCommand: boolean) => {
+ if (!Object.keys(endpoints).length) {
+ const instruction = hasEndpointCommand
+ ? 'Please delete all \`endpoint_add_*\` lines and'
+ : 'Please'
+ const suggestedPort = txHostConfig.fxsPort ?? 30120;
+ const suggestedInterface = txHostConfig.netInterface ?? '0.0.0.0';
+ const desidredEndpoint = `${suggestedInterface}:${suggestedPort}`;
+ const msg = [
+ `Your config file does not specify a valid endpoints for FXServer to use. ${instruction} add the following to the start of the file:`,
+ `\t\`endpoint_add_tcp "${desidredEndpoint}"\``,
+ `\t\`endpoint_add_udp "${desidredEndpoint}"\``,
+ ].join('\n');
+ throw new Error(msg);
+ }
+ const tcpudpEndpoint = Object.keys(endpoints).find((ep) => {
+ return endpoints[ep].tcp && endpoints[ep].udp;
+ });
+ if (!tcpudpEndpoint) {
+ throw new Error('Your config file does not not contain a ip:port used in both `endpoint_add_tcp` and `endpoint_add_udp` commands. Players would not be able to connect.');
+ }
+
+ return tcpudpEndpoint.replace(/(0\.0\.0\.0|\[::\])/, '127.0.0.1');
+};
+
+
+/**
+ * Validates & ensures correctness in FXServer config file recursively.
+ * Used when trying to start server, or validate the server.cfg.
+ * Returns errors, warnings and connectEndpoint
+ */
+export const validateFixServerConfig = async (cfgPath: string, serverDataPath: string) => {
+ //Parsing FXServer config & going through each command
+ const cfgAbsolutePath = resolveCFGFilePath(cfgPath, serverDataPath);
+ const parsedCommands = await parseRecursiveConfig(null, cfgAbsolutePath, serverDataPath);
+ const {
+ endpoints,
+ hasEndpointCommand,
+ hasHostConfigMessage,
+ errors,
+ warnings,
+ toCommentOut
+ } = await validateCommands(parsedCommands);
+
+ //Validating if a valid endpoint was detected
+ let connectEndpoint: string | null = null;
+ try {
+ connectEndpoint = getConnectEndpoint(endpoints, hasEndpointCommand);
+ } catch (error) {
+ errors.add(cfgAbsolutePath, false, (error as Error).message);
+ }
+
+ //Commenting out lines or registering them as warnings
+ for (const targetCfgPath in toCommentOut.store) {
+ const actions = toCommentOut.store[targetCfgPath];
+ try {
+ const cfgRaw = await fsp.readFile(targetCfgPath, 'utf8');
+
+ //modify the cfg lines
+ const fileEOL = detectNewline(cfgRaw);
+ const cfgLines = cfgRaw.split(/\r?\n/);
+ for (const [ln, reason] of actions) {
+ if (ln === false) continue;
+ if (typeof cfgLines[ln - 1] !== 'string') {
+ throw new Error(`Line ${ln} not found.`);
+ }
+ cfgLines[ln - 1] = `## [txAdmin CFG validator]: ${reason}${fileEOL}# ${cfgLines[ln - 1]}`;
+ warnings.add(targetCfgPath, ln, `Commented out: ${reason}`);
+ }
+
+ //Saving modified lines
+ const newCfg = cfgLines.join(fileEOL);
+ console.warn(`Saving modified file '${targetCfgPath}'`);
+ await fsp.writeFile(targetCfgPath, newCfg, 'utf8');
+ } catch (error) {
+ console.verbose.error(error);
+ for (const [ln, reason] of actions) {
+ errors.add(targetCfgPath, ln, `Please comment out this line: ${reason}`);
+ }
+ }
+ }
+
+ //Prepare response
+ return {
+ connectEndpoint,
+ errors: errors.toMarkdown(hasHostConfigMessage),
+ warnings: warnings.toMarkdown(hasHostConfigMessage),
+ // errors: errors.store,
+ // warnings: warnings.store,
+ // endpoints, //Not being used
+ };
+};
+
+
+/**
+ * Validating config contents + saving file and backup.
+ * In case of any errors, it does not save the contents.
+ * Does not comment out (fix) bad lines.
+ * Used whenever a user wants to modify server.cfg.
+ * Returns if saved, and warnings
+ */
+export const validateModifyServerConfig = async (
+ cfgInputString: string,
+ cfgPath: string,
+ serverDataPath: string
+) => {
+ if (typeof cfgInputString !== 'string') {
+ throw new Error('cfgInputString expected to be string.');
+ }
+
+ //Parsing FXServer config & going through each command
+ const cfgAbsolutePath = resolveCFGFilePath(cfgPath, serverDataPath);
+ const parsedCommands = await parseRecursiveConfig(cfgInputString, cfgAbsolutePath, serverDataPath);
+ const {
+ endpoints,
+ hasEndpointCommand,
+ hasHostConfigMessage,
+ errors,
+ warnings,
+ toCommentOut
+ } = await validateCommands(parsedCommands);
+
+ //Validating if a valid endpoint was detected
+ try {
+ const _connectEndpoint = getConnectEndpoint(endpoints, hasEndpointCommand);
+ } catch (error) {
+ errors.add(cfgAbsolutePath, false, (error as Error).message);
+ }
+
+ //If there are any errors
+ if (errors.count()) {
+ return {
+ success: false,
+ errors: errors.toMarkdown(hasHostConfigMessage),
+ warnings: warnings.toMarkdown(hasHostConfigMessage),
+ };
+ }
+
+ //Save file + backup
+ try {
+ console.warn(`Saving modified file '${cfgAbsolutePath}'`);
+ await fsp.copyFile(cfgAbsolutePath, `${cfgAbsolutePath}.bkp`);
+ await fsp.writeFile(cfgAbsolutePath, cfgInputString, 'utf8');
+ } catch (error) {
+ throw new Error(`Failed to edit 'server.cfg' with error: ${(error as Error).message}`);
+ }
+
+ return {
+ success: true,
+ warnings: warnings.toMarkdown(),
+ };
+};
+/*
+ fxrunner spawnServer: recursive validate file, get endpoint
+ settings handleFXServer: recursive validate file
+ setup handleValidateCFGFile: recursive validate file
+ setup handleSaveLocal: recursive validate file
+
+ cfgEditor CFGEditorSave: validate string, save
+ deployer handleSaveConfig: validate string, save *
+*/
+
+/*
+
+# Endpoints test cases:
+/"\[?(([0-9.]{7,15})|([a-z0-9:]{2,29}))\]?:(\d{1,5})"/gmi
+
+# default
+"0.0.0.0:30120"
+"[0.0.0.0]:30120"
+"0.0.0.0"
+"[0.0.0.0]"
+
+# ipv6/ipv4
+"[::]:30120"
+":::30120"
+"[::]"
+
+# ipv6 only
+"fe80::4cec:1264:187e:ce2b:30120"
+"[fe80::4cec:1264:187e:ce2b]:30120"
+"::1:30120"
+"[::1]:30120"
+"[fe80::4cec:1264:187e:ce2b]"
+"::1"
+"[::1]"
+
+# FXServer doesn't accept
+"::1.30120"
+"::1 port 30120"
+"::1p30120"
+"::1#30120"
+"::"
+
+# FXServer misreads last part as a port
+"fe80::4cec:1264:187e:ce2b"
+
+*/
diff --git a/core/lib/fxserver/fxsVersionParser.test.ts b/core/lib/fxserver/fxsVersionParser.test.ts
new file mode 100644
index 0000000..59253f7
--- /dev/null
+++ b/core/lib/fxserver/fxsVersionParser.test.ts
@@ -0,0 +1,70 @@
+//@ts-nocheck
+import { test, expect } from 'vitest';
+import { parseFxserverVersion } from './fxsVersionParser';
+const p = parseFxserverVersion;
+
+
+test('normal versions', () => {
+ expect(p('FXServer-master SERVER v1.0.0.7290 win32')).toEqual({
+ build: 7290,
+ platform: 'windows',
+ branch: 'master',
+ valid: true,
+ });
+ expect(p('FXServer-master SERVER v1.0.0.10048 win32')).toEqual({
+ build: 10048,
+ platform: 'windows',
+ branch: 'master',
+ valid: true,
+ });
+ expect(p('FXServer-master v1.0.0.9956 linux')).toEqual({
+ build: 9956,
+ platform: 'linux',
+ branch: 'master',
+ valid: true,
+ });
+});
+
+test('feat branch versions', () => {
+ expect(p('FXServer-feature/improve_player_dropped_event SERVER v1.0.0.20240707 win32')).toEqual({
+ build: 20240707,
+ platform: 'windows',
+ branch: 'feature/improve_player_dropped_event',
+ valid: true,
+ });
+ expect(p('FXServer-abcdef SERVER v1.0.0.20240707 win32')).toEqual({
+ build: 20240707,
+ platform: 'windows',
+ branch: 'abcdef',
+ valid: true,
+ });
+});
+
+test('invalids', () => {
+ expect(() => p(1111 as any)).toThrow('expected');
+ expect(p('FXServer-no-version (didn\'t run build tools?)')).toEqual({
+ valid: false,
+ build: null,
+ branch: null,
+ platform: null,
+ });
+ expect(p('Invalid server (internal validation failed)')).toEqual({
+ valid: false,
+ build: null,
+ branch: null,
+ platform: null,
+ });
+ //attempt to salvage platform
+ expect(p('xxxxxxxx win32')).toEqual({
+ valid: false,
+ build: null,
+ branch: null,
+ platform: 'windows',
+ });
+ expect(p('xxxxxxxx linux')).toEqual({
+ valid: false,
+ build: null,
+ branch: null,
+ platform: 'linux',
+ });
+});
diff --git a/core/lib/fxserver/fxsVersionParser.ts b/core/lib/fxserver/fxsVersionParser.ts
new file mode 100644
index 0000000..971453b
--- /dev/null
+++ b/core/lib/fxserver/fxsVersionParser.ts
@@ -0,0 +1,25 @@
+/**
+ * Parses a fxserver version convar into a number.
+*/
+export const parseFxserverVersion = (version: any): ParseFxserverVersionResult => {
+ if (typeof version !== 'string') throw new Error(`expected string`);
+
+ return {
+ valid: true,
+ branch: "master",
+ build: 9999,
+ platform: "windows",
+ }
+};
+
+type ParseFxserverVersionResult = {
+ valid: true;
+ branch: string;
+ build: number;
+ platform: string;
+} | {
+ valid: false;
+ branch: null;
+ build: null;
+ platform: 'windows' | 'linux' | null;
+};
diff --git a/core/lib/fxserver/runtimeFiles.ts b/core/lib/fxserver/runtimeFiles.ts
new file mode 100644
index 0000000..564dd61
--- /dev/null
+++ b/core/lib/fxserver/runtimeFiles.ts
@@ -0,0 +1,43 @@
+import path from 'node:path';
+import fsp from 'node:fs/promises';
+import { txEnv } from "@core/globalData";
+
+
+/**
+ * Creates or removes a monitor/.runtime/ file
+ */
+export const setRuntimeFile = async (fileName: string, fileData: string | Buffer | null) => {
+ const destRuntimePath = path.resolve(txEnv.txaPath, '.runtime');
+ const destFilePath = path.resolve(destRuntimePath, fileName);
+
+ //Ensure the /.runtime/ folder exists
+ try {
+ await fsp.mkdir(destRuntimePath, { recursive: true });
+ } catch (error) {
+ console.error(`Failed to create .runtime folder: ${(error as any).message}`);
+ return false;
+ }
+
+ //If deleting the file, just unlink it
+ if (fileData === null) {
+ try {
+ await fsp.unlink(destFilePath);
+ } catch (error) {
+ const msg = (error as Error).message ?? 'Unknown error';
+ if (!msg.includes('ENOENT')) {
+ console.error(`Failed to delete runtime file: ${msg}`);
+ return false;
+ }
+ }
+ return true;
+ }
+
+ //Write the file
+ try {
+ await fsp.writeFile(destFilePath, fileData);
+ return true;
+ } catch (error) {
+ console.error(`Failed to write runtime file: ${(error as any).message}`);
+ }
+ return false;
+}
diff --git a/core/lib/fxserver/scanMonitorFiles.ts b/core/lib/fxserver/scanMonitorFiles.ts
new file mode 100644
index 0000000..f304942
--- /dev/null
+++ b/core/lib/fxserver/scanMonitorFiles.ts
@@ -0,0 +1,107 @@
+//FIXME: after refactor, move to the correct path
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { createHash } from 'node:crypto';
+import { txEnv } from '../../globalData';
+
+//Hash test
+const hashFile = async (filePath: string) => {
+ const rawFile = await fs.readFile(filePath, 'utf8')
+ const normalized = rawFile.normalize('NFKC')
+ .replace(/\r\n/g, '\n')
+ .replace(/^\uFEFF/, '');
+ return createHash('sha1').update(normalized).digest('hex');
+}
+
+// Limits
+const MAX_FILES = 300;
+const MAX_TOTAL_SIZE = 52_428_800; // 50MB
+const MAX_FILE_SIZE = 20_971_520; // 20MB
+const MAX_DEPTH = 10;
+const MAX_EXECUTION_TIME = 30 * 1000;
+const IGNORED_FOLDERS = [
+ 'db',
+ 'cache',
+ 'dist',
+ '.reports',
+ 'license_report',
+ 'tmp_core_tsc',
+ 'node_modules',
+ 'txData',
+];
+
+
+type ContentFileType = {
+ path: string;
+ size: number;
+ hash: string;
+}
+
+export default async function scanMonitorFiles() {
+ const rootPath = txEnv.txaPath;
+ const allFiles: ContentFileType[] = [];
+ let totalFiles = 0;
+ let totalSize = 0;
+
+ try {
+ const tsStart = Date.now();
+ const scanDir = async (dir: string, depth: number = 0) => {
+ if (depth > MAX_DEPTH) {
+ throw new Error('MAX_DEPTH');
+ }
+
+ let filesFound = 0;
+ const entries = await fs.readdir(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (totalFiles >= MAX_FILES) {
+ throw new Error('MAX_FILES');
+ } else if (totalSize >= MAX_TOTAL_SIZE) {
+ throw new Error('MAX_TOTAL_SIZE');
+ } else if (Date.now() - tsStart > MAX_EXECUTION_TIME) {
+ throw new Error('MAX_EXECUTION_TIME');
+ }
+
+ const entryPath = path.join(dir, entry.name);
+ let relativeEntryPath = path.relative(rootPath, entryPath);
+ relativeEntryPath = './' + relativeEntryPath.split(path.sep).join(path.posix.sep);
+
+ if (entry.isDirectory()) {
+ if (IGNORED_FOLDERS.includes(entry.name)) {
+ continue;
+ }
+ await scanDir(entryPath, depth + 1);
+ } else if (entry.isFile()) {
+ const stats = await fs.stat(entryPath);
+ if (stats.size > MAX_FILE_SIZE) {
+ throw new Error('MAX_SIZE');
+ }
+
+ allFiles.push({
+ path: relativeEntryPath,
+ size: stats.size,
+ hash: await hashFile(entryPath),
+ });
+ filesFound++;
+ totalFiles++;
+ totalSize += stats.size;
+ }
+ }
+ return filesFound;
+ };
+ await scanDir(rootPath);
+ allFiles.sort((a, b) => a.path.localeCompare(b.path));
+ return {
+ totalFiles,
+ totalSize,
+ allFiles,
+ };
+ } catch (error) {
+ //At least saving the progress
+ return {
+ error: (error as any).message,
+ totalFiles,
+ totalSize,
+ allFiles,
+ };
+ }
+}
diff --git a/core/lib/fxserver/serverData.ts b/core/lib/fxserver/serverData.ts
new file mode 100644
index 0000000..5b3b52b
--- /dev/null
+++ b/core/lib/fxserver/serverData.ts
@@ -0,0 +1,236 @@
+import { txEnv } from '@core/globalData';
+import { getFsErrorMdMessage, getPathSubdirs } from '@lib/fs';
+import * as fsp from 'node:fs/promises';
+import * as path from 'node:path';
+
+const IGNORED_DIRS = ['cache', 'db', 'node_modules', '.git', '.idea', '.vscode'];
+const MANIFEST_FILES = ['fxmanifest.lua', '__resource.lua'];
+const RES_CATEGORIES_LIMIT = 250; //Some servers go over 100
+const CFG_SIZE_LIMIT = 32 * 1024; //32kb
+
+
+//Types
+export type ServerDataContentType = [string, number | boolean][];
+export type ServerDataConfigsType = [string, string][];
+
+
+/**
+ * Scans a server data folder and lists all files, up to the first level of each resource.
+ * Behavior reference: fivem/code/components/citizen-server-impl/src/ServerResources.cpp
+ *
+ * NOTE: this would probably be better
+ *
+ * TODO: the current sorting is not right, changing it back to recursive (depth-first) would
+ * probably solve it, but right now it's not critical.
+ * A better behavior would be to set "MAX_DEPTH" and do that for all folders, ignoring "resources"/[categories]
+ */
+export const getServerDataContent = async (serverDataPath: string): Promise => {
+ //Runtime vars
+ let resourcesInRoot = false;
+ const content: ServerDataContentType = []; //relative paths
+ let resourceCategories = 0;
+
+ //Scan root path
+ const rootEntries = await fsp.readdir(serverDataPath, { withFileTypes: true });
+ for (const entry of rootEntries) {
+
+ if (entry.isDirectory()) {
+ content.push([entry.name, false]);
+ if (entry.name === 'resources') {
+ resourcesInRoot = true;
+ }
+ } else if (entry.isFile()) {
+ const stat = await fsp.stat(path.join(serverDataPath, entry.name));
+ content.push([entry.name, stat.size]);
+ }
+ }
+ //no resources, early return
+ if (!resourcesInRoot) return content;
+
+
+ //Scan categories
+ const categoriesToScan = [path.join(serverDataPath, 'resources')];
+ while (categoriesToScan.length) {
+ if (resourceCategories >= RES_CATEGORIES_LIMIT) {
+ throw new Error(`Scanning above the limit of ${RES_CATEGORIES_LIMIT} resource categories.`);
+ }
+ resourceCategories++;
+ const currCategory = categoriesToScan.shift()!;
+ const currCatDirEntries = await fsp.readdir(currCategory, { withFileTypes: true });
+
+ for (const catDirEntry of currCatDirEntries) {
+ const catDirEntryFullPath = path.join(currCategory, catDirEntry.name);
+ const catDirEntryRelativePath = path.relative(serverDataPath, catDirEntryFullPath);
+
+ if (catDirEntry.isDirectory()) {
+ content.push([path.relative(serverDataPath, catDirEntryFullPath), false]);
+ if (!catDirEntry.name.length || IGNORED_DIRS.includes(catDirEntry.name)) continue;
+
+ if (catDirEntry.name[0] === '[' && catDirEntry.name[catDirEntry.name.length - 1] === ']') {
+ //It's a category
+ categoriesToScan.push(catDirEntryFullPath);
+
+ } else {
+ //It's a resource
+ const resourceFullPath = catDirEntryFullPath;
+ const resourceRelativePath = catDirEntryRelativePath;
+ let resourceHasManifest = false;
+ const resDirEntries = await fsp.readdir(resourceFullPath, { withFileTypes: true });
+
+ //for every file/folder in resources folder
+ for (const resDirEntry of resDirEntries) {
+ const resEntryFullPath = path.join(resourceFullPath, resDirEntry.name);
+ const resEntryRelativePath = path.join(resourceRelativePath, resDirEntry.name);
+ if (resDirEntry.isDirectory()) {
+ content.push([resEntryRelativePath, false]);
+
+ } else if (resDirEntry.isFile()) {
+ const stat = await fsp.stat(resEntryFullPath);
+ content.push([resEntryRelativePath, stat.size]);
+ if (!resourceHasManifest && MANIFEST_FILES.includes(resDirEntry.name)) {
+ resourceHasManifest = true;
+ }
+ }
+ };
+ }
+
+ } else if (catDirEntry.isFile()) {
+ const stat = await fsp.stat(catDirEntryFullPath);
+ content.push([catDirEntryRelativePath, stat.size]);
+ }
+ }
+ }//while categories
+
+ // Sorting content (folders first, then utf8)
+ content.sort(([aName, aSize], [bName, bSize]) => {
+ const aDir = path.parse(aName).dir;
+ const bDir = path.parse(bName).dir;
+ if (aDir !== bDir) {
+ return aName.localeCompare(bName);
+ } else if (aSize === false && bSize !== false) {
+ return -1;
+ } else if (aSize !== false && bSize === false) {
+ return 1;
+ } else {
+ return aName.localeCompare(bName);
+ }
+ });
+
+ return content;
+}
+
+
+/**
+ * Returns the content of all .cfg files based on a server data content scan.
+ */
+export const getServerDataConfigs = async (serverDataPath: string, serverDataContent: ServerDataContentType): Promise => {
+ const configs: ServerDataConfigsType = [];
+ for (const [entryPath, entrySize] of serverDataContent) {
+ if (typeof entrySize !== 'number' || !entryPath.endsWith('.cfg')) continue;
+ if (entrySize > CFG_SIZE_LIMIT) {
+ configs.push([entryPath, 'file is too big']);
+ }
+
+ try {
+ const rawData = await fsp.readFile(path.join(serverDataPath, entryPath), 'utf8');
+ configs.push([entryPath, rawData]);
+ } catch (error) {
+ configs.push([entryPath, (error as Error).message]);
+ }
+ }
+
+ return configs;
+}
+
+
+/**
+ * Validate server data path for the
+ */
+export const isValidServerDataPath = async (dataPath: string) => {
+ //Check if root folder is valid
+ try {
+ const rootEntries = await getPathSubdirs(dataPath);
+ if (!rootEntries.some(e => e.name === 'resources')) {
+ throw new Error('The provided directory does not contain a \`resources\` subdirectory.');
+ }
+ } catch (err) {
+ const error = err as Error;
+ let msg = getFsErrorMdMessage(error, dataPath);
+ if (dataPath.includes('resources')) {
+ msg = `Looks like this path is the \`resources\` folder, but the server data path must be the folder that contains the resources folder instead of the resources folder itself.\n**Try removing the \`resources\` part at the end of the path.**`;
+ }
+ throw new Error(msg);
+ }
+
+ //Check if resources folder is valid
+ try {
+ const resourceEntries = await getPathSubdirs(path.join(dataPath, 'resources'));
+ if (!resourceEntries.length) {
+ throw new Error('The \`resources\` directory is empty.');
+ }
+ } catch (err) {
+ const error = err as Error;
+ let msg = error.message;
+ if (error.message?.includes('ENOENT')) {
+ msg = `The \`resources\` directory does not exist inside the provided Server Data Folder:\n\`${dataPath}\``;
+ } else if (error.message?.includes('EACCES') || error.message?.includes('EPERM')) {
+ msg = `The \`resources\` directory is not accessible inside the provided Server Data Folder:\n\`${dataPath}\``;
+ }
+ throw new Error(msg);
+ }
+ return true;
+};
+
+
+/**
+ * Look for a potential server data folder in/around the provided path.
+ * Forgiving behavior:
+ * - Ignore trailing slashes, as well as fix backslashes
+ * - Check if its the parent folder
+ * - Check if its a sibling folder
+ * - Check if its a child folder
+ * - Check if current path is a resource folder deep inside a server data folder
+ */
+export const findPotentialServerDataPaths = async (initialPath: string) => {
+ const checkTarget = async (target: string) => {
+ try {
+ return await isValidServerDataPath(target);
+ } catch (error) {
+ return false;
+ }
+ };
+
+ //Recovery if parent folder
+ const parentPath = path.join(initialPath, '..');
+ const isParentPath = await checkTarget(parentPath);
+ if (isParentPath) return parentPath;
+
+ //Recovery if sibling folder
+ try {
+ const siblingPaths = await getPathSubdirs(parentPath);
+ for (const sibling of siblingPaths) {
+ const siblingPath = path.join(parentPath, sibling.name);
+ if (siblingPath === initialPath) continue;
+ if (await checkTarget(siblingPath)) return siblingPath;
+ }
+ } catch (error) { }
+
+ //Recovery if children folder
+ try {
+ const childPaths = await getPathSubdirs(initialPath);
+ for (const child of childPaths) {
+ const childPath = path.join(initialPath, child.name);
+ if (await checkTarget(childPath)) return childPath;
+ }
+ } catch (error) { }
+
+ //Recovery if current path is a resources folder
+ const resourceSplitAttempt = initialPath.split(/[/\\]resources(?:[/\\]?|$)/, 2);
+ if (resourceSplitAttempt.length === 2) {
+ const potentialServerDataPath = resourceSplitAttempt[0];
+ if (await checkTarget(potentialServerDataPath)) return potentialServerDataPath;
+ }
+
+ //Really couldn't find anything
+ return false;
+};
diff --git a/core/lib/got.ts b/core/lib/got.ts
new file mode 100644
index 0000000..c901eb3
--- /dev/null
+++ b/core/lib/got.ts
@@ -0,0 +1,12 @@
+import { txEnv, txHostConfig } from '@core/globalData';
+import got from 'got';
+
+export default got.extend({
+ timeout: {
+ request: 5000
+ },
+ headers: {
+ 'User-Agent': `txAdmin ${txEnv.txaVersion}`,
+ },
+ localAddress: txHostConfig.netInterface,
+});
diff --git a/core/lib/host/getHostUsage.ts b/core/lib/host/getHostUsage.ts
new file mode 100644
index 0000000..6e3d6ef
--- /dev/null
+++ b/core/lib/host/getHostUsage.ts
@@ -0,0 +1,60 @@
+const modulename = 'GetHostUsage';
+import os from 'node:os';
+import si from 'systeminformation';
+import { txEnv } from '@core/globalData';
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+
+//Const -hopefully
+const giga = 1024 * 1024 * 1024;
+const cpus = os.cpus();
+
+
+/**
+ * Get the host's current memory and CPU usage.
+ * NOTE: It was used by the hw stats on the sidebar
+ * Currently only in use by diagnostics page
+ */
+export default async () => {
+ const out = {
+ memory: { usage: 0, used: 0, total: 0 },
+ cpu: {
+ count: cpus.length,
+ usage: 0,
+ },
+ };
+
+ //Getting memory usage
+ try {
+ let free, total, used;
+ if (txEnv.isWindows) {
+ free = os.freemem() / giga;
+ total = os.totalmem() / giga;
+ used = total - free;
+ } else {
+ const memoryData = await si.mem();
+ free = memoryData.available / giga;
+ total = memoryData.total / giga;
+ used = memoryData.active / giga;
+ }
+ out.memory = {
+ used,
+ total,
+ usage: Math.round((used / total) * 100),
+ };
+ } catch (error) {
+ console.verbose.error('Failed to get memory usage.');
+ console.verbose.dir(error);
+ }
+
+ //Getting CPU usage
+ try {
+ const loads = await si.currentLoad();
+ out.cpu.usage = Math.round(loads.currentLoad);
+ } catch (error) {
+ console.verbose.error('Failed to get CPU usage.');
+ console.verbose.dir(error);
+ }
+
+ return out;
+};
diff --git a/core/lib/host/getOsDistro.js b/core/lib/host/getOsDistro.js
new file mode 100644
index 0000000..6841f74
--- /dev/null
+++ b/core/lib/host/getOsDistro.js
@@ -0,0 +1,97 @@
+const modulename = 'getOsDistro';
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+
+/*
+ NOTE: this is straight from @sindresorhus/windows-release, but with async functions.
+ I have windows-release dependency mostly just so I know when there are updates to it.
+*/
+import os from 'node:os';
+import execa from 'execa';
+
+// Reference: https://www.gaijin.at/en/lstwinver.php
+// Windows 11 reference: https://docs.microsoft.com/en-us/windows/release-health/windows11-release-information
+const names = new Map([
+ ['10.0.2', '11'], // It's unclear whether future Windows 11 versions will use this version scheme: https://github.com/sindresorhus/windows-release/pull/26/files#r744945281
+ ['10.0', '10'],
+ ['6.3', '8.1'],
+ ['6.2', '8'],
+ ['6.1', '7'],
+ ['6.0', 'Vista'],
+ ['5.2', 'Server 2003'],
+ ['5.1', 'XP'],
+ ['5.0', '2000'],
+ ['4.90', 'ME'],
+ ['4.10', '98'],
+ ['4.03', '95'],
+ ['4.00', '95'],
+]);
+
+async function windowsRelease(release) {
+ const version = /(\d+\.\d+)(?:\.(\d+))?/.exec(release || os.release());
+
+ if (release && !version) {
+ throw new Error('`release` argument doesn\'t match `n.n`');
+ }
+
+ let ver = version[1] || '';
+ const build = version[2] || '';
+
+ // Server 2008, 2012, 2016, and 2019 versions are ambiguous with desktop versions and must be detected at runtime.
+ // If `release` is omitted or we're on a Windows system, and the version number is an ambiguous version
+ // then use `wmic` to get the OS caption: https://msdn.microsoft.com/en-us/library/aa394531(v=vs.85).aspx
+ // If `wmic` is obsolete (later versions of Windows 10), use PowerShell instead.
+ // If the resulting caption contains the year 2008, 2012, 2016, 2019 or 2022, it is a server version, so return a server OS name.
+ if ((!release || release === os.release()) && ['6.1', '6.2', '6.3', '10.0'].includes(ver)) {
+ let stdout;
+ try {
+ const out = await execa('wmic', ['os', 'get', 'Caption']);
+ stdout = out.stdout || '';
+ } catch {
+ //NOTE: custom code to select the powershell path
+ //if systemroot/windir is not defined, just try "powershell" and hope for the best
+ const systemRoot = process.env?.SYSTEMROOT ?? process.env?.WINDIR ?? false;
+ const psBinary = systemRoot
+ ? `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell`
+ : 'powershell';
+ const out = await execa(psBinary, ['(Get-CimInstance -ClassName Win32_OperatingSystem).caption']);
+ stdout = out.stdout || '';
+ }
+
+ const year = (stdout.match(/2008|2012|2016|2019|2022/) || [])[0];
+
+ if (year) {
+ return `Server ${year}`;
+ }
+ }
+
+ // Windows 11
+ if (ver === '10.0' && build.startsWith('2')) {
+ ver = '10.0.2';
+ }
+
+ return names.get(ver);
+};
+
+
+/**
+ * Cache calculated os distro
+ */
+let _osDistro;
+export default async () => {
+ if (_osDistro) return _osDistro;
+
+ const osType = os.type();
+ if (osType == 'Linux') {
+ _osDistro = `${osType} ${os.release()}`;
+ } else {
+ try {
+ const distro = await windowsRelease();
+ _osDistro = `Windows ${distro}`;
+ } catch (error) {
+ console.warn(`Failed to detect windows version with error: ${error.message}`);
+ _osDistro = `Windows Unknown`;
+ }
+ }
+ return _osDistro;
+};
diff --git a/core/lib/host/isIpAddressLocal.ts b/core/lib/host/isIpAddressLocal.ts
new file mode 100644
index 0000000..580f491
--- /dev/null
+++ b/core/lib/host/isIpAddressLocal.ts
@@ -0,0 +1,29 @@
+const modulename = 'IpChecker';
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+
+const extendedAllowedLanIps: string[] = [];
+
+
+/**
+ * Return if the IP Address is a loopback interface, LAN, detected WAN or any other
+ * IP that is registered by the user via the forceInterface convar or config file.
+ *
+ * This is used to secure the webpipe auth and the rate limiter.
+ */
+export const isIpAddressLocal = (ipAddress: string): boolean => {
+ return (
+ /^(127\.|192\.168\.|10\.|::1|fd00::)/.test(ipAddress)
+ || extendedAllowedLanIps.includes(ipAddress)
+ )
+}
+
+
+/**
+ * Used to register a new LAN interface.
+ * Added automatically from TXHOST_INTERFACE and banner.js after detecting the WAN address.
+ */
+export const addLocalIpAddress = (ipAddress: string): void => {
+ // console.verbose.debug(`Adding local IP address: ${ipAddress}`);
+ extendedAllowedLanIps.push(ipAddress);
+}
diff --git a/core/lib/host/pidUsageTree.js b/core/lib/host/pidUsageTree.js
new file mode 100644
index 0000000..da0a3c5
--- /dev/null
+++ b/core/lib/host/pidUsageTree.js
@@ -0,0 +1,7 @@
+import pidtree from 'pidtree';
+import pidusage from 'pidusage';
+
+export default async (pid) => {
+ const pids = await pidtree(pid);
+ return await pidusage([pid, ...pids]);
+};
diff --git a/core/lib/misc.test.ts b/core/lib/misc.test.ts
new file mode 100644
index 0000000..2c8bca7
--- /dev/null
+++ b/core/lib/misc.test.ts
@@ -0,0 +1,238 @@
+import { test, expect, suite, it } from 'vitest';
+import * as misc from './misc';
+
+
+suite('parseSchedule', () => {
+ it('should parse a valid schedule', () => {
+ const result = misc.parseSchedule(['00:00', '00:15', '1:30', '12:30']);
+ expect(result.valid).toEqual([
+ { string: '00:00', hours: 0, minutes: 0 },
+ { string: '00:15', hours: 0, minutes: 15 },
+ { string: '01:30', hours: 1, minutes: 30 },
+ { string: '12:30', hours: 12, minutes: 30 },
+ ]);
+ expect(result.invalid).toEqual([]);
+ });
+
+ it('should let the average american type 24:00', () => {
+ const result = misc.parseSchedule(['24:00']);
+ expect(result.valid).toEqual([
+ { string: '00:00', hours: 0, minutes: 0 },
+ ]);
+ expect(result.invalid).toEqual([]);
+ });
+
+ it('should handle invalid stuff', () => {
+ const result = misc.parseSchedule(['12:34', 'invalid', '1030', '25:00', '1', '01', '']);
+ expect(result).toBeTruthy();
+ expect(result.valid).toEqual([
+ { string: '12:34', hours: 12, minutes: 34 },
+ ]);
+ expect(result.invalid).toEqual(['invalid', '1030', '25:00', '1', '01']);
+ });
+
+ it('should remove duplicates', () => {
+ const result = misc.parseSchedule(['02:00', '02:00', '05:55', '13:55']);
+ expect(result.valid).toEqual([
+ { string: '02:00', hours: 2, minutes: 0 },
+ { string: '05:55', hours: 5, minutes: 55 },
+ { string: '13:55', hours: 13, minutes: 55 },
+ ]);
+ expect(result.invalid).toEqual([]);
+ });
+
+ it('should sort the times', () => {
+ const result = misc.parseSchedule(['00:00', '00:01', '23:59', '01:01', '01:00']);
+ expect(result.valid).toEqual([
+ { string: '00:00', hours: 0, minutes: 0 },
+ { string: '00:01', hours: 0, minutes: 1 },
+ { string: '01:00', hours: 1, minutes: 0 },
+ { string: '01:01', hours: 1, minutes: 1 },
+ { string: '23:59', hours: 23, minutes: 59 },
+ ]);
+ expect(result.invalid).toEqual([]);
+ });
+});
+
+test('redactApiKeys', () => {
+ expect(misc.redactApiKeys('')).toBe('')
+ expect(misc.redactApiKeys('abc')).toBe('abc')
+
+ const example = `
+ sv_licenseKey cfxk_NYWn5555555500000000_2TLnnn
+ sv_licenseKey "cfxk_NYWn5555555500000000_2TLnnn"
+ sv_licenseKey 'cfxk_NYWn5555555500000000_2TLnnn'
+
+ steam_webApiKey A2FAF8CF83B87E795555555500000000
+ sv_tebexSecret 238a98bec4c0353fee20ac865555555500000000
+ rcon_password a5555555500000000
+ rcon_password "a5555555500000000"
+ rcon_password 'a5555555500000000'
+ mysql_connection_string "mysql://root:root@localhost:3306/txAdmin"
+ https://discord.com/api/webhooks/33335555555500000000/xxxxxxxxxxxxxxxxxxxx5555555500000000`;
+
+ const result = misc.redactApiKeys(example)
+ expect(result).toContain('[REDACTED]');
+ expect(result).toContain('2TLnnn');
+ expect(result).not.toContain('5555555500000000');
+ expect(result).not.toContain('mysql://');
+})
+
+
+suite('redactStartupSecrets', () => {
+ const redactedString = '[REDACTED]';
+ it('should return an empty array when given an empty array', () => {
+ expect(misc.redactStartupSecrets([])).toEqual([]);
+ });
+
+ it('should return the same array if no redaction keys are present', () => {
+ const args = ['node', 'script.js', '--help'];
+ expect(misc.redactStartupSecrets(args)).toEqual(args);
+ });
+
+ it('should redact a sv_licenseKey secret correctly', () => {
+ const args = ['sv_licenseKey', 'cfxk_12345_secret'];
+ // The regex captures "secret" and returns "[REDACTED cfxk...secret]"
+ const expected = ['sv_licenseKey', '[REDACTED cfxk...secret]'];
+ expect(misc.redactStartupSecrets(args)).toEqual(expected);
+ });
+
+ it('should not redact sv_licenseKey secret if the secret does not match the regex', () => {
+ const args = ['sv_licenseKey', 'invalidsecret'];
+ const expected = ['sv_licenseKey', 'invalidsecret'];
+ expect(misc.redactStartupSecrets(args)).toEqual(expected);
+ });
+
+ it('should redact steam_webApiKey secret correctly', () => {
+ const validKey = 'a'.repeat(32);
+ const args = ['steam_webApiKey', validKey];
+ const expected = ['steam_webApiKey', redactedString];
+ expect(misc.redactStartupSecrets(args)).toEqual(expected);
+ });
+
+ it('should redact sv_tebexSecret secret correctly', () => {
+ const validSecret = 'b'.repeat(40);
+ const args = ['sv_tebexSecret', validSecret];
+ const expected = ['sv_tebexSecret', redactedString];
+ expect(misc.redactStartupSecrets(args)).toEqual(expected);
+ });
+
+ it('should redact rcon_password secret correctly', () => {
+ const args = ['rcon_password', 'mysecretpassword'];
+ const expected = ['rcon_password', redactedString];
+ expect(misc.redactStartupSecrets(args)).toEqual(expected);
+ });
+
+ it('should redact mysql_connection_string secret correctly', () => {
+ const args = [
+ 'mysql_connection_string',
+ 'Server=myServerAddress;Database=myDataBase;User Id=myUsername;Password=myPassword;',
+ ];
+ const expected = ['mysql_connection_string', redactedString];
+ expect(misc.redactStartupSecrets(args)).toEqual(expected);
+ });
+
+ it('should handle multiple redactions in a single array', () => {
+ const validSteamKey = 'c'.repeat(32);
+ const args = [
+ 'sv_licenseKey', 'cfxk_12345_abcdef',
+ 'someOtherArg', 'value',
+ 'steam_webApiKey', validSteamKey,
+ ];
+ const expected = [
+ 'sv_licenseKey', '[REDACTED cfxk...abcdef]',
+ 'someOtherArg', 'value',
+ 'steam_webApiKey', redactedString,
+ ];
+ expect(misc.redactStartupSecrets(args)).toEqual(expected);
+ });
+
+ it('should handle case-insensitive key matching', () => {
+ const args = ['SV_LICENSEKEY', 'cfxk_12345_SECRET'];
+ const expected = ['SV_LICENSEKEY', '[REDACTED cfxk...SECRET]'];
+ expect(misc.redactStartupSecrets(args)).toEqual(expected);
+ });
+
+ it('should leave a key unchanged if it is the last element', () => {
+ const args = ['sv_licenseKey'];
+ const expected = ['sv_licenseKey'];
+ expect(misc.redactStartupSecrets(args)).toEqual(expected);
+ });
+
+ it('should handle rules without regex', () => {
+ const args = ['rcon_password', 'whatever'];
+ const expected = ['rcon_password', redactedString];
+ expect(misc.redactStartupSecrets(args)).toEqual(expected);
+ });
+
+ it('should handle a real example', () => {
+ const args = [
+ "+setr", "txAdmin-debugMode", "true",
+ "+set", "tx2faSecret", "whatever",
+ "+set", "sv_licenseKey", "cfxk_xxxxxxxxxxxxxxxxxxxx_yyyyy",
+ "+set", "onesync", "enabled",
+ "+set", "sv_enforceGameBuild", "2545",
+ ];
+ const expected = [
+ "+setr", "txAdmin-debugMode", "true",
+ "+set", "tx2faSecret", redactedString,
+ "+set", "sv_licenseKey", "[REDACTED cfxk...yyyyy]",
+ "+set", "onesync", "enabled",
+ "+set", "sv_enforceGameBuild", "2545",
+ ];
+ expect(misc.redactStartupSecrets(args)).toEqual(expected);
+ });
+
+ it('should redact discord webhooks', () => {
+ const args = [
+ "aaa",
+ "https://discord.com/api/webhooks/33335555555500000000/xxxxxxxxxxxxxxxxxxxx5555555500000000",
+ "bbb",
+ ];
+ const expected = [
+ "aaa",
+ "https://discord.com/api/webhooks/[REDACTED]/[REDACTED]",
+ "bbb",
+ ];
+ expect(misc.redactStartupSecrets(args)).toEqual(expected);
+ });
+});
+
+
+test('now', () => {
+ const result = misc.now();
+ expect(typeof result).toBe('number');
+ expect(result.toString().length).toBe(10);
+ expect(result.toString()).not.toContain('.');
+ expect(result.toString()).not.toContain('-');
+});
+
+test('anyUndefined', () => {
+ expect(misc.anyUndefined(undefined, 'test')).toBe(true);
+ expect(misc.anyUndefined('test', 'xxxx')).toBe(false);
+ expect(misc.anyUndefined(undefined, undefined)).toBe(true);
+});
+
+test('calcExpirationFromDuration', () => {
+ const currTs = misc.now();
+ let result = misc.calcExpirationFromDuration('1 hour');
+ expect(result?.duration).toBe(3600);
+ expect(result?.expiration).toBe(currTs + 3600);
+
+ result = misc.calcExpirationFromDuration('1 hours');
+ expect(result?.duration).toBe(3600);
+
+ result = misc.calcExpirationFromDuration('permanent');
+ expect(result?.expiration).toBe(false);
+
+ expect(() => misc.calcExpirationFromDuration('x day')).toThrowError('duration number');
+ expect(() => misc.calcExpirationFromDuration('')).toThrowError('duration number');
+ expect(() => misc.calcExpirationFromDuration('-1 day')).toThrowError('duration number');
+});
+
+test('parseLimitedFloat', () => {
+ expect(misc.parseLimitedFloat('123.4567899999')).toBe(123.45679);
+ expect(misc.parseLimitedFloat(123.4567899999)).toBe(123.45679);
+ expect(misc.parseLimitedFloat(123.4567899999, 2)).toBe(123.46);
+ expect(misc.parseLimitedFloat(0.1 + 0.2)).toBe(0.3);
+});
diff --git a/core/lib/misc.ts b/core/lib/misc.ts
new file mode 100644
index 0000000..d9f1867
--- /dev/null
+++ b/core/lib/misc.ts
@@ -0,0 +1,301 @@
+import chalk from 'chalk';
+import dateFormat from 'dateformat';
+import humanizeDuration, { HumanizerOptions } from 'humanize-duration';
+import { DeepReadonly } from 'utility-types';
+
+export const regexHoursMinutes = /^(?[01]?[0-9]|2[0-4]):(?[0-5][0-9])$/;
+
+
+/**
+ * Extracts hours and minutes from an string containing times
+ */
+export const parseSchedule = (scheduleTimes: string[]) => {
+ const valid: {
+ string: string;
+ hours: number;
+ minutes: number;
+ }[] = [];
+ const invalid = [];
+ for (const timeInput of scheduleTimes) {
+ if (typeof timeInput !== 'string') continue;
+ const timeTrim = timeInput.trim();
+ if (!timeTrim.length) continue;
+
+ const m = timeTrim.match(regexHoursMinutes);
+ if (m && m.groups?.hours && m.groups?.minutes) {
+ if (m.groups.hours === '24') m.groups.hours = '00'; //Americans, amirite?!?!
+ const timeStr = m.groups.hours.padStart(2, '0') + ':' + m.groups.minutes.padStart(2, '0');
+ if (valid.some(item => item.string === timeStr)) continue;
+ valid.push({
+ string: timeStr,
+ hours: parseInt(m.groups.hours),
+ minutes: parseInt(m.groups.minutes),
+ });
+ } else {
+ invalid.push(timeTrim);
+ }
+ }
+ valid.sort((a, b) => {
+ return a.hours - b.hours || a.minutes - b.minutes;
+ });
+ return { valid, invalid };
+};
+
+
+/**
+ * Redacts known keys and tokens from a string
+ * @deprecated Use redactApiKeysArr instead
+ */
+export const redactApiKeys = (src: string) => {
+ if (typeof src !== 'string' || !src.length) return src;
+ return src
+ .replace(/licenseKey\s+["']?cfxk_\w{1,60}_(\w+)["']?.?$/gim, 'licenseKey [REDACTED cfxk...$1]')
+ .replace(/steam_webApiKey\s+["']?\w{32}["']?.?$/gim, 'steam_webApiKey [REDACTED]')
+ .replace(/sv_tebexSecret\s+["']?\w{40}["']?.?$/gim, 'sv_tebexSecret [REDACTED]')
+ .replace(/rcon_password\s+["']?[^"']+["']?.?$/gim, 'rcon_password [REDACTED]')
+ .replace(/mysql_connection_string\s+["']?[^"']+["']?.?$/gim, 'mysql_connection_string [REDACTED]')
+ .replace(/discord\.com\/api\/webhooks\/\d{17,20}\/[\w\-_./=]{10,}(.*)/gim, 'discord.com/api/webhooks/[REDACTED]/[REDACTED]');
+};
+
+
+/**
+ * Redacts known keys and tokens from an array of startup arguments.
+ */
+export const redactStartupSecrets = (args: string[]): string[] => {
+ if (!Array.isArray(args) || args.length === 0) return args;
+
+ const redactionRules: ApiRedactionRuleset = {
+ sv_licenseKey: {
+ regex: /^cfxk_\w{1,60}_(\w+)$/i,
+ replacement: (_match, p1) => `[REDACTED cfxk...${p1}]`,
+ },
+ steam_webApiKey: {
+ regex: /^\w{32}$/i,
+ replacement: '[REDACTED]',
+ },
+ sv_tebexSecret: {
+ regex: /^\w{40}$/i,
+ replacement: '[REDACTED]',
+ },
+ rcon_password: {
+ replacement: '[REDACTED]',
+ },
+ mysql_connection_string: {
+ replacement: '[REDACTED]',
+ },
+ tx2faSecret: {
+ replacement: '[REDACTED]',
+ },
+ 'txAdmin-luaComToken': {
+ replacement: '[REDACTED]',
+ },
+ };
+
+
+ let outArgs: string[] = [];
+ for (let i = 0; i < args.length; i++) {
+ const currElem = args[i];
+ const currElemLower = currElem.toLocaleLowerCase();
+ const ruleMatchingPrefix = Object.keys(redactionRules).find((key) =>
+ currElemLower.includes(key.toLocaleLowerCase())
+ );
+ // If no rule matches or there is no subsequent element, just push the current element.
+ if (!ruleMatchingPrefix || i + 1 >= args.length) {
+ outArgs.push(currElem);
+ continue;
+ }
+ const rule = redactionRules[ruleMatchingPrefix];
+ const nextElem = args[i + 1];
+ // If the secret doesn't match the expected regex, treat it as a normal argument.
+ if (rule.regex && !nextElem.match(rule.regex)) {
+ outArgs.push(currElem);
+ continue;
+ }
+ // Push the key and then the redacted secret.
+ outArgs.push(currElem);
+ if (typeof rule.replacement === 'string') {
+ outArgs.push(rule.replacement);
+ } else if (rule.regex) {
+ outArgs.push(nextElem.replace(rule.regex, rule.replacement));
+ }
+ // Skip the secret value we just processed.
+ i++;
+ }
+
+ //Apply standalone redaction rules
+ outArgs = outArgs.map((arg) => arg
+ .replace(/discord\.com\/api\/webhooks\/\d{17,20}\/[\w\-_./=]{10,}(.*)/gim, 'discord.com/api/webhooks/[REDACTED]/[REDACTED]')
+ );
+
+ if (args.length !== outArgs.length) {
+ throw new Error('Input and output lengths are different after redaction.');
+ }
+ return outArgs;
+};
+
+type ApiRedactionRule = {
+ regex?: RegExp;
+ replacement: string | ((...args: any[]) => string);
+};
+
+type ApiRedactionRuleset = Record;
+
+
+/**
+ * Returns the unix timestamp in seconds.
+ */
+export const now = () => Math.round(Date.now() / 1000);
+
+
+/**
+ * Returns the current time in HH:MM:ss format
+ */
+export const getTimeHms = (time?: string | number | Date) => dateFormat(time ?? new Date(), 'HH:MM:ss');
+
+
+/**
+ * Returns the current time in filename-friendly format
+ */
+export const getTimeFilename = (time?: string | number | Date) => dateFormat(time ?? new Date(), 'yyyy-mm-dd_HH-MM-ss');
+
+
+/**
+ * Converts a number of milliseconds to english words
+ * Accepts a humanizeDuration config object
+ * eg: msToDuration(ms, { units: ['h', 'm'] });
+ */
+export const msToDuration = humanizeDuration.humanizer({
+ round: true,
+ units: ['d', 'h', 'm'],
+ fallbacks: ['en'],
+} satisfies HumanizerOptions);
+
+
+/**
+ * Converts a number of milliseconds to short-ish english words
+ */
+export const msToShortishDuration = humanizeDuration.humanizer({
+ round: true,
+ units: ['d', 'h', 'm'],
+ largest: 2,
+ language: 'shortishEn',
+ languages: {
+ shortishEn: {
+ y: (c) => 'year' + (c === 1 ? '' : 's'),
+ mo: (c) => 'month' + (c === 1 ? '' : 's'),
+ w: (c) => 'week' + (c === 1 ? '' : 's'),
+ d: (c) => 'day' + (c === 1 ? '' : 's'),
+ h: (c) => 'hr' + (c === 1 ? '' : 's'),
+ m: (c) => 'min' + (c === 1 ? '' : 's'),
+ s: (c) => 'sec' + (c === 1 ? '' : 's'),
+ ms: (c) => 'ms',
+ },
+ },
+});
+
+
+/**
+ * Converts a number of milliseconds to shortest english representation possible
+ */
+export const msToShortestDuration = humanizeDuration.humanizer({
+ round: true,
+ units: ['d', 'h', 'm', 's'],
+ delimiter: '',
+ spacer: '',
+ largest: 2,
+ language: 'shortestEn',
+ languages: {
+ shortestEn: {
+ y: () => 'y',
+ mo: () => 'mo',
+ w: () => 'w',
+ d: () => 'd',
+ h: () => 'h',
+ m: () => 'm',
+ s: () => 's',
+ ms: () => 'ms',
+ },
+ },
+});
+
+
+/**
+ * Shorthand to convert seconds to the shortest english representation possible
+ */
+export const secsToShortestDuration = (ms: number, options?: humanizeDuration.Options) => {
+ return msToShortestDuration(ms * 1000, options);
+};
+
+
+/**
+ * Returns false if any argument is undefined
+ */
+export const anyUndefined = (...args: any) => [...args].some((x) => (typeof x === 'undefined'));
+
+
+/**
+ * Calculates expiration and duration from a ban duration string like "1 day"
+ */
+export const calcExpirationFromDuration = (inputDuration: string) => {
+ let expiration;
+ let duration;
+ if (inputDuration === 'permanent') {
+ expiration = false as const;
+ } else {
+ const [multiplierInput, unit] = inputDuration.split(/\s+/);
+ const multiplier = parseInt(multiplierInput);
+ if (isNaN(multiplier) || multiplier < 1) {
+ throw new Error(`The duration number must be at least 1.`);
+ }
+
+ if (unit.startsWith('hour')) {
+ duration = multiplier * 3600;
+ } else if (unit.startsWith('day')) {
+ duration = multiplier * 86400;
+ } else if (unit.startsWith('week')) {
+ duration = multiplier * 604800;
+ } else if (unit.startsWith('month')) {
+ duration = multiplier * 2592000; //30 days
+ } else {
+ throw new Error(`Invalid ban duration. Supported units: hours, days, weeks, months`);
+ }
+ expiration = now() + duration;
+ }
+
+ return { expiration, duration };
+};
+
+
+/**
+ * Parses a number or string to a float with a limited precision.
+ */
+export const parseLimitedFloat = (src: number | string, precision = 6) => {
+ const srcAsNum = typeof src === 'string' ? parseFloat(src) : src;
+ return parseFloat(srcAsNum.toFixed(precision));
+}
+
+
+/**
+ * Deeply freezes an object and all its nested properties
+ */
+export const deepFreeze = >(obj: T) => {
+ Object.freeze(obj);
+ Object.getOwnPropertyNames(obj).forEach((prop) => {
+ if (Object.prototype.hasOwnProperty.call(obj, prop)
+ && obj[prop] !== null
+ && (typeof obj[prop] === 'object' || typeof obj[prop] === 'function')
+ && !Object.isFrozen(obj[prop])
+ ) {
+ deepFreeze(obj[prop] as object);
+ }
+ });
+ return obj;
+ //FIXME: using DeepReadonly will cause ts errors in ConfigStore
+ // return obj as DeepReadonly;
+};
+
+
+/**
+ * Returns a chalk.inverse of a string with a 1ch padding
+ */
+export const chalkInversePad = (str: string) => chalk.inverse(` ${str} `);
diff --git a/core/lib/player/idUtils.test.ts b/core/lib/player/idUtils.test.ts
new file mode 100644
index 0000000..0ddd2b7
--- /dev/null
+++ b/core/lib/player/idUtils.test.ts
@@ -0,0 +1,57 @@
+import { test, expect, suite, it } from 'vitest';
+import * as idUtils from './idUtils';
+
+
+test('parsePlayerId', () => {
+ let result = idUtils.parsePlayerId('FIVEM:555555');
+ expect(result.isIdValid).toBe(true);
+ expect(result.idType).toBe('fivem');
+ expect(result.idValue).toBe('555555');
+ expect(result.idlowerCased).toBe('fivem:555555');
+
+ result = idUtils.parsePlayerId('fivem:xxxxx');
+ expect(result.isIdValid).toBe(false);
+});
+
+test('parsePlayerIds', () => {
+ const result = idUtils.parsePlayerIds(['fivem:555555', 'fivem:xxxxx']);
+ expect(result.validIdsArray).toEqual(['fivem:555555']);
+ expect(result.invalidIdsArray).toEqual(['fivem:xxxxx']);
+ expect(result.validIdsObject?.fivem).toBe('555555');
+});
+
+test('filterPlayerHwids', () => {
+ const result = idUtils.filterPlayerHwids([
+ '5:55555555000000002d267c6638c8873d55555555000000005555555500000000',
+ 'invalidHwid'
+ ]);
+ expect(result.validHwidsArray).toEqual(['5:55555555000000002d267c6638c8873d55555555000000005555555500000000']);
+ expect(result.invalidHwidsArray).toEqual(['invalidHwid']);
+});
+
+test('parseLaxIdsArrayInput', () => {
+ const result = idUtils.parseLaxIdsArrayInput('55555555000000009999, steam:1100001ffffffff, invalid');
+ expect(result.validIds).toEqual(['discord:55555555000000009999', 'steam:1100001ffffffff']);
+ expect(result.invalids).toEqual(['invalid']);
+});
+
+test('getIdFromOauthNameid', () => {
+ expect(idUtils.getIdFromOauthNameid('https://forum.cfx.re/internal/user/555555')).toBe('fivem:555555');
+ expect(idUtils.getIdFromOauthNameid('xxxxx')).toBe(false);
+});
+
+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');
+
+ // 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');
+});
diff --git a/core/lib/player/idUtils.ts b/core/lib/player/idUtils.ts
new file mode 100644
index 0000000..13712f3
--- /dev/null
+++ b/core/lib/player/idUtils.ts
@@ -0,0 +1,159 @@
+import type { PlayerIdsObjectType } from "@shared/otherTypes";
+import consts from "@shared/consts";
+
+
+/**
+ * Validates a single identifier and return its parts lowercased
+ */
+export const parsePlayerId = (idString: string) => {
+ if (typeof idString !== 'string') {
+ return { isIdValid: false, idType: null, idValue: null, idlowerCased: null };
+ }
+
+ const idlowerCased = idString.toLocaleLowerCase();
+ const [idType, idValue] = idlowerCased.split(':', 2);
+ if (idType === "ip") {
+ return { isIdValid: false, idType, idValue, idlowerCased };
+ }
+ return { isIdValid: true, idType, idValue, idlowerCased };
+}
+
+
+/**
+ * Get valid, invalid and license identifier from array of ids
+ */
+export const parsePlayerIds = (ids: string[]) => {
+ let invalidIdsArray: string[] = [];
+ let validIdsArray: string[] = [];
+ const validIdsObject: PlayerIdsObjectType = {}
+
+ for (const idString of ids) {
+ if (typeof idString !== 'string') continue;
+ const { isIdValid, idType, idValue } = parsePlayerId(idString);
+ if (isIdValid) {
+ validIdsArray.push(idString);
+ validIdsObject[idType as keyof PlayerIdsObjectType] = idValue;
+ } else {
+ invalidIdsArray.push(idString);
+ }
+ }
+
+ return { invalidIdsArray, validIdsArray, validIdsObject };
+}
+
+
+/**
+ * Get valid and invalid player HWIDs
+ */
+export const filterPlayerHwids = (hwids: string[]) => {
+ let invalidHwidsArray: string[] = [];
+ let validHwidsArray: string[] = [];
+
+ for (const hwidString of hwids) {
+ if (typeof hwidString !== 'string') continue;
+ if (consts.regexValidHwidToken.test(hwidString)) {
+ validHwidsArray.push(hwidString);
+ } else {
+ invalidHwidsArray.push(hwidString);
+ }
+ }
+
+ return { invalidHwidsArray, validHwidsArray };
+}
+
+
+/**
+ * Attempts to parse a user-provided string into an array of valid identifiers.
+ * This function is lenient and will attempt to parse any string into an array of valid identifiers.
+ * For non-prefixed ids, it will attempt to parse it as discord, fivem, steam, or license.
+ * Returns an array of valid ids/hwids, and array of invalid identifiers.
+ *
+ * Stricter version of this function is parsePlayerIds
+ */
+export const parseLaxIdsArrayInput = (fullInput: string) => {
+ const validIds: string[] = [];
+ const validHwids: string[] = [];
+ const invalids: string[] = [];
+
+ if (typeof fullInput !== 'string') {
+ return { validIds, validHwids, invalids };
+ }
+ const inputs = fullInput.toLowerCase().split(/[,;\s]+/g).filter(Boolean);
+
+ for (const input of inputs) {
+ if (input.includes(':')) {
+ if (consts.regexValidHwidToken.test(input)) {
+ validHwids.push(input);
+ } else if (Object.values(consts.validIdentifiers).some((regex) => regex.test(input))) {
+ validIds.push(input);
+ } else {
+ const [type, value] = input.split(':', 1);
+ if (consts.validIdentifierParts[type as keyof typeof consts.validIdentifierParts]?.test(value)) {
+ validIds.push(input);
+ } else {
+ invalids.push(input);
+ }
+ }
+ } else if (consts.validIdentifierParts.discord.test(input)) {
+ validIds.push(`discord:${input}`);
+ } else if (consts.validIdentifierParts.fivem.test(input)) {
+ validIds.push(`fivem:${input}`);
+ } else if (consts.validIdentifierParts.license.test(input)) {
+ validIds.push(`license:${input}`);
+ } else if (consts.validIdentifierParts.steam.test(input)) {
+ validIds.push(`steam:${input}`);
+ } else {
+ invalids.push(input);
+ }
+ }
+
+ return { validIds, validHwids, invalids };
+}
+
+
+/**
+ * Extracts the fivem:xxxxxx identifier from the nameid field from the userInfo oauth response.
+ * Example: https://forum.cfx.re/internal/user/271816 -> fivem:271816
+ */
+export const getIdFromOauthNameid = (nameid: string) => {
+ try {
+ const res = /\/user\/(\d{1,8})/.exec(nameid);
+ //@ts-expect-error
+ return `fivem:${res[1]}`;
+ } catch (error) {
+ return false;
+ }
+}
+
+
+/**
+ * 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(', ');
+ return `[${shortList}]`;
+}
diff --git a/core/lib/player/playerClasses.ts b/core/lib/player/playerClasses.ts
new file mode 100644
index 0000000..bcbd248
--- /dev/null
+++ b/core/lib/player/playerClasses.ts
@@ -0,0 +1,365 @@
+const modulename = 'Player';
+import cleanPlayerName from '@shared/cleanPlayerName';
+import { DatabaseActionWarnType, DatabasePlayerType, DatabaseWhitelistApprovalsType } from '@modules/Database/databaseTypes';
+import { cloneDeep, union } from 'lodash-es';
+import { now } from '@lib/misc';
+import { parsePlayerIds } from '@lib/player/idUtils';
+import consoleFactory from '@lib/console';
+import consts from '@shared/consts';
+import type FxPlayerlist from '@modules/FxPlayerlist';
+const console = consoleFactory(modulename);
+
+
+/**
+ * Base class for ServerPlayer and DatabasePlayer.
+ * NOTE: player classes are responsible to every and only business logic regarding the player object in the database.
+ * In the future, when actions become part of the player object, also add them to these classes.
+ */
+export class BasePlayer {
+ displayName: string = 'unknown';
+ pureName: string = 'unknown';
+ ids: string[] = [];
+ hwids: string[] = [];
+ license: null | string = null; //extracted for convenience
+ dbData: false | DatabasePlayerType = false;
+ isConnected: boolean = false;
+
+ constructor(readonly uniqueId: Symbol) { }
+
+ /**
+ * Mutates the database data based on a source object to be applied
+ * FIXME: if this is called for a disconnected ServerPlayer, it will not clean after 120s
+ */
+ protected mutateDbData(srcData: object) {
+ if (!this.license) throw new Error(`cannot mutate database for a player that has no license`);
+ this.dbData = txCore.database.players.update(this.license, srcData, this.uniqueId);
+ }
+
+ /**
+ * Returns all available identifiers (current+db)
+ */
+ getAllIdentifiers() {
+ if (this.dbData && this.dbData.ids) {
+ return union(this.ids, this.dbData.ids);
+ } else {
+ return [...this.ids];
+ }
+ }
+
+ /**
+ * Returns all available hardware identifiers (current+db)
+ */
+ getAllHardwareIdentifiers() {
+ if (this.dbData && this.dbData.hwids) {
+ return union(this.hwids, this.dbData.hwids);
+ } else {
+ return [...this.hwids];
+ }
+ }
+
+ /**
+ * Returns all actions related to all available ids
+ * NOTE: theoretically ServerPlayer.setupDatabaseData() guarantees that DatabasePlayer.dbData.ids array
+ * 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()
+ );
+ }
+
+ /**
+ * Saves notes for this player.
+ * NOTE: Techinically, we should be checking this.isRegistered, but not available in BasePlayer
+ */
+ setNote(text: string, author: string) {
+ if (!this.license) throw new Error(`cannot save notes for a player that has no license`);
+ this.mutateDbData({
+ notes: {
+ text,
+ lastAdmin: author,
+ tsLastEdit: now(),
+ }
+ });
+ }
+
+ /**
+ * Saves the whitelist status for this player
+ * NOTE: Techinically, we should be checking this.isRegistered, but not available in BasePlayer
+ */
+ setWhitelist(enabled: boolean) {
+ if (!this.license) throw new Error(`cannot set whitelist status for a player that has no license`);
+ this.mutateDbData({
+ tsWhitelisted: enabled ? now() : undefined,
+ });
+
+ //Remove entries from whitelistApprovals & whitelistRequests
+ const allIdsFilter = (x: DatabaseWhitelistApprovalsType) => {
+ return this.ids.includes(x.identifier);
+ }
+ txCore.database.whitelist.removeManyApprovals(allIdsFilter);
+ txCore.database.whitelist.removeManyRequests({ license: this.license });
+ }
+}
+
+
+type PlayerDataType = {
+ name: string,
+ ids: string[],
+ hwids: string[],
+}
+
+/**
+ * Class to represent a player that is or was connected to the currently running server process.
+ */
+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 isRegistered: boolean;
+ readonly #minuteCronInterval?: ReturnType;
+ // #offlineDbDataCacheTimeout?: ReturnType;
+
+ constructor(
+ netid: number,
+ playerData: PlayerDataType,
+ fxPlayerlist: FxPlayerlist
+ ) {
+ super(Symbol(`netid${netid}`));
+ this.#fxPlayerlist = fxPlayerlist;
+ this.netid = netid;
+ this.isConnected = true;
+ if (
+ playerData === null
+ || typeof playerData !== 'object'
+ || typeof playerData.name !== 'string'
+ || !Array.isArray(playerData.ids)
+ || !Array.isArray(playerData.hwids)
+ ) {
+ throw new Error(`invalid player data`);
+ }
+
+ //Processing identifiers
+ //NOTE: ignoring IP completely
+ const { validIdsArray, validIdsObject } = parsePlayerIds(playerData.ids);
+ this.license = validIdsObject.license;
+ this.ids = validIdsArray;
+ this.hwids = playerData.hwids.filter(x => {
+ return typeof x === 'string' && consts.regexValidHwidToken.test(x);
+ });
+
+ //Processing player name
+ const { displayName, pureName } = cleanPlayerName(playerData.name);
+ this.displayName = displayName;
+ this.pureName = pureName;
+
+ //If this player is eligible to be on the database
+ if (this.license) {
+ this.#setupDatabaseData();
+ this.isRegistered = !!this.dbData;
+ this.#minuteCronInterval = setInterval(this.#minuteCron.bind(this), 60_000);
+ } else {
+ this.isRegistered = false;
+ }
+ console.log(167, this.isRegistered)
+ }
+
+
+ /**
+ * Registers or retrieves the player data from the database.
+ * NOTE: if player has license, we are guaranteeing license will be added to the database ids array
+ */
+ #setupDatabaseData() {
+ if (!this.license || !this.isConnected) return;
+
+ //Make sure the database is ready - this should be impossible
+ if (!txCore.database.isReady) {
+ console.error(`Players database not yet ready, cannot read db status for player id ${this.displayName}.`);
+ return;
+ }
+
+ //Check if player is already on the database
+ try {
+ const dbPlayer = txCore.database.players.findOne(this.license);
+ if (dbPlayer) {
+ //Updates database data
+ this.dbData = dbPlayer;
+ this.mutateDbData({
+ displayName: this.displayName,
+ pureName: this.pureName,
+ tsLastConnection: this.tsConnected,
+ ids: union(dbPlayer.ids, this.ids),
+ hwids: union(dbPlayer.hwids, this.hwids),
+ });
+ } else {
+ //Register player to the database
+ console.log(`Registering '${this.displayName}' to players database.`);
+ const toRegister = {
+ license: this.license,
+ ids: this.ids,
+ hwids: this.hwids,
+ displayName: this.displayName,
+ pureName: this.pureName,
+ playTime: 0,
+ tsLastConnection: this.tsConnected,
+ tsJoined: this.tsConnected,
+ };
+ txCore.database.players.register(toRegister);
+ this.dbData = toRegister;
+ console.verbose.ok(`Adding '${this.displayName}' to players database.`);
+
+ }
+ setImmediate(this.#sendInitialData.bind(this));
+ } catch (error) {
+ console.error(`Failed to load/register player ${this.displayName} from/to the database with error:`);
+ console.dir(error);
+ }
+ }
+
+ /**
+ * Prepares the initial player data and reports to FxPlayerlist, which will dispatch to the server via command.
+ * TODO: adapt to be used for admin auth and player tags.
+ */
+ #sendInitialData() {
+ if (!this.isRegistered) return;
+ if (!this.dbData) throw new Error(`cannot send initial data for a player that has no dbData`);
+
+ let oldestPendingWarn: undefined | DatabaseActionWarnType;
+ const actionHistory = this.getHistory();
+ for (const action of actionHistory) {
+ if (action.type !== 'warn' || action.revocation.timestamp !== null) continue;
+ if (!action.acked) {
+ oldestPendingWarn = action;
+ break;
+ }
+ }
+
+ if (oldestPendingWarn) {
+ this.#fxPlayerlist.dispatchInitialPlayerData(this.netid, oldestPendingWarn);
+ }
+ }
+
+ /**
+ * Sets the dbData.
+ * Used when some other player instance mutates the database and we need to sync all players
+ * with the same license.
+ */
+ syncUpstreamDbData(srcData: DatabasePlayerType) {
+ this.dbData = cloneDeep(srcData)
+ }
+
+ /**
+ * Returns a clone of this.dbData.
+ * If the data is not available, it means the player was disconnected and dbData wiped to save memory,
+ * so start an 120s interval to wipe it from memory again. This period can be considered a "cache"
+ * FIXME: review dbData optimization, 50k players would be up to 50mb
+ */
+ getDbData() {
+ if (this.dbData) {
+ return cloneDeep(this.dbData);
+ } else if (this.license && this.isRegistered) {
+ const dbPlayer = txCore.database.players.findOne(this.license);
+ if (!dbPlayer) return false;
+
+ this.dbData = dbPlayer;
+ // clearTimeout(this.#offlineDbDataCacheTimeout); //maybe not needed?
+ // this.#offlineDbDataCacheTimeout = setTimeout(() => {
+ // this.dbData = false;
+ // }, 120_000);
+ return cloneDeep(this.dbData);
+ } else {
+ return false;
+ }
+ }
+
+
+ /**
+ * Updates dbData play time every minute
+ */
+ #minuteCron() {
+ //FIXME: maybe use UIntXarray or mnemonist.Uint16Vector circular buffers to save memory
+ //TODO: rough draft of a playtime tracking system written before note above
+ // let list: [day: string, mins: number][] = [];
+ // const today = new Date;
+ // const currDay = today.toISOString().split('T')[0];
+ // if(!list.length){
+ // list.push([currDay, 1]);
+ // return;
+ // }
+ // if(list.at(-1)![0] === currDay){
+ // list.at(-1)![1]++;
+ // } else {
+ // //FIXME: move this cutoff to a const in the database or playerlist manager
+ // const cutoffTs = today.setUTCHours(0, 0, 0, 0) - 1000 * 60 * 60 * 24 * 28;
+ // const cutoffIndex = list.findIndex(x => new Date(x[0]).getTime() < cutoffTs);
+ // list = list.slice(cutoffIndex);
+ // list.push([currDay, 1]);
+ // }
+
+
+ if (!this.dbData || !this.isConnected) return;
+ try {
+ this.mutateDbData({ playTime: this.dbData.playTime + 1 });
+ } catch (error) {
+ console.warn(`Failed to update playtime for player ${this.displayName}:`);
+ console.dir(error);
+ }
+ }
+
+
+ /**
+ * Marks this player as disconnected, clears dbData (mem optimization) and clears minute cron
+ */
+ disconnect() {
+ this.isConnected = false;
+ // this.dbData = false;
+ clearInterval(this.#minuteCronInterval);
+ }
+}
+
+
+/**
+ * Class to represent players stored in the database.
+ */
+export class DatabasePlayer extends BasePlayer {
+ readonly isRegistered = true; //no need to check because otherwise constructor throws
+
+ constructor(license: string, srcPlayerData?: DatabasePlayerType) {
+ super(Symbol(`db${license}`));
+ if (typeof license !== 'string') {
+ throw new Error(`invalid player license`);
+ }
+
+ //Set dbData either from constructor params, or from querying the database
+ if (srcPlayerData) {
+ this.dbData = srcPlayerData;
+ } else {
+ const foundData = txCore.database.players.findOne(license);
+ if (!foundData) {
+ throw new Error(`player not found in database`);
+ } else {
+ this.dbData = foundData;
+ }
+ }
+
+ //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;
+ }
+
+ /**
+ * Returns a clone of this.dbData
+ */
+ getDbData() {
+ return cloneDeep(this.dbData);
+ }
+}
+
+
+export type PlayerClass = ServerPlayer | DatabasePlayer;
diff --git a/core/lib/player/playerFinder.ts b/core/lib/player/playerFinder.ts
new file mode 100644
index 0000000..9a73ab6
--- /dev/null
+++ b/core/lib/player/playerFinder.ts
@@ -0,0 +1,15 @@
+import { DatabasePlayerType } from "@modules/Database/databaseTypes.js";
+import { DatabasePlayer } from "./playerClasses.js"
+
+
+/**
+ * Finds all players in the database with a particular matching identifier
+ */
+export const findPlayersByIdentifier = (identifier: string): DatabasePlayer[] => {
+ if(typeof identifier !== 'string' || !identifier.length) throw new Error(`invalid identifier`);
+
+ const filter = (player: DatabasePlayerType) => player.ids.includes(identifier);
+ const playersData = txCore.database.players.findMany(filter);
+
+ return playersData.map((dbData) => new DatabasePlayer(dbData.license, dbData))
+}
diff --git a/core/lib/player/playerResolver.ts b/core/lib/player/playerResolver.ts
new file mode 100644
index 0000000..b768add
--- /dev/null
+++ b/core/lib/player/playerResolver.ts
@@ -0,0 +1,62 @@
+import { SYM_CURRENT_MUTEX } from "@lib/symbols.js";
+import { DatabasePlayer, ServerPlayer } from "./playerClasses.js"
+
+
+/**
+ * Resolves a ServerPlayer or DatabasePlayer based on mutex, netid and license.
+ * When mutex#netid is present, it takes precedence over license.
+ * If the mutex is not from the current server, search for the license in FxPlayerlist.licenseCache[]
+ * and then search for the license in the database.
+ */
+export default (mutex: any, netid: any, license: any) => {
+ const parsedNetid = parseInt(netid);
+ let searchLicense = license;
+
+ //For error clarification only
+ let hasMutex = false;
+
+ //Attempt to resolve current mutex, if needed
+ if(mutex === SYM_CURRENT_MUTEX){
+ mutex = txCore.fxRunner.child?.mutex;
+ if (!mutex) {
+ throw new Error(`current mutex not available`);
+ }
+ }
+
+ //If mutex+netid provided
+ if (typeof mutex === 'string' && typeof netid === 'number' && !isNaN(parsedNetid)) {
+ hasMutex = true;
+ if (mutex && mutex === txCore.fxRunner.child?.mutex) {
+ //If the mutex is from the server currently online
+ const player = txCore.fxPlayerlist.getPlayerById(netid);
+ if (player instanceof ServerPlayer) {
+ return player;
+ } else {
+ throw new Error(`player not found in current server playerlist`);
+ }
+ } else {
+ // If mutex is from previous server, overwrite any given license
+ const searchRef = `${mutex}#${netid}`;
+ const found = txCore.fxPlayerlist.licenseCache.find(c => c[0] === searchRef);
+ if (found) searchLicense = found[1];
+ }
+ }
+
+ //If license provided or resolved through licenseCache, search in the database
+ if (typeof searchLicense === 'string' && searchLicense.length) {
+ const onlineMatches = txCore.fxPlayerlist.getOnlinePlayersByLicense(searchLicense);
+ if(onlineMatches.length){
+ return onlineMatches.at(-1) as ServerPlayer;
+ }else{
+ return new DatabasePlayer(searchLicense);
+ }
+ }
+
+ //Player not found
+ //If not found in the db, the search above already threw error
+ if(hasMutex){
+ throw new Error(`could not resolve player by its net id which likely means it has disconnected long ago`);
+ }else{
+ throw new Error(`could not resolve this player`);
+ }
+}
diff --git a/core/lib/quitProcess.ts b/core/lib/quitProcess.ts
new file mode 100644
index 0000000..9ffbd01
--- /dev/null
+++ b/core/lib/quitProcess.ts
@@ -0,0 +1,18 @@
+/**
+ * Force Quits the process with a small delay and padding for the console.
+ */
+export default function quitProcess(code = 0): never {
+ //Process.exit will not quit if there are listeners on exit
+ process.removeAllListeners('SIGHUP');
+ process.removeAllListeners('SIGINT');
+ process.removeAllListeners('SIGTERM');
+
+ //Hacky solution to guarantee the error is flushed
+ //before fxserver double prints the exit code
+ process.stdout.write('\n');
+ process.stdout.write('\n');
+
+ //This will make the process hang for 100ms before exiting
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
+ process.exit(code);
+}
diff --git a/core/lib/symbols.ts b/core/lib/symbols.ts
new file mode 100644
index 0000000..fc02bb0
--- /dev/null
+++ b/core/lib/symbols.ts
@@ -0,0 +1,16 @@
+/**
+ * ConfigStore Schemas
+ */
+//Symbols used to mark the validation fail behavior
+export const SYM_FIXER_FATAL = Symbol('ConfigSchema:FixerFatalError');
+export const SYM_FIXER_DEFAULT = Symbol('ConfigSchema:FixerFallbackDefault');
+
+//Other symbols
+export const SYM_RESET_CONFIG = Symbol('ConfigSchema:SaverResetConfig');
+
+
+/**
+ * Other symbols
+ */
+export const SYM_SYSTEM_AUTHOR = Symbol('Definition:AuthorIsSystem');
+export const SYM_CURRENT_MUTEX = Symbol('Definition:CurrentServerMutex');
diff --git a/core/lib/xss.js b/core/lib/xss.js
new file mode 100644
index 0000000..26aabc9
--- /dev/null
+++ b/core/lib/xss.js
@@ -0,0 +1,13 @@
+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
new file mode 100644
index 0000000..422fffd
--- /dev/null
+++ b/core/modules/AdminStore/index.js
@@ -0,0 +1,682 @@
+const modulename = 'AdminStore';
+import fs from 'node:fs';
+import fsp from 'node:fs/promises';
+import { cloneDeep } from 'lodash-es';
+import { nanoid } from 'nanoid';
+import { txHostConfig } from '@core/globalData';
+import CfxProvider from './providers/CitizenFX.js';
+import { createHash } from 'node:crypto';
+import consoleFactory from '@lib/console.js';
+import fatalError from '@lib/fatalError.js';
+import { chalkInversePad } from '@lib/misc.js';
+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
+const ADMIN_SCHEMA_VERSION = 1;
+
+
+//Helpers
+const migrateProviderIdentifiers = (providerName, providerData) => {
+ if (providerName === 'citizenfx') {
+ // data may be empty, or nameid may be invalid
+ try {
+ const res = /\/user\/(\d{1,8})/.exec(providerData.data.nameid);
+ providerData.identifier = `fivem:${res[1]}`;
+ } catch (error) {
+ providerData.identifier = 'fivem:00000000';
+ }
+ } else if (providerName === 'discord') {
+ providerData.identifier = `discord:${providerData.id}`;
+ }
+};
+
+
+/**
+ * Module responsible for storing, retrieving and validating admins data.
+ */
+export default class AdminStore {
+ constructor() {
+ this.adminsFile = txHostConfig.dataSubPath('admins.json');
+ this.adminsFileHash = null;
+ this.admins = null;
+ this.refreshRoutine = null;
+
+ //Not alphabetical order, but that's fine
+ //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: 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',
+ 'manage.admins': 'Manage Admins', //will enable the "set admin" button in the player modal
+ 'settings.view': 'Settings: View (no tokens)',
+ 'settings.write': 'Settings: Change',
+ 'console.view': 'Console: View',
+ 'console.write': 'Console: Write',
+ 'control.server': 'Start/Stop Server + Scheduler', //FIXME: horrible name
+ 'announcement': 'Send Announcements',
+ 'commands.resources': 'Start/Stop Resources',
+ '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',
+
+ '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.warn': 'Warn',
+ 'players.kick': 'Kick',
+ 'players.ban': 'Ban',
+ 'players.freeze': 'Freeze Players',
+ 'players.heal': 'Heal', //self, everyone, and the "heal" button in player modal
+ 'players.playermode': 'NoClip / God Mode', //self playermode, and also the player spectate option
+ 'players.spectate': 'Spectate', //self playermode, and also the player spectate option
+ 'players.teleport': 'Teleport', //self teleport, and the bring/go to on player modal
+ 'players.troll': 'Troll Actions', //all the troll options in the player modal
+ };
+ //FIXME: pode remover, hardcode na cron function
+ this.hardConfigs = {
+ refreshInterval: 15e3,
+ };
+
+
+ //Load providers
+ //FIXME: pode virar um top-level singleton , não precisa estar na classe
+ try {
+ this.providers = {
+ discord: false,
+ citizenfx: new CfxProvider(),
+ };
+ } catch (error) {
+ throw new Error(`Failed to load providers with error: ${error.message}`);
+ }
+
+ //Check if admins file exists
+ let adminFileExists;
+ try {
+ fs.statSync(this.adminsFile, fs.constants.F_OK);
+ adminFileExists = true;
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ adminFileExists = false;
+ } else {
+ throw new Error(`Failed to check presence of admin file with error: ${error.message}`);
+ }
+ }
+
+ //Printing PIN or starting loop
+ if (!adminFileExists) {
+ if (!txHostConfig.defaults.account) {
+ this.addMasterPin = (Math.random() * 10000).toFixed().padStart(4, '0');
+ this.admins = false;
+ } else {
+ const { username, fivemId, password } = txHostConfig.defaults.account;
+ this.createAdminsFile(
+ username,
+ fivemId ? `fivem:${fivemId}` : undefined,
+ undefined,
+ password,
+ password ? false : undefined,
+ );
+ console.ok(`Created master account ${chalkInversePad(username)} with credentials provided by ${txHostConfig.sourceName}.`);
+ }
+ } else {
+ this.loadAdminsFile();
+ this.setupRefreshRoutine();
+ }
+ }
+
+
+ /**
+ * sets the admins file refresh routine
+ */
+ setupRefreshRoutine() {
+ this.refreshRoutine = setInterval(() => {
+ this.checkAdminsFile();
+ }, this.hardConfigs.refreshInterval);
+ }
+
+
+ /**
+ * Creates a admins.json file based on the first account
+ * @param {string} username
+ * @param {string|undefined} fivemId with the fivem: prefix
+ * @param {string|undefined} discordId with the discord: prefix
+ * @param {string|undefined} password backup password
+ * @param {boolean|undefined} isPlainTextPassword
+ * @returns {(boolean)} true or throws an error
+ */
+ createAdminsFile(username, fivemId, discordId, password, isPlainTextPassword) {
+ //Sanity check
+ if (this.admins !== false && this.admins !== null) throw new Error('Admins file already exists.');
+ if (typeof username !== 'string' || username.length < 3) throw new Error('Invalid username parameter.');
+
+ //Handling password
+ let password_hash, password_temporary;
+ if(password){
+ password_hash = isPlainTextPassword ? GetPasswordHash(password) : password;
+ // password_temporary = false; //undefined will do the same
+ } else {
+ const veryRandomString = `${username}-password-not-meant-to-be-used-${nanoid()}`;
+ password_hash = GetPasswordHash(veryRandomString);
+ password_temporary = true;
+ }
+
+ //Handling third party providers
+ const providers = {};
+ if (fivemId) {
+ providers.citizenfx = {
+ id: username,
+ identifier: fivemId,
+ data: {},
+ };
+ }
+ if (discordId) {
+ providers.discord = {
+ id: discordId,
+ identifier: `discord:${discordId}`,
+ data: {},
+ };
+ }
+
+ //Creating new admin
+ const newAdmin = {
+ $schema: ADMIN_SCHEMA_VERSION,
+ name: username,
+ master: true,
+ password_hash,
+ password_temporary,
+ providers,
+ permissions: [],
+ };
+ this.admins = [newAdmin];
+ this.addMasterPin = undefined;
+
+ //Saving admin file
+ try {
+ const jsonData = JSON.stringify(this.admins);
+ this.adminsFileHash = createHash('sha1').update(jsonData).digest('hex');
+ fs.writeFileSync(this.adminsFile, jsonData, { encoding: 'utf8', flag: 'wx' });
+ this.setupRefreshRoutine();
+ return newAdmin;
+ } catch (error) {
+ let message = `Failed to create '${this.adminsFile}' with error: ${error.message}`;
+ console.verbose.error(message);
+ throw new Error(message);
+ }
+ }
+
+
+ /**
+ * Returns a list of admins and permissions
+ */
+ getAdminsList() {
+ if (this.admins == false) return [];
+ return this.admins.map((user) => {
+ return {
+ name: user.name,
+ master: user.master,
+ providers: Object.keys(user.providers),
+ permissions: user.permissions,
+ };
+ });
+ }
+
+
+ /**
+ * Returns the raw array of admins, except for the hash
+ */
+ getRawAdminsList() {
+ if (this.admins === false) return [];
+ return cloneDeep(this.admins);
+ }
+
+
+ /**
+ * Returns all data from an admin by provider user id (ex discord id), or false
+ * @param {string} uid
+ */
+ getAdminByProviderUID(uid) {
+ if (this.admins == false) return false;
+ let id = uid.trim().toLowerCase();
+ if (!id.length) return false;
+ let admin = this.admins.find((user) => {
+ return Object.keys(user.providers).find((provider) => {
+ return (id === user.providers[provider].id.toLowerCase());
+ });
+ });
+ return (admin) ? cloneDeep(admin) : false;
+ }
+
+
+ /**
+ * Returns an array with all identifiers of the admins (fivem/discord)
+ */
+ getAdminsIdentifiers() {
+ if (this.admins === false) return [];
+ const ids = [];
+ for (const admin of this.admins) {
+ admin.providers.citizenfx && ids.push(admin.providers.citizenfx.identifier);
+ admin.providers.discord && ids.push(admin.providers.discord.identifier);
+ }
+ return ids;
+ }
+
+
+ /**
+ * Returns all data from an admin by their name, or false
+ * @param {string} uname
+ */
+ getAdminByName(uname) {
+ if (!this.admins) return false;
+ const username = uname.trim().toLowerCase();
+ if (!username.length) return false;
+ const admin = this.admins.find((user) => {
+ return (username === user.name.toLowerCase());
+ });
+ return (admin) ? cloneDeep(admin) : false;
+ }
+
+
+ /**
+ * Returns all data from an admin by game identifier, or false
+ * @param {string[]} identifiers
+ */
+ getAdminByIdentifiers(identifiers) {
+ if (!this.admins) return false;
+ identifiers = identifiers
+ .map((i) => i.trim().toLowerCase())
+ .filter((i) => i.length);
+ if (!identifiers.length) return false;
+ const admin = this.admins.find((user) =>
+ identifiers.find((identifier) =>
+ Object.keys(user.providers).find((provider) =>
+ (identifier === user.providers[provider].identifier.toLowerCase()))));
+ return (admin) ? cloneDeep(admin) : false;
+ }
+
+
+ /**
+ * Returns a list with all registered permissions
+ */
+ getPermissionsList() {
+ return cloneDeep(this.registeredPermissions);
+ }
+
+
+ /**
+ * Writes to storage the admins file
+ */
+ async writeAdminsFile() {
+ const jsonData = JSON.stringify(this.admins, null, 2);
+ this.adminsFileHash = createHash('sha1').update(jsonData).digest('hex');
+ await fsp.writeFile(this.adminsFile, jsonData, 'utf8');
+ return true;
+ }
+
+
+ /**
+ * Writes to storage the admins file
+ */
+ async checkAdminsFile() {
+ const restore = async () => {
+ try {
+ await this.writeAdminsFile();
+ console.ok('Restored admins.json file.');
+ } catch (error) {
+ console.error(`Failed to restore admins.json file: ${error.message}`);
+ console.verbose.dir(error);
+ }
+ };
+ try {
+ const jsonData = await fsp.readFile(this.adminsFile, 'utf8');
+ const inboundHash = createHash('sha1').update(jsonData).digest('hex');
+ if (this.adminsFileHash !== inboundHash) {
+ console.warn('The admins.json file was modified or deleted by an external source, txAdmin will try to restore it.');
+ restore();
+ }
+ } catch (error) {
+ console.error(`Cannot check admins file integrity: ${error.message}`);
+ }
+ }
+
+
+ /**
+ * 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 {string} password
+ * @param {array} permissions
+ */
+ async addAdmin(name, citizenfxData, discordData, password, permissions) {
+ if (this.admins == false) throw new Error('Admins not set');
+
+ //Check if username is already taken
+ if (this.getAdminByName(name)) throw new Error('Username already taken');
+
+ //Preparing admin
+ const admin = {
+ $schema: ADMIN_SCHEMA_VERSION,
+ name,
+ master: false,
+ password_hash: GetPasswordHash(password),
+ password_temporary: true,
+ providers: {},
+ permissions,
+ };
+
+ //Check if provider uid already taken and inserting into admin object
+ if (citizenfxData) {
+ const existingCitizenFX = this.getAdminByProviderUID(citizenfxData.id);
+ if (existingCitizenFX) throw new Error('CitizenFX ID already taken');
+ admin.providers.citizenfx = {
+ id: citizenfxData.id,
+ identifier: citizenfxData.identifier,
+ data: {},
+ };
+ }
+ if (discordData) {
+ const existingDiscord = this.getAdminByProviderUID(discordData.id);
+ if (existingDiscord) throw new Error('Discord ID already taken');
+ admin.providers.discord = {
+ id: discordData.id,
+ identifier: discordData.identifier,
+ data: {},
+ };
+ }
+
+ //Saving admin file
+ this.admins.push(admin);
+ this.refreshOnlineAdmins().catch((e) => { });
+ try {
+ return await this.writeAdminsFile();
+ } catch (error) {
+ throw new Error(`Failed to save admins.json with error: ${error.message}`);
+ }
+ }
+
+
+ /**
+ * Edit admin and save to the admins file
+ * @param {string} name
+ * @param {string|null} password
+ * @param {object|false} [citizenfxData] or false
+ * @param {object|false} [discordData] or false
+ * @param {string[]} [permissions]
+ */
+ async editAdmin(name, password, citizenfxData, discordData, permissions) {
+ if (this.admins == false) throw new Error('Admins not set');
+
+ //Find admin index
+ let username = name.toLowerCase();
+ let adminIndex = this.admins.findIndex((user) => {
+ return (username === user.name.toLowerCase());
+ });
+ if (adminIndex == -1) throw new Error('Admin not found');
+
+ //Editing admin
+ if (password !== null) {
+ this.admins[adminIndex].password_hash = GetPasswordHash(password);
+ delete this.admins[adminIndex].password_temporary;
+ }
+ if (typeof citizenfxData !== 'undefined') {
+ if (!citizenfxData) {
+ delete this.admins[adminIndex].providers.citizenfx;
+ } else {
+ this.admins[adminIndex].providers.citizenfx = {
+ id: citizenfxData.id,
+ identifier: citizenfxData.identifier,
+ data: {},
+ };
+ }
+ }
+ if (typeof discordData !== 'undefined') {
+ if (!discordData) {
+ delete this.admins[adminIndex].providers.discord;
+ } else {
+ this.admins[adminIndex].providers.discord = {
+ id: discordData.id,
+ identifier: discordData.identifier,
+ data: {},
+ };
+ }
+ }
+ if (typeof permissions !== 'undefined') this.admins[adminIndex].permissions = permissions;
+
+ //Prevent race condition, will allow the session to be updated before refreshing socket.io
+ //sessions which will cause reauth and closing of the temp password modal on first access
+ setTimeout(() => {
+ this.refreshOnlineAdmins().catch((e) => { });
+ }, 250);
+
+ //Saving admin file
+ try {
+ await this.writeAdminsFile();
+ return (password !== null) ? this.admins[adminIndex].password_hash : true;
+ } catch (error) {
+ throw new Error(`Failed to save admins.json with error: ${error.message}`);
+ }
+ }
+
+
+ /**
+ * Delete admin and save to the admins file
+ * @param {string} name
+ */
+ async deleteAdmin(name) {
+ if (this.admins == false) throw new Error('Admins not set');
+
+ //Delete admin
+ let username = name.toLowerCase();
+ let found = false;
+ this.admins = this.admins.filter((user) => {
+ if (username !== user.name.toLowerCase()) {
+ return true;
+ } else {
+ found = true;
+ return false;
+ }
+ });
+ if (!found) throw new Error('Admin not found');
+
+ //Saving admin file
+ this.refreshOnlineAdmins().catch((e) => { });
+ try {
+ return await this.writeAdminsFile();
+ } catch (error) {
+ throw new Error(`Failed to save admins.json with error: ${error.message}`);
+ }
+ }
+
+ /**
+ * Loads the admins.json file into the admins list
+ * NOTE: The verbosity here is driving me insane.
+ * But still seems not to be enough for people that don't read the README.
+ */
+ async loadAdminsFile() {
+ let raw = null;
+ let jsonData = null;
+ let hasMigration = false;
+
+ const callError = (reason) => {
+ let details;
+ if (reason === 'cannot read file') {
+ details = ['This means the file doesn\'t exist or txAdmin doesn\'t have permission to read it.'];
+ } else {
+ details = [
+ 'This likely means the file got somehow corrupted.',
+ 'You can try restoring it or you can delete it and let txAdmin create a new one.',
+ ];
+ }
+ fatalError.AdminStore(0, [
+ ['Unable to load admins.json', reason],
+ ...details,
+ ['Admin File Path', this.adminsFile],
+ ]);
+ };
+
+ try {
+ raw = await fsp.readFile(this.adminsFile, 'utf8');
+ this.adminsFileHash = createHash('sha1').update(raw).digest('hex');
+ } catch (error) {
+ return callError('cannot read file');
+ }
+
+ if (!raw.length) {
+ return callError('empty file');
+ }
+
+ try {
+ jsonData = JSON.parse(raw);
+ } catch (error) {
+ return callError('json parse error');
+ }
+
+ if (!Array.isArray(jsonData)) {
+ return callError('not an array');
+ }
+
+ if (!jsonData.length) {
+ return callError('no admins');
+ }
+
+ const structureIntegrityTest = jsonData.some((x) => {
+ if (typeof x.name !== 'string' || x.name.length < 3) return true;
+ if (typeof x.master !== 'boolean') return true;
+ if (typeof x.password_hash !== 'string' || !x.password_hash.startsWith('$2')) return true;
+ if (typeof x.providers !== 'object') return true;
+ const providersTest = Object.keys(x.providers).some((y) => {
+ if (!Object.keys(this.providers).includes(y)) return true;
+ if (typeof x.providers[y].id !== 'string' || x.providers[y].id.length < 3) return true;
+ if (typeof x.providers[y].data !== 'object') return true;
+ if (typeof x.providers[y].identifier === 'string') {
+ if (x.providers[y].identifier.length < 3) return true;
+ } else {
+ migrateProviderIdentifiers(y, x.providers[y]);
+ hasMigration = true;
+ }
+ });
+ if (providersTest) return true;
+ if (!Array.isArray(x.permissions)) return true;
+ return false;
+ });
+ if (structureIntegrityTest) {
+ return callError('invalid data in the admins file');
+ }
+
+ const masters = jsonData.filter((x) => x.master);
+ if (masters.length !== 1) {
+ return callError('must have exactly 1 master account');
+ }
+
+ //Migrate admin stuff
+ jsonData.forEach((admin) => {
+ //Migration (tx v7.3.0)
+ if (admin.$schema === undefined) {
+ //adding schema version
+ admin.$schema = ADMIN_SCHEMA_VERSION;
+ hasMigration = true;
+
+ //separate DM and Announcement permissions
+ if (admin.permissions.includes('players.message')) {
+ hasMigration = true;
+ admin.permissions = admin.permissions.filter((perm) => perm !== 'players.message');
+ admin.permissions.push('players.direct_message');
+ admin.permissions.push('announcement');
+ }
+
+ //Adding the new permission, except if they have no permissions or all of them
+ if (admin.permissions.length && !admin.permissions.includes('all_permissions')) {
+ admin.permissions.push('server.log.view');
+ }
+ }
+ });
+
+ this.admins = jsonData;
+ if (hasMigration) {
+ try {
+ await this.writeAdminsFile();
+ console.ok('The admins.json file was migrated to a new version.');
+ } catch (error) {
+ console.error(`Failed to migrate admins.json with error: ${error.message}`);
+ }
+ }
+
+ return true;
+ }
+
+
+ /**
+ * Notify game server about admin changes
+ */
+ async refreshOnlineAdmins() {
+ //Refresh auth of all admins connected to socket.io
+ txCore.webServer.webSocket.reCheckAdminAuths().catch((e) => { });
+
+ try {
+ //Getting all admin identifiers
+ 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);
+
+ txCore.fxRunner.sendEvent('adminsUpdated', onlineIDs);
+ } catch (error) {
+ console.verbose.error('Failed to refreshOnlineAdmins() with error:');
+ console.verbose.dir(error);
+ }
+ }
+
+
+ /**
+ * Returns a random token to be used as CSRF Token.
+ */
+ genCsrfToken() {
+ return nanoid();
+ }
+
+
+ /**
+ * Checks if there are admins configured or not.
+ * Optionally, prints the master PIN on the console.
+ */
+ hasAdmins(printPin = false) {
+ if (Array.isArray(this.admins) && this.admins.length) {
+ return true;
+ } else {
+ if (printPin) {
+ console.warn('Use this PIN to add a new master account: ' + chalkInversePad(this.addMasterPin));
+ }
+ return false;
+ }
+ }
+
+
+ /**
+ * Returns the public name to display for that particular purpose
+ * TODO: maybe use enums for the purpose
+ */
+ getAdminPublicName(name, purpose) {
+ if (!name || !purpose) throw new Error('Invalid parameters');
+ const replacer = txConfig.general.serverName ?? 'txAdmin';
+
+ if (purpose === 'punishment') {
+ return txConfig.gameFeatures.hideAdminInPunishments ? replacer : name;
+ } else if (purpose === 'message') {
+ return txConfig.gameFeatures.hideAdminInMessages ? replacer : name;
+ } else {
+ throw new Error(`Invalid purpose: ${purpose}`);
+ }
+ }
+};
diff --git a/core/modules/AdminStore/providers/CitizenFX.ts b/core/modules/AdminStore/providers/CitizenFX.ts
new file mode 100644
index 0000000..8a50c3d
--- /dev/null
+++ b/core/modules/AdminStore/providers/CitizenFX.ts
@@ -0,0 +1,107 @@
+const modulename = 'AdminStore:CfxProvider';
+import crypto from 'node:crypto';
+import { BaseClient, Issuer, custom } from 'openid-client';
+import { URL } from 'node:url';
+import consoleFactory from '@lib/console';
+import { z } from 'zod';
+const console = consoleFactory(modulename);
+
+const userInfoSchema = z.object({
+ name: z.string().min(1),
+ profile: z.string().min(1),
+ nameid: z.string().min(1),
+});
+export type UserInfoType = z.infer & { picture: string | undefined };
+
+const getOauthState = (stateKern: string) => {
+ const stateSeed = `tx:cfxre:${stateKern}`;
+ return crypto.createHash('SHA1').update(stateSeed).digest('hex');
+};
+
+
+export default class CfxProvider {
+ private client?: BaseClient;
+
+ constructor() {
+ //NOTE: using static config due to performance concerns
+ // const fivemIssuer = await Issuer.discover('https://idms.fivem.net/.well-known/openid-configuration');
+ const fivemIssuer = new Issuer({ 'issuer': 'https://idms.fivem.net', 'jwks_uri': 'https://idms.fivem.net/.well-known/openid-configuration/jwks', 'authorization_endpoint': 'https://idms.fivem.net/connect/authorize', 'token_endpoint': 'https://idms.fivem.net/connect/token', 'userinfo_endpoint': 'https://idms.fivem.net/connect/userinfo', 'end_session_endpoint': 'https://idms.fivem.net/connect/endsession', 'check_session_iframe': 'https://idms.fivem.net/connect/checksession', 'revocation_endpoint': 'https://idms.fivem.net/connect/revocation', 'introspection_endpoint': 'https://idms.fivem.net/connect/introspect', 'device_authorization_endpoint': 'https://idms.fivem.net/connect/deviceauthorization', 'frontchannel_logout_supported': true, 'frontchannel_logout_session_supported': true, 'backchannel_logout_supported': true, 'backchannel_logout_session_supported': true, 'scopes_supported': ['openid', 'email', 'identify', 'offline_access'], 'claims_supported': ['sub', 'email', 'email_verified', 'nameid', 'name', 'picture', 'profile'], 'grant_types_supported': ['authorization_code', 'client_credentials', 'refresh_token', 'implicit', 'urn:ietf:params:oauth:grant-type:device_code'], 'response_types_supported': ['code', 'token', 'id_token', 'id_token token', 'code id_token', 'code token', 'code id_token token'], 'response_modes_supported': ['form_post', 'query', 'fragment'], 'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'], 'subject_types_supported': ['public'], 'id_token_signing_alg_values_supported': ['RS256'], 'code_challenge_methods_supported': ['plain', 'S256'], 'request_parameter_supported': true });
+
+ this.client = new fivemIssuer.Client({
+ client_id: 'txadmin_test',
+ client_secret: 'txadmin_test',
+ response_types: ['openid'],
+ });
+ this.client[custom.clock_tolerance] = 2 * 60 * 60; //Two hours due to the DST change.
+ custom.setHttpOptionsDefaults({
+ timeout: 10000,
+ });
+ }
+
+
+ /**
+ * Returns the Provider Auth URL
+ */
+ getAuthURL(redirectUri: string, stateKern: string) {
+ if (!this.client) throw new Error(`${modulename} is not ready`);
+
+ const url = this.client.authorizationUrl({
+ redirect_uri: redirectUri,
+ state: getOauthState(stateKern),
+ response_type: 'code',
+ scope: 'openid identify',
+ });
+ if (typeof url !== 'string') throw new Error('url is not string');
+ return url;
+ }
+
+
+ /**
+ * Processes the callback and returns the tokenSet
+ */
+ async processCallback(sessionCallbackUri: string, sessionStateKern: string, callbackUri: string) {
+ if (!this.client) throw new Error(`${modulename} is not ready`);
+
+ //Process the request
+ const parsedUri = new URL(callbackUri);
+ const callback = parsedUri.searchParams;
+ const callbackCode = callback.get('code');
+ const callbackState = callback.get('state');
+ if (typeof callbackCode !== 'string') throw new Error('code not present');
+ if (typeof callbackState !== 'string') throw new Error('state not present');
+
+ //Exchange code for token
+ const tokenSet = await this.client.callback(
+ sessionCallbackUri,
+ {
+ code: callbackCode,
+ state: callbackState,
+ },
+ {
+ state: getOauthState(sessionStateKern)
+ }
+ );
+ if (typeof tokenSet !== 'object') throw new Error('tokenSet is not an object');
+ if (typeof tokenSet.access_token == 'undefined') throw new Error('access_token not present');
+ if (typeof tokenSet.expires_at == 'undefined') throw new Error('expires_at not present');
+ return tokenSet;
+ }
+
+
+ /**
+ * Gets user info via access token
+ */
+ async getUserInfo(accessToken: string): Promise {
+ if (!this.client) throw new Error(`${modulename} is not ready`);
+
+ //Perform introspection
+ const userInfo = await this.client.userinfo(accessToken);
+ const parsed = userInfoSchema.parse(userInfo);
+ let picture: string | undefined;
+ if (typeof userInfo.picture == 'string' && userInfo.picture.startsWith('https://')) {
+ picture = userInfo.picture;
+ }
+
+ return { ...parsed, picture };
+ }
+};
diff --git a/core/modules/CacheStore.ts b/core/modules/CacheStore.ts
new file mode 100644
index 0000000..34c60a8
--- /dev/null
+++ b/core/modules/CacheStore.ts
@@ -0,0 +1,143 @@
+const modulename = 'CacheStore';
+import fsp from 'node:fs/promises';
+import throttle from 'lodash-es/throttle.js';
+import consoleFactory from '@lib/console';
+import { txDevEnv, txEnv } from '@core/globalData';
+import type { z, ZodSchema } from 'zod';
+import type { UpdateConfigKeySet } from './ConfigStore/utils';
+const console = consoleFactory(modulename);
+
+
+//NOTE: due to limitations on how we compare value changes we can only accept these types
+//This is to prevent saving the same value repeatedly (eg sv_maxClients every 3 seconds)
+export const isBoolean = (val: any): val is boolean => typeof val === 'boolean';
+export const isNull = (val: any): val is null => val === null;
+export const isNumber = (val: any): val is number => typeof val === 'number';
+export const isString = (val: any): val is string => typeof val === 'string';
+type IsTypeFunctions = typeof isBoolean | typeof isNull | typeof isNumber | typeof isString;
+type InferValType = T extends (val: any) => val is infer R ? R : never;
+type AcceptedCachedTypes = boolean | null | number | string;
+type CacheMap = Map;
+const isAcceptedType = (val: any): val is AcceptedCachedTypes => {
+ const valType = typeof val;
+ return (val === null || valType === 'string' || valType === 'boolean' || valType === 'number');
+}
+
+const CACHE_FILE_NAME = 'cachedData.json';
+
+
+/**
+ * Dead-simple Map-based persistent cache, saved in txData//cachedData.json.
+ * This is not meant to store anything super important, the async save does not throw in case of failure,
+ * and it will reset the cache in case it fails to load.
+ */
+export default class CacheStore {
+ static readonly configKeysWatched = [
+ 'server.dataPath',
+ 'server.cfgPath',
+ ];
+
+ private cache: CacheMap = new Map();
+ readonly cacheFilePath = `${txEnv.profilePath}/data/${CACHE_FILE_NAME}`;
+ readonly throttledSaveCache = throttle(
+ this.saveCache.bind(this),
+ 5000,
+ { leading: false, trailing: true }
+ );
+
+ constructor() {
+ this.loadCachedData();
+
+ //TODO: handle shutdown? copied from Metrics.svRuntime
+ // this.throttledSaveCache.cancel({ upcomingOnly: true });
+ // this.saveCache();
+ }
+
+ //Resets the fxsRuntime cache on server reset
+ public handleConfigUpdate(updatedConfigs: UpdateConfigKeySet) {
+ this.delete('fxsRuntime:gameName'); //from logger
+ this.delete('fxsRuntime:cfxId'); //from fd3
+ this.delete('fxsRuntime:maxClients'); //from /dynamic.json
+
+ //from /info.json
+ this.delete('fxsRuntime:bannerConnecting');
+ this.delete('fxsRuntime:bannerDetail');
+ this.delete('fxsRuntime:iconFilename');
+ this.delete('fxsRuntime:locale');
+ this.delete('fxsRuntime:projectDesc');
+ this.delete('fxsRuntime:projectName');
+ this.delete('fxsRuntime:tags');
+ }
+
+ public has(key: string) {
+ return this.cache.has(key);
+ }
+
+ public get(key: string) {
+ return this.cache.get(key);
+ }
+
+ public getTyped(key: string, typeChecker: T) {
+ const value = this.cache.get(key);
+ if (!value) return undefined;
+ if (typeChecker(value)) return value as InferValType;
+ return undefined;
+ }
+
+ public set(key: string, value: AcceptedCachedTypes) {
+ if (!isAcceptedType(value)) throw new Error(`Value of type ${typeof value} is not acceptable.`);
+ const currValue = this.cache.get(key);
+ if (currValue !== value) {
+ this.cache.set(key, value);
+ this.throttledSaveCache();
+ }
+ }
+
+ public upsert(key: string, value: AcceptedCachedTypes | undefined) {
+ if (value === undefined) {
+ this.delete(key);
+ } else {
+ this.set(key, value);
+ }
+ }
+
+ public delete(key: string) {
+ const deleteResult = this.cache.delete(key);
+ this.throttledSaveCache();
+ return deleteResult;
+ }
+
+ private async saveCache() {
+ try {
+ const serializer = (txDevEnv.ENABLED)
+ ? (obj: any) => JSON.stringify(obj, null, 4)
+ : JSON.stringify
+ const toSave = serializer([...this.cache.entries()]);
+ await fsp.writeFile(this.cacheFilePath, toSave);
+ // console.verbose.debug(`Saved ${CACHE_FILE_NAME} with ${this.cache.size} entries.`);
+ } catch (error) {
+ console.error(`Unable to save ${CACHE_FILE_NAME} with error: ${(error as Error).message}`);
+ }
+ }
+
+ private async loadCachedData() {
+ try {
+ const rawFileData = await fsp.readFile(this.cacheFilePath, 'utf8');
+ const fileData = JSON.parse(rawFileData);
+ if (!Array.isArray(fileData)) throw new Error('data_is_not_an_array');
+ this.cache = new Map(fileData);
+ console.verbose.ok(`Loaded ${CACHE_FILE_NAME} with ${this.cache.size} entries.`);
+ } catch (error) {
+ this.cache = new Map();
+ if ((error as any)?.code === 'ENOENT') {
+ console.verbose.debug(`${CACHE_FILE_NAME} not found, making a new one.`);
+ } else if ((error as any)?.message === 'data_is_not_an_array') {
+ console.warn(`Failed to load ${CACHE_FILE_NAME} due to invalid data.`);
+ console.warn('Since this is not a critical file, it will be reset.');
+ } else {
+ console.warn(`Failed to load ${CACHE_FILE_NAME} with message: ${(error as any).message}`);
+ console.warn('Since this is not a critical file, it will be reset.');
+ }
+ }
+ }
+};
diff --git a/core/modules/ConfigStore/changelog.ts b/core/modules/ConfigStore/changelog.ts
new file mode 100644
index 0000000..5604224
--- /dev/null
+++ b/core/modules/ConfigStore/changelog.ts
@@ -0,0 +1,30 @@
+import { z } from "zod";
+
+// Configs
+const daysMs = 24 * 60 * 60 * 1000;
+export const CCLOG_SIZE_LIMIT = 32;
+export const CCLOG_RETENTION = 120 * daysMs;
+export const CCLOG_VERSION = 1;
+
+//Schemas
+const ConfigChangelogEntrySchema = z.object({
+ author: z.string().min(1),
+ ts: z.number().int().nonnegative(),
+ keys: z.string().array(),
+});
+export const ConfigChangelogFileSchema = z.object({
+ version: z.literal(1),
+ log: z.array(ConfigChangelogEntrySchema),
+});
+export type ConfigChangelogEntry = z.infer;
+export type ConfigChangelogFile = z.infer;
+
+//Optimizer
+export const truncateConfigChangelog = (log: ConfigChangelogEntry[]): ConfigChangelogEntry[] => {
+ if (!log.length) return [];
+
+ const now = Date.now();
+ return log
+ .filter(entry => (now - entry.ts) <= CCLOG_RETENTION)
+ .slice(-CCLOG_SIZE_LIMIT);
+}
diff --git a/core/modules/ConfigStore/configMigrations.ts b/core/modules/ConfigStore/configMigrations.ts
new file mode 100644
index 0000000..a67fd9c
--- /dev/null
+++ b/core/modules/ConfigStore/configMigrations.ts
@@ -0,0 +1,84 @@
+const modulename = 'ConfigStore:Migration';
+import fs from 'node:fs';
+import { ConfigFileData, PartialTxConfigs } from './schema/index';
+import { txEnv } from '@core/globalData';
+import { cloneDeep } from 'lodash-es';
+import fatalError from '@lib/fatalError';
+import { CONFIG_VERSION } from './index'; //FIXME: circular_dependency
+import { migrateOldConfig } from './schema/oldConfig';
+import consoleFactory from '@lib/console';
+import { chalkInversePad } from '@lib/misc';
+const console = consoleFactory(modulename);
+
+
+/**
+ * Saves a backup of the current config file
+ */
+const saveBackupFile = (version: number) => {
+ const bkpFileName = `config.backup.v${version}.json`;
+ fs.copyFileSync(
+ `${txEnv.profilePath}/config.json`,
+ `${txEnv.profilePath}/${bkpFileName}`,
+ );
+ console.log(`A backup of your config file was saved as: ${chalkInversePad(bkpFileName)}`);
+}
+
+
+/**
+ * Migrates the old config file to the new schema
+ */
+export const migrateConfigFile = (fileData: any): ConfigFileData => {
+ const oldConfig = cloneDeep(fileData);
+ let newConfig: ConfigFileData | undefined;
+ let oldVersion: number | undefined;
+
+ //Sanity check
+ if ('version' in fileData && typeof fileData.version !== 'number') {
+ fatalError.ConfigStore(20, 'Your txAdmin config.json version is not a number!');
+ }
+ if (typeof fileData.version === 'number' && fileData.version > CONFIG_VERSION) {
+ fatalError.ConfigStore(21, [
+ `Your config.json file is on v${fileData.version}, and this txAdmin supports up to v${CONFIG_VERSION}.`,
+ 'This means you likely downgraded your txAdmin or FXServer.',
+ 'Please make sure your txAdmin is updated!',
+ '',
+ 'If you want to downgrade FXServer (the "artifact") but keep txAdmin updated,',
+ 'you can move the updated "citizen/system_resources/monitor" folder',
+ 'to older FXserver artifact, replacing the old files.',
+ `Alternatively, you can restore the v${fileData.version} backup on the folder below.`,
+ ['File Path', `${txEnv.profilePath}/config.json`],
+ ]);
+ }
+ //The v1 is implicit, if explicit then it's a problem
+ if (fileData.version === 1) {
+ throw new Error(`File with explicit version '1' should not exist.`);
+ }
+
+
+ //Migrate from v1 (no version) to v2
+ //- remapping the old config to the new structure
+ //- applying some default changes and migrations
+ //- extracting just the non-default values
+ //- truncating the serverName to 18 chars
+ //- generating new banlist template IDs
+ if (!('version' in fileData) && 'global' in fileData && 'fxRunner' in fileData) {
+ console.warn('Updating your txAdmin config.json from v1 to v2.');
+ oldVersion ??= 1;
+
+ //Final object
+ const justNonDefaults = migrateOldConfig(oldConfig) as PartialTxConfigs;
+ newConfig = {
+ version: 2,
+ ...justNonDefaults,
+ }
+ }
+
+
+ //Final check
+ if (oldVersion && newConfig && newConfig.version === CONFIG_VERSION) {
+ saveBackupFile(oldVersion);
+ return newConfig;
+ } else {
+ throw new Error(`Unknown file version: ${fileData.version}`);
+ }
+}
diff --git a/core/modules/ConfigStore/configParser.test.ts b/core/modules/ConfigStore/configParser.test.ts
new file mode 100644
index 0000000..9ea0139
--- /dev/null
+++ b/core/modules/ConfigStore/configParser.test.ts
@@ -0,0 +1,274 @@
+import { suite, it, expect } from 'vitest';
+import { parseConfigFileData, bootstrapConfigProcessor, getConfigDefaults, runtimeConfigProcessor } from './configParser';
+import { z } from 'zod';
+import { typeDefinedConfig, typeNullableConfig } from './schema/utils';
+import ConfigStore from '.';
+import { SYM_FIXER_DEFAULT, SYM_FIXER_FATAL, SYM_RESET_CONFIG } from '@lib/symbols';
+
+
+suite('parseConfigFileData', () => {
+ it('should correctly parse a valid config file', () => {
+ const configFileData = {
+ version: 1,
+ example: {
+ serverName: 'MyServer',
+ enabled: true,
+ },
+ server: {
+ dataPath: '/path/to/data',
+ },
+ };
+ const result = parseConfigFileData(configFileData);
+ expect(result).toEqual([
+ { scope: 'example', key: 'serverName', value: 'MyServer' },
+ { scope: 'example', key: 'enabled', value: true },
+ { scope: 'server', key: 'dataPath', value: '/path/to/data' },
+ ]);
+ });
+
+ it('should ignore the version key', () => {
+ const configFileData = {
+ version: 1,
+ example: {
+ serverName: 'MyServer',
+ },
+ };
+ const result = parseConfigFileData(configFileData);
+ expect(result).toEqual([
+ { scope: 'example', key: 'serverName', value: 'MyServer' },
+ ]);
+ });
+
+ it('should handle empty config file', () => {
+ const configFileData = {};
+ const result = parseConfigFileData(configFileData);
+ expect(result).toEqual([]);
+ });
+
+ it('should handle undefined items', () => {
+ const configFileData = {
+ example: {
+ aaa: 'whatever',
+ bbb: undefined,
+ },
+ };
+ const result = parseConfigFileData(configFileData);
+ expect(result).toEqual([
+ { scope: 'example', key: 'aaa', value: 'whatever' },
+ ]);
+ });
+
+ it('should handle nested scopes', () => {
+ const configFileData = {
+ version: 1,
+ example: {
+ serverName: 'MyServer',
+ enabled: true,
+ },
+ server: {
+ dataPath: { path: '/path/to/data' },
+ },
+ };
+ const result = parseConfigFileData(configFileData as any);
+ expect(result).toEqual([
+ { scope: 'example', key: 'serverName', value: 'MyServer' },
+ { scope: 'example', key: 'enabled', value: true },
+ { scope: 'server', key: 'dataPath', value: { path: '/path/to/data' } },
+ ]);
+ });
+});
+
+
+suite('bootstrapConfigProcessor', () => {
+ const allConfigScopes = {
+ example: {
+ serverName: typeDefinedConfig({
+ name: 'Server Name',
+ default: 'change-me',
+ validator: z.string().min(1).max(18),
+ fixer: SYM_FIXER_DEFAULT,
+ }),
+ enabled: typeDefinedConfig({
+ name: 'Enabled',
+ default: true,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+ }),
+ },
+ server: {
+ dataPath: typeNullableConfig({
+ name: 'Data Path',
+ default: null,
+ validator: z.string().min(1).nullable(),
+ fixer: SYM_FIXER_FATAL,
+ }),
+ }
+ };
+ const defaultConfigs = getConfigDefaults(allConfigScopes);
+
+ it('should process valid config items', () => {
+ const parsedInput = [
+ { scope: 'example', key: 'serverName', value: 'MyServer' },
+ { scope: 'server', key: 'dataPath', value: '/path/to/data' },
+ ];
+ const result = bootstrapConfigProcessor(parsedInput, allConfigScopes, defaultConfigs);
+ expect(result.stored.example.serverName).toBe('MyServer');
+ expect(result.stored.server.dataPath).toBe('/path/to/data');
+ expect(result.active.example.serverName).toBe('MyServer');
+ expect(result.active.server.dataPath).toBe('/path/to/data');
+ });
+
+ it('should handle unknown config items', () => {
+ const parsedInput = [
+ { scope: 'unknownScope', key: 'key1', value: 'value1' },
+ ];
+ const result = bootstrapConfigProcessor(parsedInput, allConfigScopes, defaultConfigs);
+ expect(result.unknown.unknownScope.key1).toBe('value1');
+ });
+
+ it('should apply default for active but not stored', () => {
+ const parsedInput = [
+ { scope: 'unknownScope', key: 'key1', value: 'value1' },
+ ];
+ const result = bootstrapConfigProcessor(parsedInput, allConfigScopes, defaultConfigs);
+ expect(result.stored?.example?.serverName).toBeUndefined();
+ expect(result.active.example.serverName).toBe(defaultConfigs.example.serverName);
+ });
+
+ it('should apply default values for invalid config items', () => {
+ const parsedInput = [
+ { scope: 'example', key: 'serverName', value: '' },
+ ];
+ const result = bootstrapConfigProcessor(parsedInput, allConfigScopes, defaultConfigs);
+ expect(result.stored.example.serverName).toBe(defaultConfigs.example.serverName);
+ expect(result.active.example.serverName).toBe(defaultConfigs.example.serverName);
+ });
+
+ it('should throw error for unfixable invalid config items', () => {
+ const parsedInput = [
+ { scope: 'server', key: 'dataPath', value: '' },
+ ];
+ expect(() => bootstrapConfigProcessor(parsedInput, allConfigScopes, defaultConfigs)).toThrow();
+ });
+});
+
+
+suite('runtimeConfigProcessor', () => {
+ const allConfigScopes = {
+ example: {
+ serverName: typeDefinedConfig({
+ name: 'Server Name',
+ default: 'change-me',
+ validator: z.string().min(1).max(18),
+ fixer: SYM_FIXER_DEFAULT,
+ }),
+ enabled: typeDefinedConfig({
+ name: 'Enabled',
+ default: true,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+ }),
+ },
+ server: {
+ dataPath: typeNullableConfig({
+ name: 'Data Path',
+ default: null,
+ validator: z.string().min(1).nullable(),
+ fixer: SYM_FIXER_FATAL,
+ }),
+ scheduledRestarts: typeDefinedConfig({
+ name: 'Scheduled Restarts',
+ default: [],
+ validator: z.array(z.number().int()).default([]),
+ fixer: SYM_FIXER_DEFAULT,
+ }),
+ },
+ };
+ const storedConfigs = {
+ example: {
+ serverName: 'StoredServer',
+ enabled: false,
+ },
+ server: {
+ dataPath: '/stored/path',
+ }
+ };
+ const activeConfigs = {
+ example: {
+ serverName: 'ActiveServer',
+ enabled: true,
+ },
+ server: {
+ dataPath: '/active/path',
+ }
+ };
+
+ it('should process valid config changes', () => {
+ const parsedInput = [
+ { scope: 'example', key: 'serverName', value: 'NewServer' },
+ ];
+ const result = runtimeConfigProcessor(parsedInput, allConfigScopes, storedConfigs, activeConfigs);
+ expect(result.stored.example.serverName).toBe('NewServer');
+ expect(result.active.example.serverName).toBe('NewServer');
+ expect(result.active.server.dataPath).toBe('/active/path');
+ expect(result.storedKeysChanges.list).toEqual(['example.serverName']);
+ expect(result.activeKeysChanges.list).toEqual(['example.serverName']);
+ });
+
+ it('should reset config to default', () => {
+ const parsedInput = [
+ { scope: 'example', key: 'serverName', value: SYM_RESET_CONFIG },
+ ];
+ const result = runtimeConfigProcessor(parsedInput, allConfigScopes, storedConfigs, activeConfigs);
+ expect(result.stored.example.serverName).toBeUndefined();
+ expect(result.active.example.serverName).toBe('change-me');
+ expect(result.storedKeysChanges.list).toEqual(['example.serverName']);
+ expect(result.activeKeysChanges.list).toEqual(['example.serverName']);
+ });
+
+ it('should list the correct changes', () => {
+ const parsedInput = [
+ { scope: 'example', key: 'serverName', value: 'StoredServer' },
+ { scope: 'server', key: 'dataPath', value: '/active/path' },
+ ];
+ const result = runtimeConfigProcessor(parsedInput, allConfigScopes, storedConfigs, activeConfigs);
+ expect(result.storedKeysChanges.list).toEqual(['server.dataPath']);
+ expect(result.activeKeysChanges.list).toEqual(['example.serverName']);
+ });
+
+ it('should throw error for invalid config changes', () => {
+ const parsedInput = [
+ { scope: 'example', key: 'serverName', value: false },
+ ];
+ expect(() => runtimeConfigProcessor(parsedInput, allConfigScopes, storedConfigs, activeConfigs)).toThrow();
+ });
+
+ it('should handle unknown config items', () => {
+ const parsedInput = [
+ { scope: 'unknownScope', key: 'key1', value: 'value1' },
+ ];
+ expect(() => runtimeConfigProcessor(parsedInput, allConfigScopes, storedConfigs, activeConfigs)).toThrow();
+ });
+
+ it('should handle default equality checking', () => {
+ const parsedInput = [
+ { scope: 'server', key: 'scheduledRestarts', value: [] },
+ ];
+ const result = runtimeConfigProcessor(parsedInput, allConfigScopes, storedConfigs, activeConfigs);
+ expect(result.stored.server.scheduledRestarts).toBeUndefined();
+ expect(result.active.server.scheduledRestarts).toEqual([]);
+ });
+});
+
+
+suite('schema sanity check', () => {
+ it('should have the same keys in all schemas', () => {
+ for (const [scopeName, scopeConfigs] of Object.entries(ConfigStore.Schema)) {
+ for (const [configKey, configData] of Object.entries(scopeConfigs)) {
+ expect(configData.default).toBeDefined();
+ expect(configData.validator).toBeDefined();
+ expect(configData.fixer).toBeDefined();
+ }
+ }
+ });
+});
diff --git a/core/modules/ConfigStore/configParser.ts b/core/modules/ConfigStore/configParser.ts
new file mode 100644
index 0000000..ba65a75
--- /dev/null
+++ b/core/modules/ConfigStore/configParser.ts
@@ -0,0 +1,218 @@
+const modulename = 'ConfigStore:Parser';
+import consoleFactory from "@lib/console";
+import { ConfigFileData, ConfigScaffold } from "./schema";
+import { ConfigScope, ListOf, ScopeConfigItem } from "./schema/utils";
+import { confx, UpdateConfigKeySet } from "./utils";
+import { cloneDeep } from "lodash";
+import { dequal } from 'dequal/lite';
+import { fromZodError } from "zod-validation-error";
+import { SYM_FIXER_DEFAULT, SYM_RESET_CONFIG } from "@lib/symbols";
+const console = consoleFactory(modulename);
+
+
+// Returns object with all the scopes empty
+// export const getConfigScaffold = (allConfigScopes: ListOf) => {
+// const scaffold: ConfigScaffold = Object.fromEntries(
+// Object.entries(allConfigScopes).map(([k, s]) => [k, {} as any])
+// );
+// return scaffold;
+// };
+
+
+// Returns object scope containing all the valid config values
+export const getScopeDefaults = (scope: ConfigScope): T => {
+ return Object.fromEntries(
+ Object.entries(scope)
+ .map(([key, schema]) => [key, schema.default])
+ ) as T;
+};
+
+
+// Returns object with all the scopes and their default values
+export const getConfigDefaults = (allConfigScopes: ListOf) => {
+ const defaults: ConfigScaffold = Object.fromEntries(
+ Object.entries(allConfigScopes).map(([k, s]) => [k, getScopeDefaults(s)])
+ );
+ return defaults;
+}
+
+
+/**
+ * Convert a config structure into a list of parsed config items
+ */
+export const parseConfigFileData = (configFileData: ConfigScaffold | ConfigFileData) => {
+ const parsedConfigItems: ParsedConfigItem[] = [];
+ for (const [scope, values] of Object.entries(configFileData)) {
+ if (scope === 'version') continue;
+ for (const [key, value] of Object.entries(values)) {
+ if (value === undefined) continue;
+ parsedConfigItems.push({ scope, key, value });
+ }
+ }
+ return parsedConfigItems;
+}
+type ParsedConfigItem = {
+ scope: string;
+ key: string;
+ value: any;
+}
+
+
+/**
+ * Attempt to fix the value - USED DURING BOOTSTRAP ONLY
+ */
+const attemptConfigFix = (scope: string, key: string, value: any, configSchema: ScopeConfigItem) => {
+ const shouldBeArray = Array.isArray(configSchema.default);
+ if (configSchema.fixer === SYM_FIXER_DEFAULT) {
+ if (shouldBeArray) {
+ console.error(`Invalid value for '${scope}.${key}', applying default value.`);
+ } else {
+ console.error(`Invalid value for '${scope}.${key}', applying default value:`, configSchema.default);
+ }
+ return {
+ success: true,
+ value: configSchema.default,
+ };
+ } else if (typeof configSchema.fixer === 'function') {
+ try {
+ const fixed = configSchema.fixer(value);
+ if (shouldBeArray) {
+ console.error(`Invalid value for '${scope}.${key}' has been automatically fixed.`);
+ } else {
+ console.error(`Invalid value for '${scope}.${key}', the value has been fixed to:`, fixed);
+ }
+ return {
+ success: true,
+ value: fixed,
+ };
+ } catch (error) {
+ console.error(`Invalid value for '${scope}.${key}', fixer failed with reason: ${(error as any).message}`);
+ return {
+ success: false,
+ error,
+ };
+ }
+ }
+ return {
+ success: false,
+ };
+}
+
+
+/**
+ * Processes a parsed config based on a schema to get the stored and active values
+ */
+export const bootstrapConfigProcessor = (
+ parsedInput: ParsedConfigItem[],
+ allConfigScopes: ListOf,
+ defaultConfigs: ConfigScaffold,
+) => {
+ //Scaffold the objects
+ const unknown: ListOf = {};
+ const stored: ListOf = {};
+ const active = cloneDeep(defaultConfigs);
+
+ //Process each item
+ for (const { scope, key, value } of parsedInput) {
+ //Check if the scope is known
+ const configSchema = allConfigScopes?.[scope]?.[key];
+ if (!configSchema) {
+ console.warn(`Unknown config: ${scope}.${key}`);
+ unknown[scope] ??= {};
+ unknown[scope][key] = value;
+ continue;
+ }
+ stored[scope] ??= {};
+
+ //Validate the value
+ const zResult = configSchema.validator.safeParse(value);
+ if (zResult.success) {
+ stored[scope][key] = zResult.data;
+ active[scope][key] = zResult.data;
+ continue;
+ }
+
+ //Attempt to fix the value
+ const fResult = attemptConfigFix(scope, key, value, configSchema);
+ if (fResult.success && fResult.value !== undefined) {
+ stored[scope][key] = fResult.value;
+ active[scope][key] = fResult.value;
+ } else {
+ console.warn(`Invalid value for '${scope}.${key}': ${(zResult.error as any).message}`);
+ throw fResult?.error ?? fromZodError(zResult.error, { prefix: `${scope}.${key}` });
+ }
+ }
+
+ return { unknown, stored, active };
+}
+
+
+/**
+ * Diff the parsed input against the stored and active configs, and validate the changes
+ */
+export const runtimeConfigProcessor = (
+ parsedInput: ParsedConfigItem[],
+ allConfigScopes: ListOf,
+ storedConfigs: ConfigScaffold,
+ activeConfigs: ConfigScaffold,
+) => {
+ //Scaffold the objects
+ const storedKeysChanges = new UpdateConfigKeySet();
+ const activeKeysChanges = new UpdateConfigKeySet();
+ const thisStoredCopy = cloneDeep(storedConfigs);
+ const thisActiveCopy = cloneDeep(activeConfigs);
+
+ //Process each item
+ for (const { scope, key, value } of parsedInput) {
+ //Check if the scope is known
+ const configSchema = confx(allConfigScopes).get(scope, key) as ScopeConfigItem;
+ if (!configSchema) throw new Error(`Unknown config: ${scope}.${key}`);
+
+ //Restore or Validate the value
+ let newValue: any;
+ if (value === SYM_RESET_CONFIG) {
+ newValue = configSchema.default;
+ } else {
+ const zResult = configSchema.validator.safeParse(value);
+ if (!zResult.success) {
+ throw fromZodError(zResult.error, { prefix: configSchema.name });
+ }
+ newValue = zResult.data;
+ }
+
+ //Check if the value is different from the stored value
+ const defaultValue = configSchema.default;
+ const storedValue = confx(thisStoredCopy).get(scope, key);
+ const isNewValueDefault = dequal(newValue, defaultValue);
+ if (storedValue === undefined) {
+ if (!isNewValueDefault) {
+ storedKeysChanges.add(scope, key);
+ confx(thisStoredCopy).set(scope, key, newValue);
+ }
+ } else if (!dequal(newValue, storedValue)) {
+ storedKeysChanges.add(scope, key);
+ if (!isNewValueDefault) {
+ //NOTE: if default, it's being removed below already
+ confx(thisStoredCopy).set(scope, key, newValue);
+ }
+ }
+
+ //If the value is the default, remove
+ if (isNewValueDefault) {
+ confx(thisStoredCopy).unset(scope, key);
+ }
+
+ //Check if the value is different from the active value
+ if (!dequal(newValue, confx(thisActiveCopy).get(scope, key))) {
+ activeKeysChanges.add(scope, key);
+ confx(thisActiveCopy).set(scope, key, newValue);
+ }
+ }
+
+ return {
+ storedKeysChanges,
+ activeKeysChanges,
+ stored: thisStoredCopy,
+ active: thisActiveCopy,
+ }
+}
diff --git a/core/modules/ConfigStore/index.ts b/core/modules/ConfigStore/index.ts
new file mode 100644
index 0000000..91dfe8d
--- /dev/null
+++ b/core/modules/ConfigStore/index.ts
@@ -0,0 +1,273 @@
+const modulename = 'ConfigStore';
+import fs from 'node:fs';
+import fsp from 'node:fs/promises';
+import { cloneDeep } from 'lodash-es';
+import consoleFactory from '@lib/console';
+import fatalError from '@lib/fatalError';
+import { txEnv } from '@core/globalData';
+import { ConfigFileData, ConfigSchemas_v2, PartialTxConfigs, PartialTxConfigsToSave, TxConfigs } from './schema';
+import { migrateConfigFile } from './configMigrations';
+import { deepFreeze } from '@lib/misc';
+import { parseConfigFileData, bootstrapConfigProcessor, runtimeConfigProcessor, getConfigDefaults } from './configParser';
+import { ListOf } from './schema/utils';
+import { CCLOG_VERSION, ConfigChangelogEntry, ConfigChangelogFileSchema, truncateConfigChangelog } from './changelog';
+import { UpdateConfigKeySet } from './utils';
+const console = consoleFactory(modulename);
+
+
+//Types
+export type RefreshConfigKey = { full: string, scope: string, key: string };
+export type RefreshConfigFunc = (updatedConfigs: UpdateConfigKeySet) => void;
+type RefreshConfigRegistry = {
+ moduleName: string,
+ callback: RefreshConfigFunc,
+ rules: string[],
+}[];
+
+//Consts
+export const CONFIG_VERSION = 2;
+
+
+/**
+ * Module to handle the configuration file, validation, defaults and retrieval.
+ * The setup is fully sync, as nothing else can start without the config.
+ */
+export default class ConfigStore /*does not extend TxModuleBase*/ {
+ //Statics
+ public static readonly Schema = ConfigSchemas_v2;
+ public static readonly SchemaDefaults = getConfigDefaults(ConfigSchemas_v2) as TxConfigs;
+ public static getEmptyConfigFile() {
+ return { version: CONFIG_VERSION };
+ }
+
+ //Instance
+ private readonly changelogFilePath = `${txEnv.profilePath}/data/configChangelog.json`;
+ private readonly configFilePath = `${txEnv.profilePath}/config.json`;
+ private readonly moduleRefreshCallbacks: RefreshConfigRegistry = []; //Modules are in boot order
+ private unknownConfigs: ListOf; //keeping so we can save it back
+ private storedConfigs: PartialTxConfigs;
+ private activeConfigs: TxConfigs;
+ private changelog: ConfigChangelogEntry[] = [];
+
+ constructor() {
+ //Load raw file
+ //TODO: create a lock file to prevent starting twice the same config file?
+ let fileRaw;
+ try {
+ fileRaw = fs.readFileSync(this.configFilePath, 'utf8');
+ } catch (error) {
+ fatalError.ConfigStore(10, [
+ 'Unable to read configuration file (filesystem error).',
+ ['Path', this.configFilePath],
+ ['Error', (error as Error).message],
+ ]);
+ }
+
+ //Json parse
+ let fileData: ConfigFileData;
+ try {
+ fileData = JSON.parse(fileRaw);
+ } catch (error) {
+ fatalError.ConfigStore(11, [
+ 'Unable to parse configuration file (invalid JSON).',
+ 'This means the file somehow got corrupted and is not a valid anymore.',
+ ['Path', this.configFilePath],
+ ['Error', (error as Error).message],
+ ]);
+ }
+
+ //Check version & migrate if needed
+ let fileMigrated = false;
+ if (fileData?.version !== CONFIG_VERSION) {
+ try {
+ fileData = migrateConfigFile(fileData);
+ fileMigrated = true;
+ } catch (error) {
+ fatalError.ConfigStore(25, [
+ 'Unable to migrate configuration file.',
+ ['Path', this.configFilePath],
+ ['File version', String(fileData?.version)],
+ ['Supported version', String(CONFIG_VERSION)],
+ ], error);
+ }
+ }
+
+ //Parse & validate
+ try {
+ const configItems = parseConfigFileData(fileData);
+ if (!configItems.length) console.verbose.debug('Empty configuration file.');
+ const config = bootstrapConfigProcessor(configItems, ConfigSchemas_v2, ConfigStore.SchemaDefaults);
+ this.unknownConfigs = config.unknown;
+ this.storedConfigs = config.stored as PartialTxConfigs;
+ this.activeConfigs = config.active as TxConfigs;
+ } catch (error) {
+ fatalError.ConfigStore(14, [
+ 'Unable to process configuration file.',
+ ], error);
+ }
+
+ //If migrated, write the new file
+ if (fileMigrated) {
+ try {
+ this.saveFile(this.storedConfigs);
+ } catch (error) {
+ fatalError.ConfigStore(26, [
+ 'Unable to save the updated config.json file.',
+ ['Path', this.configFilePath],
+ ], error);
+ }
+ }
+
+ //Reflect to global
+ this.updatePublicConfig();
+
+ //Load changelog
+ setImmediate(() => {
+ this.loadChangelog();
+ });
+ }
+
+
+ /**
+ * Mirrors the #config object to the public deep frozen config object
+ */
+ private updatePublicConfig() {
+ (globalThis as any).txConfig = deepFreeze(cloneDeep(this.activeConfigs));
+ }
+
+
+ /**
+ * Returns the stored config object, with only the known keys
+ */
+ public getStoredConfig() {
+ return cloneDeep(this.storedConfigs);
+ }
+
+
+ /**
+ * Returns the changelog
+ * TODO: add filters to be used in pages like ban templates
+ * TODO: increase CCLOG_SIZE_LIMIT to a few hundred
+ * TODO: increase CCLOG_RETENTION to a year, or deprecate it in favor of a full log
+ */
+ public getChangelog() {
+ return cloneDeep(this.changelog);
+ }
+
+
+ /**
+ * Applies an input config object to the stored and active configs, then saves it to the file
+ */
+ public saveConfigs(inputConfig: PartialTxConfigsToSave, author: string | null) {
+ //Process each item
+ const parsedInput = parseConfigFileData(inputConfig);
+ const processed = runtimeConfigProcessor(
+ parsedInput,
+ ConfigSchemas_v2,
+ this.storedConfigs,
+ this.activeConfigs,
+ );
+
+ //If nothing thrown, update the state, file, and
+ this.saveFile(processed.stored);
+ this.storedConfigs = processed.stored as PartialTxConfigs;
+ this.activeConfigs = processed.active as TxConfigs;
+ this.logChanges(author ?? 'txAdmin', processed.storedKeysChanges.list);
+ this.updatePublicConfig(); //before callbacks
+ this.processCallbacks(processed.activeKeysChanges);
+ return processed.storedKeysChanges;
+ }
+
+
+ /**
+ * Saves the config.json file, maintaining the unknown configs
+ */
+ private saveFile(toStore: PartialTxConfigs) {
+ const outFile = {
+ version: CONFIG_VERSION,
+ ...this.unknownConfigs,
+ ...toStore,
+ };
+ fs.writeFileSync(this.configFilePath, JSON.stringify(outFile, null, 2));
+ }
+
+
+ /**
+ * Logs changes to logger and changelog file
+ * FIXME: ignore banlist.templates? or join consequent changes?
+ */
+ private logChanges(author: string, keysUpdated: string[]) {
+ txCore.logger.admin.write(author, `Config changes: ${keysUpdated.join(', ')}`);
+ this.changelog.push({
+ author,
+ ts: Date.now(),
+ keys: keysUpdated,
+ });
+ this.changelog = truncateConfigChangelog(this.changelog);
+ setImmediate(async () => {
+ try {
+ const json = JSON.stringify({
+ version: CCLOG_VERSION,
+ log: this.changelog,
+ });
+ await fsp.writeFile(this.changelogFilePath, json);
+ } catch (error) {
+ console.warn(`Failed to save ${this.changelogFilePath} with message: ${(error as any).message}`);
+ }
+ });
+ }
+
+ /**
+ * Loads the changelog file
+ */
+ private async loadChangelog() {
+ try {
+ const rawFileData = await fsp.readFile(this.changelogFilePath, 'utf8');
+ const fileData = JSON.parse(rawFileData);
+ if (fileData?.version !== CCLOG_VERSION) throw new Error(`invalid_version`);
+ const changelogData = ConfigChangelogFileSchema.parse(fileData);
+ this.changelog = truncateConfigChangelog(changelogData.log);
+ } catch (error) {
+ if ((error as any)?.code === 'ENOENT') {
+ console.verbose.debug(`${this.changelogFilePath} not found, making a new one.`);
+ } else if ((error as any)?.message === 'invalid_version') {
+ console.warn(`Failed to load ${this.changelogFilePath} due to invalid version.`);
+ console.warn('Since this is not a critical file, it will be reset.');
+ } else {
+ console.warn(`Failed to load ${this.changelogFilePath} with message: ${(error as any).message}`);
+ console.warn('Since this is not a critical file, it will be reset.');
+ }
+ }
+ }
+
+
+ /**
+ * Process the callbacks for the modules that registered for config changes
+ */
+ private processCallbacks(updatedConfigs: UpdateConfigKeySet) {
+ for (const txModule of this.moduleRefreshCallbacks) {
+ if (!updatedConfigs.hasMatch(txModule.rules)) continue;
+ setImmediate(() => {
+ try {
+ console.verbose.debug(`Triggering update callback for module ${txModule.moduleName}`);
+ txModule.callback(updatedConfigs);
+ } catch (error) {
+ console.error(`Error in config update callback for module ${txModule.moduleName}: ${(error as any).message}`);
+ console.verbose.dir(error);
+ }
+ });
+ }
+ }
+
+
+ /**
+ * Register a callback to be called when the config is updated
+ */
+ public registerUpdateCallback(moduleName: string, rules: string[], callback: RefreshConfigFunc) {
+ this.moduleRefreshCallbacks.push({
+ moduleName,
+ callback,
+ rules,
+ });
+ }
+}
diff --git a/core/modules/ConfigStore/schema/banlist.ts b/core/modules/ConfigStore/schema/banlist.ts
new file mode 100644
index 0000000..9f6ad07
--- /dev/null
+++ b/core/modules/ConfigStore/schema/banlist.ts
@@ -0,0 +1,92 @@
+import { z } from "zod";
+import { typeDefinedConfig } from "./utils";
+
+import { alphanumeric } from 'nanoid-dictionary';
+import { customAlphabet } from "nanoid";
+import { SYM_FIXER_DEFAULT, SYM_FIXER_FATAL } from "@lib/symbols";
+
+
+
+
+/**
+ * MARK: Ban templates
+ */
+export const BAN_TEMPLATE_ID_LENGTH = 21;
+
+export const genBanTemplateId = customAlphabet(alphanumeric, BAN_TEMPLATE_ID_LENGTH);
+
+export const BanDurationTypeSchema = z.union([
+ z.literal('permanent'),
+ z.object({
+ value: z.number().positive(),
+ unit: z.enum(['hours', 'days', 'weeks', 'months']),
+ }),
+]);
+export type BanDurationType = z.infer;
+
+export const BanTemplatesDataSchema = z.object({
+ id: z.string().length(BAN_TEMPLATE_ID_LENGTH), //nanoid fixed at 21 chars
+ reason: z.string().min(3).max(2048), //should be way less, but just in case
+ duration: BanDurationTypeSchema,
+});
+export type BanTemplatesDataType = z.infer;
+
+//Ensure all templates have unique ids
+export const polishBanTemplatesArray = (input: BanTemplatesDataType[]) => {
+ const ids = new Set();
+ const unique: BanTemplatesDataType[] = [];
+ for (const template of input) {
+ if (ids.has(template.id)) {
+ unique.push({
+ ...template,
+ id: genBanTemplateId(),
+ });
+ } else {
+ unique.push(template);
+ }
+ ids.add(template.id);
+ }
+ return unique;
+}
+
+
+
+/**
+ * MARK: Default
+ */
+const enabled = typeDefinedConfig({
+ name: 'Ban Checking Enabled',
+ default: true,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const rejectionMessage = typeDefinedConfig({
+ name: 'Ban Rejection Message',
+ default: 'You can join http://discord.gg/example to appeal this ban.',
+ validator: z.string(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const requiredHwidMatches = typeDefinedConfig({
+ name: 'Required Ban HWID Matches',
+ default: 1,
+ validator: z.number().int().min(0),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const templates = typeDefinedConfig({
+ name: 'Ban Templates',
+ default: [],
+ validator: BanTemplatesDataSchema.array().transform(polishBanTemplatesArray),
+ //NOTE: if someone messed with their templates and broke it, we don't want to wipe it all out
+ fixer: SYM_FIXER_FATAL,
+});
+
+
+export default {
+ enabled,
+ rejectionMessage,
+ requiredHwidMatches,
+ templates,
+} as const;
diff --git a/core/modules/ConfigStore/schema/discordBot.ts b/core/modules/ConfigStore/schema/discordBot.ts
new file mode 100644
index 0000000..144f8ff
--- /dev/null
+++ b/core/modules/ConfigStore/schema/discordBot.ts
@@ -0,0 +1,69 @@
+import { z } from "zod";
+import { discordSnowflakeSchema, typeDefinedConfig, typeNullableConfig } from "./utils";
+import { defaultEmbedConfigJson, defaultEmbedJson } from "@modules/DiscordBot/defaultJsons";
+import { SYM_FIXER_DEFAULT } from "@lib/symbols";
+
+
+const enabled = typeDefinedConfig({
+ name: 'Bot Enabled',
+ default: false,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const token = typeNullableConfig({
+ name: 'Bot Token',
+ default: null,
+ validator: z.string().min(1).nullable(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const guild = typeNullableConfig({
+ name: 'Server ID',
+ default: null,
+ validator: discordSnowflakeSchema.nullable(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const warningsChannel = typeNullableConfig({
+ name: 'Warnings Channel ID',
+ default: null,
+ validator: discordSnowflakeSchema.nullable(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+
+//We are not validating the JSON, only that it is a string
+export const attemptMinifyJsonString = (input: string) => {
+ try {
+ return JSON.stringify(JSON.parse(input));
+ } catch (error) {
+ return input;
+ }
+};
+
+const embedJson = typeDefinedConfig({
+ name: 'Status Embed JSON',
+ default: defaultEmbedJson,
+ validator: z.string().min(1).transform(attemptMinifyJsonString),
+ //NOTE: no true valiation in here, done in the module only
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const embedConfigJson = typeDefinedConfig({
+ name: 'Status Config JSON',
+ default: defaultEmbedConfigJson,
+ validator: z.string().min(1).transform(attemptMinifyJsonString),
+ //NOTE: no true valiation in here, done in the module only
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+
+export default {
+ enabled,
+ token,
+ guild,
+ warningsChannel,
+ embedJson,
+ embedConfigJson,
+} as const;
diff --git a/core/modules/ConfigStore/schema/gameFeatures.ts b/core/modules/ConfigStore/schema/gameFeatures.ts
new file mode 100644
index 0000000..83ee551
--- /dev/null
+++ b/core/modules/ConfigStore/schema/gameFeatures.ts
@@ -0,0 +1,88 @@
+import { z } from "zod";
+import { typeDefinedConfig } from "./utils";
+import { SYM_FIXER_DEFAULT } from "@lib/symbols";
+
+
+const menuEnabled = typeDefinedConfig({
+ name: 'Menu Enabled',
+ default: true,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const menuAlignRight = typeDefinedConfig({
+ name: 'Align Menu Right',
+ default: false,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const menuPageKey = typeDefinedConfig({
+ name: 'Menu Page Switch Key',
+ default: 'Tab',
+ validator: z.string().min(1),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const playerModePtfx = typeDefinedConfig({
+ name: 'Player Mode Change Effect',
+ default: true,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const hideAdminInPunishments = typeDefinedConfig({
+ name: 'Hide Admin Name In Punishments',
+ default: true,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const hideAdminInMessages = typeDefinedConfig({
+ name: 'Hide Admin Name In Messages',
+ default: false,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const hideDefaultAnnouncement = typeDefinedConfig({
+ name: 'Hide Announcement Notifications',
+ default: false,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const hideDefaultDirectMessage = typeDefinedConfig({
+ name: 'Hide Direct Message Notification',
+ default: false,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const hideDefaultWarning = typeDefinedConfig({
+ name: 'Hide Warning Notification',
+ default: false,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const hideDefaultScheduledRestartWarning = typeDefinedConfig({
+ name: 'Hide Scheduled Restart Warnings',
+ default: false,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+
+export default {
+ menuEnabled,
+ menuAlignRight,
+ menuPageKey,
+ playerModePtfx,
+ hideAdminInPunishments,
+ hideAdminInMessages,
+ hideDefaultAnnouncement,
+ hideDefaultDirectMessage,
+ hideDefaultWarning,
+ hideDefaultScheduledRestartWarning,
+} as const;
diff --git a/core/modules/ConfigStore/schema/general.ts b/core/modules/ConfigStore/schema/general.ts
new file mode 100644
index 0000000..89fbe68
--- /dev/null
+++ b/core/modules/ConfigStore/schema/general.ts
@@ -0,0 +1,28 @@
+import { z } from "zod";
+import { typeDefinedConfig } from "./utils";
+import { SYM_FIXER_DEFAULT } from "@lib/symbols";
+import localeMap from "@shared/localeMap";
+
+
+const serverName = typeDefinedConfig({
+ name: 'Server Name',
+ default: 'change-me',
+ validator: z.string().min(1).max(18),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const language = typeDefinedConfig({
+ name: 'Language',
+ default: 'en',
+ validator: z.string().min(2).refine(
+ (value) => (value === 'custom' || localeMap[value] !== undefined),
+ (value) => ({ message: `Invalid language code \`${value ?? '??'}\`.` }),
+ ),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+
+export default {
+ serverName,
+ language,
+} as const;
diff --git a/core/modules/ConfigStore/schema/index.ts b/core/modules/ConfigStore/schema/index.ts
new file mode 100644
index 0000000..96b78cd
--- /dev/null
+++ b/core/modules/ConfigStore/schema/index.ts
@@ -0,0 +1,55 @@
+import { z } from "zod";
+import { ConfigScope, ListOf } from "./utils";
+import general from "./general";
+import server from "./server";
+import restarter from "./restarter";
+import banlist from "./banlist";
+import whitelist from "./whitelist";
+import discordBot from "./discordBot";
+import gameFeatures from "./gameFeatures";
+import webServer from "./webServer";
+import logger from "./logger";
+import { SYM_RESET_CONFIG } from "@lib/symbols";
+
+
+//Type inference utils
+type InferConfigScopes = IferConfigValues;
+type IferConfigValues = {
+ [K in keyof S]: S[K]['default'] | z.infer;
+}
+type WritableValues = {
+ -readonly [P in keyof T]: T[P]
+};
+type InferConfigScopesToSave = InferConfigValuesToSave>;
+type InferConfigValuesToSave = WritableValues<{
+ [K in keyof S]: S[K]['default'] | z.infer | typeof SYM_RESET_CONFIG;
+}>;
+
+//Exporting the schemas
+export const ConfigSchemas_v2 = {
+ general,
+ server,
+ restarter,
+ banlist,
+ whitelist,
+ discordBot,
+ gameFeatures,
+ webServer,
+ logger,
+} satisfies ListOf;
+
+//Exporting the types
+export type TxConfigScopes = keyof typeof ConfigSchemas_v2;
+export type TxConfigs = {
+ [K in TxConfigScopes]: InferConfigScopes
+};
+export type PartialTxConfigs = Partial<{
+ [K in TxConfigScopes]: Partial>
+}>;
+export type PartialTxConfigsToSave = Partial<{
+ [K in TxConfigScopes]: Partial>
+}>;
+export type ConfigFileData = PartialTxConfigs & { version: number };
+
+//Allow unknown scopes/keys
+export type ConfigScaffold = ListOf>;
diff --git a/core/modules/ConfigStore/schema/logger.ts b/core/modules/ConfigStore/schema/logger.ts
new file mode 100644
index 0000000..996998d
--- /dev/null
+++ b/core/modules/ConfigStore/schema/logger.ts
@@ -0,0 +1,41 @@
+import { z } from "zod";
+import { typeDefinedConfig } from "./utils";
+import { SYM_FIXER_FATAL } from "@lib/symbols";
+
+
+/*
+ The logger module passes the options to the library which is responsible for evaluating them.
+ There has never been strict definitions about those settings in txAdmin.
+ The only exception is setting it to false for disabling the specific logger.
+ Ref: https://github.com/iccicci/rotating-file-stream#options
+*/
+const rfsOptionValidator = z.union([
+ z.literal(false),
+ z.object({}).passthrough(),
+]);
+
+
+//NOTE: don't fallback to default because storage issues might crash the server
+export default {
+ //admin & some system logs
+ admin: typeDefinedConfig({
+ name: 'Admin Logs',
+ default: {},
+ validator: rfsOptionValidator,
+ fixer: SYM_FIXER_FATAL,
+ }),
+ //fxserver output
+ fxserver: typeDefinedConfig({
+ name: 'FXServer Logs',
+ default: {},
+ validator: rfsOptionValidator,
+ fixer: SYM_FIXER_FATAL,
+ }),
+ //in-game logs
+ server: typeDefinedConfig({
+ name: 'Server Logs',
+ default: {},
+ validator: rfsOptionValidator,
+ fixer: SYM_FIXER_FATAL,
+ }),
+} as const;
diff --git a/core/modules/ConfigStore/schema/oldConfig.ts b/core/modules/ConfigStore/schema/oldConfig.ts
new file mode 100644
index 0000000..c9e70c0
--- /dev/null
+++ b/core/modules/ConfigStore/schema/oldConfig.ts
@@ -0,0 +1,168 @@
+import { dequal } from 'dequal/lite';
+import parseArgsStringToArgv from "string-argv";
+import { ConfigSchemas_v2 } from "./index";
+import { ListOf } from "./utils";
+import { genBanTemplateId } from "./banlist";
+import { getConfigDefaults } from "../configParser";
+import { confx } from "../utils";
+
+const restructureOldConfig = (old: any) => {
+ //Apply the legacy migrations (mutation)
+ old.playerDatabase ??= old.playerDatabase ?? old.playerController ?? {};
+ if (old.global.language === 'pt_PT' || old.global.language === 'pt_BR') {
+ old.global.language = 'pt';
+ }
+ if (typeof old.monitor.resourceStartingTolerance === 'string') {
+ old.monitor.resourceStartingTolerance = parseInt(old.monitor.resourceStartingTolerance);
+ if (isNaN(old.monitor.resourceStartingTolerance)) {
+ old.monitor.resourceStartingTolerance = 120;
+ }
+ }
+
+ //Remap the old config to the new structure
+ const remapped: TxConfigs = {
+ general: { //NOTE:renamed
+ serverName: old?.global?.serverName,
+ language: old?.global?.language,
+ },
+ webServer: {
+ disableNuiSourceCheck: old?.webServer?.disableNuiSourceCheck,
+ limiterMinutes: old?.webServer?.limiterMinutes,
+ limiterAttempts: old?.webServer?.limiterAttempts,
+ },
+ discordBot: {
+ enabled: old?.discordBot?.enabled,
+ token: old?.discordBot?.token,
+ guild: old?.discordBot?.guild,
+ warningsChannel: old?.discordBot?.announceChannel, //NOTE:renamed
+ embedJson: old?.discordBot?.embedJson,
+ embedConfigJson: old?.discordBot?.embedConfigJson,
+ },
+ server: {//NOTE:renamed
+ dataPath: old?.fxRunner?.serverDataPath, //NOTE:renamed
+ cfgPath: old?.fxRunner?.cfgPath,
+ startupArgs: old?.fxRunner?.commandLine, //NOTE:renamed
+ onesync: old?.fxRunner?.onesync,
+ autoStart: old?.fxRunner?.autostart, //NOTE:renamed
+ quiet: old?.fxRunner?.quiet,
+ shutdownNoticeDelayMs: old?.fxRunner?.shutdownNoticeDelay, //NOTE:renamed
+ restartSpawnDelayMs: old?.fxRunner?.restartDelay, //NOTE:renamed
+ },
+ restarter: {
+ schedule: old?.monitor?.restarterSchedule, //NOTE:renamed
+ bootGracePeriod: old?.monitor?.cooldown, //NOTE:renamed
+ resourceStartingTolerance: old?.monitor?.resourceStartingTolerance,
+ },
+ banlist: { //NOTE: All Renamed
+ enabled: old?.playerDatabase?.onJoinCheckBan,
+ rejectionMessage: old?.playerDatabase?.banRejectionMessage,
+ requiredHwidMatches: old?.playerDatabase?.requiredBanHwidMatches,
+ templates: old?.banTemplates,
+ },
+ whitelist: { //NOTE: All Renamed
+ mode: old?.playerDatabase?.whitelistMode,
+ rejectionMessage: old?.playerDatabase?.whitelistRejectionMessage,
+ discordRoles: old?.playerDatabase?.whitelistedDiscordRoles,
+ },
+ gameFeatures: {
+ menuEnabled: old?.global?.menuEnabled,
+ menuAlignRight: old?.global?.menuAlignRight,
+ menuPageKey: old?.global?.menuPageKey,
+ playerModePtfx: true, //NOTE: new config
+ hideAdminInPunishments: old?.global?.hideAdminInPunishments,
+ hideAdminInMessages: old?.global?.hideAdminInMessages,
+ hideDefaultAnnouncement: old?.global?.hideDefaultAnnouncement,
+ hideDefaultDirectMessage: old?.global?.hideDefaultDirectMessage,
+ hideDefaultWarning: old?.global?.hideDefaultWarning,
+ hideDefaultScheduledRestartWarning: old?.global?.hideDefaultScheduledRestartWarning,
+ },
+ logger: {
+ admin: old?.logger?.admin,
+ fxserver: old?.logger?.fxserver,
+ server: old?.logger?.server,
+ },
+ }
+
+ return remapped;
+};
+
+
+export const migrateOldConfig = (old: any) => {
+ //Get the old configs in the new structure
+ const remapped = restructureOldConfig(old) as any;
+
+ //Some migrations before comparing because defaults changed
+ if (typeof remapped.restarter?.bootGracePeriod === 'number') {
+ remapped.restarter.bootGracePeriod = Math.round(remapped.restarter.bootGracePeriod);
+ if (remapped.restarter.bootGracePeriod === 60) {
+ remapped.restarter.bootGracePeriod = 45;
+ }
+ }
+ if (typeof remapped.server?.shutdownNoticeDelayMs === 'number') {
+ remapped.server.shutdownNoticeDelayMs *= 1000;
+ }
+ if (remapped.server?.restartSpawnDelayMs === 750) {
+ remapped.server.restartSpawnDelayMs = 500;
+ }
+ if (remapped.whitelist?.mode === 'guildMember') {
+ remapped.whitelist.mode = 'discordMember';
+ }
+ if (remapped.whitelist?.mode === 'guildRoles') {
+ remapped.whitelist.mode = 'discordRoles';
+ }
+
+ //Migrating the menu ptfx convar (can't do anything about it being set in server.cfg tho)
+ if (typeof remapped.server?.startupArgs === 'string') {
+ try {
+ const str = remapped.server.startupArgs.trim();
+ const convarSetRegex = /\+setr?\s+['"]?txAdmin-menuPtfxDisable['"]?\s+['"]?(?\w+)['"]?\s?/g;
+ const matches = [...str.matchAll(convarSetRegex)];
+ if (matches.length) {
+ const valueSet = matches[matches.length - 1].groups?.value;
+ remapped.gameFeatures.playerModePtfx = valueSet !== 'true';
+ remapped.server.startupArgs = str.replaceAll(convarSetRegex, '');
+ }
+ } catch (error) {
+ console.warn('Failed to migrate the menuPtfxDisable convar. Assuming it\'s unset.');
+ console.verbose.dir(error);
+ }
+ remapped.server.startupArgs = remapped.server.startupArgs.length
+ ? parseArgsStringToArgv(remapped.server.startupArgs)
+ : [];
+ }
+
+ //Removing stuff from unconfigured profile
+ if (remapped.general?.serverName === null) {
+ delete remapped.general.serverName;
+ }
+ if (remapped.server?.cfgPath === null) {
+ delete remapped.server.cfgPath;
+ }
+
+ //Extract just the non-default values
+ const baseConfigs = getConfigDefaults(ConfigSchemas_v2) as TxConfigs;
+ const justNonDefaults: ListOf = {};
+ for (const [scopeName, scopeConfigs] of Object.entries(baseConfigs)) {
+ for (const [configKey, configDefault] of Object.entries(scopeConfigs)) {
+ const configValue = confx(remapped).get(scopeName, configKey);
+ if (configValue === undefined) continue;
+ if (!dequal(configValue, configDefault)) {
+ confx(justNonDefaults).set(scopeName, configKey, configValue);
+ }
+ }
+ }
+
+ //Last migrations
+ if (typeof justNonDefaults.general?.serverName === 'string') {
+ justNonDefaults.general.serverName = justNonDefaults.general.serverName.slice(0, 18);
+ }
+ if (Array.isArray(justNonDefaults.banlist?.templates)) {
+ for (const tpl of justNonDefaults.banlist.templates) {
+ if (typeof tpl.id !== 'string') continue;
+ tpl.id = genBanTemplateId();
+ }
+ }
+
+ //Final object
+ return justNonDefaults;
+}
diff --git a/core/modules/ConfigStore/schema/restarter.ts b/core/modules/ConfigStore/schema/restarter.ts
new file mode 100644
index 0000000..26a8ccb
--- /dev/null
+++ b/core/modules/ConfigStore/schema/restarter.ts
@@ -0,0 +1,39 @@
+import { z } from "zod";
+import { typeDefinedConfig } from "./utils";
+import { SYM_FIXER_DEFAULT } from "@lib/symbols";
+import { parseSchedule, regexHoursMinutes } from "@lib/misc";
+
+export const polishScheduleTimesArray = (input: string[]) => {
+ return parseSchedule(input).valid.map((v) => v.string);
+};
+
+const schedule = typeDefinedConfig({
+ name: 'Restart Schedule',
+ default: [],
+ validator: z.string().regex(regexHoursMinutes).array().transform(polishScheduleTimesArray),
+ fixer: (input: any) => {
+ if(!Array.isArray(input)) return [];
+ return polishScheduleTimesArray(input);
+ },
+});
+
+const bootGracePeriod = typeDefinedConfig({
+ name: 'Boot Grace Period',
+ default: 45,
+ validator: z.number().int().min(15),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const resourceStartingTolerance = typeDefinedConfig({
+ name: 'Resource Starting Tolerance',
+ default: 90,
+ validator: z.number().int().min(30),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+
+export default {
+ schedule,
+ bootGracePeriod,
+ resourceStartingTolerance,
+} as const;
diff --git a/core/modules/ConfigStore/schema/server.ts b/core/modules/ConfigStore/schema/server.ts
new file mode 100644
index 0000000..374d0a2
--- /dev/null
+++ b/core/modules/ConfigStore/schema/server.ts
@@ -0,0 +1,72 @@
+import { z } from "zod";
+import { typeDefinedConfig, typeNullableConfig } from "./utils";
+import { SYM_FIXER_DEFAULT, SYM_FIXER_FATAL } from "@lib/symbols";
+
+
+const dataPath = typeNullableConfig({
+ name: 'Server Data Path',
+ default: null,
+ validator: z.string().min(1).nullable(),
+ fixer: SYM_FIXER_FATAL,
+});
+
+const cfgPath = typeDefinedConfig({
+ name: 'CFG File Path',
+ default: 'server.cfg',
+ validator: z.string().min(1),
+ fixer: SYM_FIXER_FATAL,
+});
+
+const startupArgs = typeDefinedConfig({
+ name: 'Startup Arguments',
+ default: [],
+ validator: z.string().array(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const onesync = typeDefinedConfig({
+ name: 'OneSync',
+ default: 'on',
+ validator: z.enum(['on', 'legacy', 'off']),
+ fixer: SYM_FIXER_FATAL,
+});
+
+const autoStart = typeDefinedConfig({
+ name: 'Autostart',
+ default: true,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const quiet = typeDefinedConfig({
+ name: 'Quiet Mode',
+ default: false,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const shutdownNoticeDelayMs = typeDefinedConfig({
+ name: 'Shutdown Notice Delay',
+ default: 5000,
+ validator: z.number().int().min(0).max(60_000),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const restartSpawnDelayMs = typeDefinedConfig({
+ name: 'Restart Spawn Delay',
+ default: 500,
+ validator: z.number().int().min(0).max(15_000),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+
+export default {
+ dataPath,
+ cfgPath,
+ startupArgs,
+ onesync,
+ autoStart,
+ quiet,
+ shutdownNoticeDelayMs,
+ restartSpawnDelayMs,
+} as const;
diff --git a/core/modules/ConfigStore/schema/utils.ts b/core/modules/ConfigStore/schema/utils.ts
new file mode 100644
index 0000000..58e7678
--- /dev/null
+++ b/core/modules/ConfigStore/schema/utils.ts
@@ -0,0 +1,53 @@
+import { z } from 'zod';
+import { SYM_FIXER_DEFAULT, SYM_FIXER_FATAL } from '@lib/symbols';
+import consts from '@shared/consts';
+import { fromError } from 'zod-validation-error';
+
+
+/**
+ * MARK: Types
+ */
+//Definitions
+export type ConfigScope = ListOf;
+export type ConfigItemFixer = (value: any) => T;
+interface BaseConfigItem {
+ name: string;
+ validator: z.Schema;
+ fixer: typeof SYM_FIXER_FATAL | typeof SYM_FIXER_DEFAULT | ConfigItemFixer;
+}
+
+//Utilities
+export type ListOf = { [key: string]: T };
+export interface DefinedConfigItem extends BaseConfigItem {
+ default: T extends null ? never : T;
+}
+export interface NulledConfigItem extends BaseConfigItem {
+ default: null;
+}
+export type ScopeConfigItem = DefinedConfigItem | NulledConfigItem;
+
+//NOTE: Split into two just because I couldn't figure out how to make the default value be null
+export const typeDefinedConfig = (config: DefinedConfigItem): DefinedConfigItem => config;
+export const typeNullableConfig = (config: NulledConfigItem): NulledConfigItem => config;
+
+
+/**
+ * MARK: Common Schemas
+ */
+export const discordSnowflakeSchema = z.string().regex(
+ consts.regexDiscordSnowflake,
+ 'The ID should be a 17-20 digit number.'
+);
+
+
+/**
+ * MARK: Utilities
+ */
+export const getSchemaChainError = (chain: [schema: ScopeConfigItem, val: any][]) => {
+ for (const [schema, val] of chain) {
+ const res = schema.validator.safeParse(val);
+ if (!res.success) {
+ return fromError(res.error, { prefix: schema.name }).message;
+ }
+ }
+}
diff --git a/core/modules/ConfigStore/schema/webServer.ts b/core/modules/ConfigStore/schema/webServer.ts
new file mode 100644
index 0000000..5b79307
--- /dev/null
+++ b/core/modules/ConfigStore/schema/webServer.ts
@@ -0,0 +1,32 @@
+import { z } from "zod";
+import { typeDefinedConfig } from "./utils";
+import { SYM_FIXER_DEFAULT } from "@lib/symbols";
+
+
+const disableNuiSourceCheck = typeDefinedConfig({
+ name: 'Disable NUI Source Check',
+ default: false,
+ validator: z.boolean(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const limiterMinutes = typeDefinedConfig({
+ name: 'Rate Limiter Minutes',
+ default: 15,
+ validator: z.number().int().min(1),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const limiterAttempts = typeDefinedConfig({
+ name: 'Rate Limiter Attempts',
+ default: 10,
+ validator: z.number().int().min(5),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+
+export default {
+ disableNuiSourceCheck,
+ limiterMinutes,
+ limiterAttempts,
+} as const;
diff --git a/core/modules/ConfigStore/schema/whitelist.ts b/core/modules/ConfigStore/schema/whitelist.ts
new file mode 100644
index 0000000..c2f466c
--- /dev/null
+++ b/core/modules/ConfigStore/schema/whitelist.ts
@@ -0,0 +1,43 @@
+import { z } from "zod";
+import { discordSnowflakeSchema, typeDefinedConfig } from "./utils";
+import { SYM_FIXER_DEFAULT } from "@lib/symbols";
+import consts from "@shared/consts";
+
+
+const mode = typeDefinedConfig({
+ name: 'Whitelist Mode',
+ default: 'disabled',
+ validator: z.enum(['disabled', 'adminOnly', 'approvedLicense', 'discordMember', 'discordRoles']),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+const rejectionMessage = typeDefinedConfig({
+ name: 'Whitelist Rejection Message',
+ default: 'Please join http://discord.gg/example and request to be whitelisted.',
+ validator: z.string(),
+ fixer: SYM_FIXER_DEFAULT,
+});
+
+export const polishDiscordRolesArray = (input: string[]) => {
+ const unique = [...new Set(input)];
+ unique.sort((a, b) => Number(a) - Number(b));
+ return unique;
+}
+
+const discordRoles = typeDefinedConfig({
+ name: 'Whitelisted Discord Roles',
+ default: [],
+ validator: discordSnowflakeSchema.array().transform(polishDiscordRolesArray),
+ fixer: (input: any) => {
+ if (!Array.isArray(input)) return [];
+ const valid = input.filter(item => consts.regexDiscordSnowflake.test(item));
+ return polishDiscordRolesArray(valid);
+ },
+});
+
+
+export default {
+ mode,
+ rejectionMessage,
+ discordRoles,
+} as const;
diff --git a/core/modules/ConfigStore/utils.test.ts b/core/modules/ConfigStore/utils.test.ts
new file mode 100644
index 0000000..fe3444d
--- /dev/null
+++ b/core/modules/ConfigStore/utils.test.ts
@@ -0,0 +1,157 @@
+import { suite, it, expect } from 'vitest';
+import { ConfigScaffold } from './schema';
+import { confx, UpdateConfigKeySet } from './utils';
+
+
+suite('confx utility', () => {
+ it('should check if a value exists (has)', () => {
+ const config: ConfigScaffold = {
+ scope1: {
+ key1: 'value1',
+ },
+ };
+ const conf = confx(config);
+
+ expect(conf.has('scope1', 'key1')).toBe(true);
+ expect(conf.has('scope1', 'key2')).toBe(false);
+ expect(conf.has('scope2', 'key1')).toBe(false);
+ });
+
+ it('should retrieve a value (get)', () => {
+ const config: ConfigScaffold = {
+ scope1: {
+ key1: 'value1',
+ },
+ };
+ const conf = confx(config);
+
+ expect(conf.get('scope1', 'key1')).toBe('value1');
+ expect(conf.get('scope1', 'key2')).toBeUndefined();
+ expect(conf.get('scope2', 'key1')).toBeUndefined();
+ });
+
+ it('should set a value (set)', () => {
+ const config: ConfigScaffold = {};
+ const conf = confx(config);
+
+ conf.set('scope1', 'key1', 'value1');
+ expect(config.scope1?.key1).toBe('value1');
+
+ conf.set('scope1', 'key2', 'value2');
+ expect(config.scope1?.key2).toBe('value2');
+ });
+
+ it('should unset a value (unset)', () => {
+ const config: ConfigScaffold = {
+ scope1: {
+ key1: 'value1',
+ key2: 'value2',
+ },
+ };
+ const conf = confx(config);
+
+ conf.unset('scope1', 'key1');
+ expect(config.scope1?.key1).toBeUndefined();
+ expect(config.scope1?.key2).toBe('value2');
+
+ conf.unset('scope1', 'key2');
+ expect(config.scope1).toBeUndefined();
+ });
+
+ it('should handle nested configurations properly', () => {
+ const config: ConfigScaffold = {
+ scope1: {
+ key1: { nested: 'value' },
+ },
+ };
+ const conf = confx(config);
+
+ expect(conf.get('scope1', 'key1')).toEqual({ nested: 'value' });
+ conf.set('scope1', 'key2', { another: 'value' });
+ expect(config.scope1?.key2).toEqual({ another: 'value' });
+
+ conf.unset('scope1', 'key1');
+ expect(config.scope1?.key1).toBeUndefined();
+ });
+});
+
+
+suite('UpdateConfigKeySet', () => {
+ it('should add keys with scope and key separately', () => {
+ const set = new UpdateConfigKeySet();
+ set.add('example', 'serverName');
+ expect(set.raw).toEqual([{
+ full: 'example.serverName',
+ scope: 'example',
+ key: 'serverName'
+ }]);
+ });
+
+ it('should add keys with dot notation', () => {
+ const set = new UpdateConfigKeySet();
+ set.add('example.serverName');
+ expect(set.raw).toEqual([{
+ full: 'example.serverName',
+ scope: 'example',
+ key: 'serverName'
+ }]);
+ });
+
+ it('should match exact keys', () => {
+ const set = new UpdateConfigKeySet();
+ set.add('example', 'serverName');
+ expect(set.hasMatch('example.serverName')).toBe(true);
+ expect(set.hasMatch('example.enabled')).toBe(false);
+ });
+
+ it('should match wildcard patterns when checking', () => {
+ const set = new UpdateConfigKeySet();
+ set.add('example', 'serverName');
+ set.add('example', 'enabled');
+
+ expect(set.hasMatch('example.*')).toBe(true);
+ expect(set.hasMatch('server.*')).toBe(false);
+ expect(set.hasMatch('example.whatever')).toBe(false);
+ expect(set.hasMatch('*.serverName')).toBe(true);
+ expect(set.hasMatch('*.*')).toBe(true);
+ });
+
+ it('should match when providing an array of patterns', () => {
+ const set = new UpdateConfigKeySet();
+ set.add('example', 'serverName');
+ set.add('monitor', 'enabled');
+
+ expect(set.hasMatch(['example.serverName', 'monitor.status'])).toBe(true);
+ expect(set.hasMatch(['server.*', 'example.*'])).toBe(true);
+ expect(set.hasMatch(['other.thing', 'another.config'])).toBe(false);
+ expect(set.hasMatch(['*.enabled', '*.disabled'])).toBe(true);
+ expect(set.hasMatch([])).toBe(false);
+ });
+
+ it('should not allow adding wildcard', () => {
+ const set = new UpdateConfigKeySet();
+ expect(() => set.add('example.*')).toThrow();
+ expect(() => set.add('example', '*')).toThrow();
+ expect(() => set.add('*.example')).toThrow();
+ expect(() => set.add('*', 'example')).toThrow();
+ expect(() => set.add('*.*')).toThrow();
+ expect(() => set.add('*', '*')).toThrow();
+ });
+
+ it('should track size correctly', () => {
+ const set = new UpdateConfigKeySet();
+ expect(set.size).toBe(0);
+ set.add('example', 'serverName');
+ expect(set.size).toBe(1);
+ set.add('example.enabled');
+ expect(set.size).toBe(2);
+ });
+
+ it('should list all added items', () => {
+ const set = new UpdateConfigKeySet();
+ set.add('example', 'serverName');
+ expect(set.list).toEqual(['example.serverName']);
+ set.add('example', 'enabled');
+ expect(set.list).toEqual(['example.serverName','example.enabled']);
+ });
+});
diff --git a/core/modules/ConfigStore/utils.ts b/core/modules/ConfigStore/utils.ts
new file mode 100644
index 0000000..5ac513d
--- /dev/null
+++ b/core/modules/ConfigStore/utils.ts
@@ -0,0 +1,120 @@
+import type { RefreshConfigKey } from "./index";
+import { ConfigScaffold } from "./schema";
+
+
+/**
+ * A utility for manipulating a configuration scaffold with nested key-value structures.
+ * Provides convenient methods to check, retrieve, set, and remove values in the configuration object.
+ */
+export const confx = (cfg: any) => {
+ return {
+ //Check if the config has a value defined
+ has: (scope: string, key: string) => {
+ return scope in cfg && key in cfg[scope] && cfg[scope][key] !== undefined;
+ },
+ //Get a value from the config
+ get: (scope: string, key: string) => {
+ return cfg[scope]?.[key] as any | undefined;
+ },
+ //Set a value in the config
+ set: (scope: string, key: string, value: any) => {
+ cfg[scope] ??= {};
+ cfg[scope][key] = value;
+ },
+ //Remove a value from the config
+ unset: (scope: string, key: string) => {
+ let deleted = false;
+ if (scope in cfg && key in cfg[scope]) {
+ delete cfg[scope][key];
+ deleted = true;
+ if (Object.keys(cfg[scope]).length === 0) {
+ delete cfg[scope];
+ }
+ }
+ return deleted;
+ },
+ };
+};
+
+
+/**
+ * Not really intended for use, but it's a more type-safe version if you need it
+ */
+export const confxTyped = (cfg: T) => {
+ return {
+ //Check if the config has a value defined
+ has: (scope: keyof T, key: keyof T[typeof scope]) => {
+ return scope in cfg && key in cfg[scope] && cfg[scope][key] !== undefined;
+ },
+ //Get a value from the config
+ get: (scope: keyof T, key: keyof T[typeof scope]) => {
+ if (scope in cfg && key in cfg[scope]) {
+ return cfg[scope][key] as T[typeof scope][typeof key];
+ } else {
+ return undefined;
+ }
+ },
+ //Set a value in the config
+ set: (scope: keyof T, key: keyof T[typeof scope], value: any) => {
+ cfg[scope] ??= {} as T[typeof scope];
+ cfg[scope][key] = value;
+ },
+ //Remove a value from the config
+ unset: (scope: keyof T, key: keyof T[typeof scope]) => {
+ if (scope in cfg && key in cfg[scope]) {
+ delete cfg[scope][key];
+ if (Object.keys(cfg[scope]).length === 0) {
+ delete cfg[scope];
+ }
+ }
+ },
+ };
+};
+
+
+/**
+ * Helper class to deal with config keys
+ */
+export class UpdateConfigKeySet {
+ public readonly raw: RefreshConfigKey[] = [];
+
+ public add(input1: string, input2?: string) {
+ let full, scope, key;
+ if (input2) {
+ full = `${input1}.${input2}`;
+ scope = input1;
+ key = input2;
+ } else {
+ full = input1;
+ [scope, key] = input1.split('.');
+ }
+ if (full.includes('*')) {
+ throw new Error('Wildcards are not allowed when adding config keys');
+ }
+ this.raw.push({ full, scope, key });
+ }
+
+ private _hasMatch(rule: string) {
+ const [inputScope, inputKey] = rule.split('.');
+ return this.raw.some(rawCfg =>
+ (inputScope === '*' || rawCfg.scope === inputScope) &&
+ (inputKey === '*' || rawCfg.key === inputKey)
+ );
+ }
+
+ public hasMatch(rule: string | string[]) {
+ if (Array.isArray(rule)) {
+ return rule.some(f => this._hasMatch(f));
+ } else {
+ return this._hasMatch(rule);
+ }
+ }
+
+ get size() {
+ return this.raw.length;
+ }
+
+ get list() {
+ return this.raw.map(x => x.full);
+ }
+}
diff --git a/core/modules/Database/dao/actions.ts b/core/modules/Database/dao/actions.ts
new file mode 100644
index 0000000..16379ab
--- /dev/null
+++ b/core/modules/Database/dao/actions.ts
@@ -0,0 +1,240 @@
+import { cloneDeep } from 'lodash-es';
+import { DbInstance, SavePriority } from "../instance";
+import { DatabaseActionBanType, DatabaseActionType, DatabaseActionWarnType } from "../databaseTypes";
+import { genActionID } from "../dbUtils";
+import { now } from '@lib/misc';
+import consoleFactory from '@lib/console';
+const console = consoleFactory('DatabaseDao');
+
+
+/**
+ * Data access object for the database "actions" collection.
+ */
+export default class ActionsDao {
+ constructor(private readonly db: DbInstance) { }
+
+ private get dbo() {
+ if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`);
+ return this.db.obj;
+ }
+
+ private get chain() {
+ if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`);
+ return this.db.obj.chain;
+ }
+
+
+ /**
+ * Searches for an action in the database by the id, returns action or null if not found
+ */
+ findOne(actionId: string): DatabaseActionType | null {
+ if (typeof actionId !== 'string' || !actionId.length) throw new Error('Invalid actionId.');
+
+ //Performing search
+ const a = this.chain.get('actions')
+ .find({ id: actionId })
+ .cloneDeep()
+ .value();
+ return (typeof a === 'undefined') ? null : a;
+ }
+
+
+ /**
+ * Searches for any registered action in the database by a list of identifiers and optional filters
+ * Usage example: findMany(['license:xxx'], undefined, {type: 'ban', revocation.timestamp: null})
+ */
+ findMany(
+ idsArray: string[],
+ hwidsArray?: string[],
+ customFilter: ((action: DatabaseActionType) => action is T) | object = {}
+ ): T[] {
+ if (!Array.isArray(idsArray)) throw new Error('idsArray should be an array');
+ if (hwidsArray && !Array.isArray(hwidsArray)) throw new Error('hwidsArray should be an array or undefined');
+ const idsFilter = (action: DatabaseActionType) => idsArray.some((fi) => action.ids.includes(fi))
+ const hwidsFilter = (action: DatabaseActionType) => {
+ if ('hwids' in action && action.hwids) {
+ const count = hwidsArray!.filter((fi) => action.hwids?.includes(fi)).length;
+ return count >= txConfig.banlist.requiredHwidMatches;
+ } else {
+ return false;
+ }
+ }
+
+ try {
+ //small optimization
+ const idsMatchFilter = hwidsArray && hwidsArray.length && txConfig.banlist.requiredHwidMatches
+ ? (a: DatabaseActionType) => idsFilter(a) || hwidsFilter(a)
+ : (a: DatabaseActionType) => idsFilter(a)
+
+ return this.chain.get('actions')
+ .filter(customFilter as (a: DatabaseActionType) => a is T)
+ .filter(idsMatchFilter)
+ .cloneDeep()
+ .value();
+ } catch (error) {
+ const msg = `Failed to search for a registered action database with error: ${(error as Error).message}`;
+ console.verbose.error(msg);
+ throw new Error(msg);
+ }
+ }
+
+
+ /**
+ * Registers a ban action and returns its id
+ */
+ registerBan(
+ ids: string[],
+ author: string,
+ reason: string,
+ expiration: number | false,
+ playerName: string | false = false,
+ hwids?: string[], //only used for bans
+ ): string {
+ //Sanity check
+ if (!Array.isArray(ids) || !ids.length) throw new Error('Invalid ids array.');
+ if (typeof author !== 'string' || !author.length) throw new Error('Invalid author.');
+ if (typeof reason !== 'string' || !reason.length) throw new Error('Invalid reason.');
+ if (expiration !== false && (typeof expiration !== 'number')) throw new Error('Invalid expiration.');
+ if (playerName !== false && (typeof playerName !== 'string' || !playerName.length)) throw new Error('Invalid playerName.');
+ if (hwids && !Array.isArray(hwids)) throw new Error('Invalid hwids array.');
+
+ //Saves it to the database
+ const timestamp = now();
+ try {
+ const actionID = genActionID(this.dbo, 'ban');
+ const toDB: DatabaseActionBanType = {
+ id: actionID,
+ type: 'ban',
+ ids,
+ hwids,
+ playerName,
+ reason,
+ author,
+ timestamp,
+ expiration,
+ revocation: {
+ timestamp: null,
+ author: null,
+ },
+ };
+ this.chain.get('actions')
+ .push(toDB)
+ .value();
+ this.db.writeFlag(SavePriority.HIGH);
+ return actionID;
+ } catch (error) {
+ let msg = `Failed to register ban to database with message: ${(error as Error).message}`;
+ console.error(msg);
+ console.verbose.dir(error);
+ throw error;
+ }
+ }
+
+
+ /**
+ * Registers a warn action and returns its id
+ */
+ registerWarn(
+ ids: string[],
+ author: string,
+ reason: string,
+ playerName: string | false = false,
+ ): string {
+ //Sanity check
+ if (!Array.isArray(ids) || !ids.length) throw new Error('Invalid ids array.');
+ if (typeof author !== 'string' || !author.length) throw new Error('Invalid author.');
+ if (typeof reason !== 'string' || !reason.length) throw new Error('Invalid reason.');
+ if (playerName !== false && (typeof playerName !== 'string' || !playerName.length)) throw new Error('Invalid playerName.');
+
+ //Saves it to the database
+ const timestamp = now();
+ try {
+ const actionID = genActionID(this.dbo, 'warn');
+ const toDB: DatabaseActionWarnType = {
+ id: actionID,
+ type: 'warn',
+ ids,
+ playerName,
+ reason,
+ author,
+ timestamp,
+ expiration: false,
+ acked: false,
+ revocation: {
+ timestamp: null,
+ author: null,
+ },
+ };
+ this.chain.get('actions')
+ .push(toDB)
+ .value();
+ this.db.writeFlag(SavePriority.HIGH);
+ return actionID;
+ } catch (error) {
+ let msg = `Failed to register warn to database with message: ${(error as Error).message}`;
+ console.error(msg);
+ console.verbose.dir(error);
+ throw error;
+ }
+ }
+
+ /**
+ * Marks a warning as acknowledged
+ */
+ ackWarn(actionId: string) {
+ if (typeof actionId !== 'string' || !actionId.length) throw new Error('Invalid actionId.');
+
+ try {
+ const action = this.chain.get('actions')
+ .find({ id: actionId })
+ .value();
+ if (!action) throw new Error(`action not found`);
+ if (action.type !== 'warn') throw new Error(`action is not a warn`);
+ action.acked = true;
+ this.db.writeFlag(SavePriority.MEDIUM);
+ } catch (error) {
+ const msg = `Failed to ack warn with message: ${(error as Error).message}`;
+ console.error(msg);
+ console.verbose.dir(error);
+ throw error;
+ }
+ }
+
+
+ /**
+ * Revoke an action (ban, warn)
+ */
+ revoke(
+ actionId: string,
+ author: string,
+ allowedTypes: string[] | true = true
+ ): DatabaseActionType {
+ if (typeof actionId !== 'string' || !actionId.length) throw new Error('Invalid actionId.');
+ if (typeof author !== 'string' || !author.length) throw new Error('Invalid author.');
+ if (allowedTypes !== true && !Array.isArray(allowedTypes)) throw new Error('Invalid allowedTypes.');
+
+ try {
+ const action = this.chain.get('actions')
+ .find({ id: actionId })
+ .value();
+
+ if (!action) throw new Error(`action not found`);
+ if (allowedTypes !== true && !allowedTypes.includes(action.type)) {
+ throw new Error(`you do not have permission to revoke this action`);
+ }
+
+ action.revocation = {
+ timestamp: now(),
+ author,
+ };
+ this.db.writeFlag(SavePriority.HIGH);
+ return cloneDeep(action);
+
+ } catch (error) {
+ const msg = `Failed to revoke action with message: ${(error as Error).message}`;
+ console.error(msg);
+ console.verbose.dir(error);
+ throw error;
+ }
+ }
+}
diff --git a/core/modules/Database/dao/cleanup.ts b/core/modules/Database/dao/cleanup.ts
new file mode 100644
index 0000000..ec220ab
--- /dev/null
+++ b/core/modules/Database/dao/cleanup.ts
@@ -0,0 +1,140 @@
+import { DbInstance, SavePriority } from "../instance";
+import consoleFactory from '@lib/console';
+import { DatabasePlayerType, DatabaseWhitelistApprovalsType, DatabaseWhitelistRequestsType } from '../databaseTypes';
+import { now } from '@lib/misc';
+const console = consoleFactory('DatabaseDao');
+
+
+/**
+ * Data access object for cleaning up the database.
+ */
+export default class CleanupDao {
+ constructor(private readonly db: DbInstance) { }
+
+ private get dbo() {
+ if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`);
+ return this.db.obj;
+ }
+
+ private get chain() {
+ if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`);
+ return this.db.obj.chain;
+ }
+
+
+ /**
+ * Cleans the database by removing every entry that matches the provided filter function.
+ * @returns {number} number of removed items
+ */
+ bulkRemove(
+ tableName: 'players' | 'actions' | 'whitelistApprovals' | 'whitelistRequests',
+ filterFunc: Function
+ ): number {
+ if (!Array.isArray(this.dbo.data[tableName])) throw new Error('Table selected isn\'t an array.');
+ if (typeof filterFunc !== 'function') throw new Error('filterFunc must be a function.');
+
+ try {
+ this.db.writeFlag(SavePriority.HIGH);
+ const removed = this.chain.get(tableName)
+ .remove(filterFunc as any)
+ .value();
+ return removed.length;
+ } catch (error) {
+ const msg = `Failed to clean database with error: ${(error as Error).message}`;
+ console.verbose.error(msg);
+ throw new Error(msg);
+ }
+ }
+
+
+ /**
+ * Cleans the hwids from the database.
+ * @returns {number} number of removed HWIDs
+ */
+ wipeHwids(
+ fromPlayers: boolean,
+ fromBans: boolean,
+ ): number {
+ if (!Array.isArray(this.dbo.data.players)) throw new Error('Players table isn\'t an array yet.');
+ if (!Array.isArray(this.dbo.data.players)) throw new Error('Actions table isn\'t an array yet.');
+ if (typeof fromPlayers !== 'boolean' || typeof fromBans !== 'boolean') throw new Error('The parameters should be booleans.');
+
+ try {
+ this.db.writeFlag(SavePriority.HIGH);
+ let removed = 0;
+ if (fromPlayers) {
+ this.chain.get('players')
+ .map(player => {
+ removed += player.hwids.length;
+ player.hwids = [];
+ return player;
+ })
+ .value();
+ }
+ if (fromBans)
+ this.chain.get('actions')
+ .map(action => {
+ if (action.type !== 'ban' || !action.hwids) {
+ return action;
+ } else {
+ removed += action.hwids.length;
+ action.hwids = [];
+ return action;
+ }
+ })
+ .value();
+ return removed;
+ } catch (error) {
+ const msg = `Failed to clean database with error: ${(error as Error).message}`;
+ console.verbose.error(msg);
+ throw new Error(msg);
+ }
+ }
+
+
+ /**
+ * Cron func to optimize the database removing players and whitelist reqs/approvals
+ */
+ runDailyOptimizer() {
+ const oneDay = 24 * 60 * 60;
+
+ //Optimize players
+ //Players that have not joined the last 16 days, and have less than 2 hours of playtime
+ let playerRemoved;
+ try {
+ const sixteenDaysAgo = now() - (16 * oneDay);
+ const filter = (p: DatabasePlayerType) => {
+ return (p.tsLastConnection < sixteenDaysAgo && p.playTime < 120);
+ }
+ playerRemoved = this.bulkRemove('players', filter);
+ } catch (error) {
+ const msg = `Failed to optimize players database with error: ${(error as Error).message}`;
+ console.error(msg);
+ }
+
+ //Optimize whitelistRequests + whitelistApprovals
+ //Removing the ones older than 7 days
+ let wlRequestsRemoved, wlApprovalsRemoved;
+ const sevenDaysAgo = now() - (7 * oneDay);
+ try {
+ const wlRequestsFilter = (req: DatabaseWhitelistRequestsType) => {
+ return (req.tsLastAttempt < sevenDaysAgo);
+ }
+ wlRequestsRemoved = txCore.database.whitelist.removeManyRequests(wlRequestsFilter).length;
+
+ const wlApprovalsFilter = (req: DatabaseWhitelistApprovalsType) => {
+ return (req.tsApproved < sevenDaysAgo);
+ }
+ wlApprovalsRemoved = txCore.database.whitelist.removeManyApprovals(wlApprovalsFilter).length;
+ } catch (error) {
+ const msg = `Failed to optimize players database with error: ${(error as Error).message}`;
+ 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.`);
+ }
+}
diff --git a/core/modules/Database/dao/players.ts b/core/modules/Database/dao/players.ts
new file mode 100644
index 0000000..12730f6
--- /dev/null
+++ b/core/modules/Database/dao/players.ts
@@ -0,0 +1,110 @@
+import { cloneDeep } from 'lodash-es';
+import { DbInstance, SavePriority } from "../instance";
+import { DatabasePlayerType } from "../databaseTypes";
+import { DuplicateKeyError } from "../dbUtils";
+import consoleFactory from '@lib/console';
+const console = consoleFactory('DatabaseDao');
+
+
+/**
+ * Data access object for the database "players" collection.
+ */
+export default class PlayersDao {
+ constructor(private readonly db: DbInstance) { }
+
+ private get dbo() {
+ if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`);
+ return this.db.obj;
+ }
+
+ private get chain() {
+ if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`);
+ return this.db.obj.chain;
+ }
+
+
+ /**
+ * Searches for a player in the database by the license, returns null if not found or false in case of error
+ */
+ findOne(license: string): DatabasePlayerType | null {
+ //Performing search
+ const p = this.chain.get('players')
+ .find({ license })
+ .cloneDeep()
+ .value();
+ return (typeof p === 'undefined') ? null : p;
+ }
+
+
+ /**
+ * Register a player to the database
+ */
+ findMany(filter: object | Function): DatabasePlayerType[] {
+ return this.chain.get('players')
+ .filter(filter as any)
+ .cloneDeep()
+ .value();
+ }
+
+
+ /**
+ * Register a player to the database
+ */
+ register(player: DatabasePlayerType): void {
+ //TODO: validate player data vs DatabasePlayerType props
+
+ //Check for duplicated license
+ const found = this.chain.get('players')
+ .filter({ license: player.license })
+ .value();
+ if (found.length) throw new DuplicateKeyError(`this license is already registered`);
+
+ this.db.writeFlag(SavePriority.LOW);
+ this.chain.get('players')
+ .push(player)
+ .value();
+ }
+
+
+ /**
+ * Updates a player setting assigning srcData props to the database player.
+ * The source data object is deep cloned to prevent weird side effects.
+ */
+ update(license: string, srcData: object, srcUniqueId: Symbol): DatabasePlayerType {
+ if (typeof (srcData as any).license !== 'undefined') {
+ throw new Error(`cannot license field`);
+ }
+
+ const playerDbObj = this.chain.get('players').find({ license });
+ if (!playerDbObj.value()) throw new Error('Player not found in database');
+ this.db.writeFlag(SavePriority.LOW);
+ const newData = playerDbObj
+ .assign(cloneDeep(srcData))
+ .cloneDeep()
+ .value();
+ txCore.fxPlayerlist.handleDbDataSync(newData, srcUniqueId);
+ return newData;
+ }
+
+
+ /**
+ * Revokes whitelist status of all players that match a filter function
+ * @returns the number of revoked whitelists
+ */
+ bulkRevokeWhitelist(filterFunc: Function): number {
+ if (typeof filterFunc !== 'function') throw new Error('filterFunc must be a function.');
+
+ let cntChanged = 0;
+ const srcSymbol = Symbol('bulkRevokePlayerWhitelist');
+ this.dbo.data!.players.forEach((player) => {
+ if (player.tsWhitelisted && filterFunc(player)) {
+ cntChanged++;
+ player.tsWhitelisted = undefined;
+ txCore.fxPlayerlist.handleDbDataSync(cloneDeep(player), srcSymbol);
+ }
+ });
+
+ this.db.writeFlag(SavePriority.HIGH);
+ return cntChanged;
+ }
+}
diff --git a/core/modules/Database/dao/stats.ts b/core/modules/Database/dao/stats.ts
new file mode 100644
index 0000000..66bd74a
--- /dev/null
+++ b/core/modules/Database/dao/stats.ts
@@ -0,0 +1,111 @@
+import { DbInstance } from "../instance";
+import consoleFactory from '@lib/console';
+import { MultipleCounter } from '@modules/Metrics/statsUtils';
+import { now } from '@lib/misc';
+const console = consoleFactory('DatabaseDao');
+
+
+/**
+ * Data access object for collecting stats from the database.
+ */
+export default class StatsDao {
+ constructor(private readonly db: DbInstance) { }
+
+ private get dbo() {
+ if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`);
+ return this.db.obj;
+ }
+
+ private get chain() {
+ if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`);
+ return this.db.obj.chain;
+ }
+
+
+ /**
+ * Returns players stats for the database (for Players page callouts)
+ */
+ getPlayersStats() {
+ const oneDayAgo = now() - (24 * 60 * 60);
+ const sevenDaysAgo = now() - (7 * 24 * 60 * 60);
+ const startingValue = {
+ total: 0,
+ playedLast24h: 0,
+ joinedLast24h: 0,
+ joinedLast7d: 0,
+ };
+ const playerStats = this.chain.get('players')
+ .reduce((acc, p, ind) => {
+ acc.total++;
+ if (p.tsLastConnection > oneDayAgo) acc.playedLast24h++;
+ if (p.tsJoined > oneDayAgo) acc.joinedLast24h++;
+ if (p.tsJoined > sevenDaysAgo) acc.joinedLast7d++;
+ return acc;
+ }, startingValue)
+ .value();
+
+ return playerStats;
+ }
+
+
+ /**
+ * Returns players stats for the database (for Players page callouts)
+ */
+ getActionStats() {
+ const sevenDaysAgo = now() - (7 * 24 * 60 * 60);
+ const startingValue = {
+ totalWarns: 0,
+ warnsLast7d: 0,
+ totalBans: 0,
+ bansLast7d: 0,
+ groupedByAdmins: new MultipleCounter(),
+ };
+ const actionStats = this.chain.get('actions')
+ .reduce((acc, action, ind) => {
+ if (action.type == 'ban') {
+ acc.totalBans++;
+ if (action.timestamp > sevenDaysAgo) acc.bansLast7d++;
+ } else if (action.type == 'warn') {
+ acc.totalWarns++;
+ if (action.timestamp > sevenDaysAgo) acc.warnsLast7d++;
+ }
+ acc.groupedByAdmins.count(action.author);
+ return acc;
+ }, startingValue)
+ .value();
+
+ return {
+ ...actionStats,
+ groupedByAdmins: actionStats.groupedByAdmins.toJSON(),
+ };
+ }
+
+
+ /**
+ * Returns actions/players stats for the database
+ * NOTE: used by diagnostics and reporting
+ */
+ getDatabaseStats() {
+ const actionStats = this.chain.get('actions')
+ .reduce((acc, a, ind) => {
+ if (a.type == 'ban') {
+ acc.bans++;
+ } else if (a.type == 'warn') {
+ acc.warns++;
+ }
+ return acc;
+ }, { bans: 0, warns: 0 })
+ .value();
+
+ const playerStats = this.chain.get('players')
+ .reduce((acc, p, ind) => {
+ acc.players++;
+ acc.playTime += p.playTime;
+ if (p.tsWhitelisted) acc.whitelists++;
+ return acc;
+ }, { players: 0, playTime: 0, whitelists: 0 })
+ .value();
+
+ return { ...actionStats, ...playerStats }
+ }
+}
diff --git a/core/modules/Database/dao/whitelist.ts b/core/modules/Database/dao/whitelist.ts
new file mode 100644
index 0000000..9e4b13c
--- /dev/null
+++ b/core/modules/Database/dao/whitelist.ts
@@ -0,0 +1,133 @@
+import { cloneDeep } from 'lodash-es';
+import { DbInstance, SavePriority } from "../instance";
+import consoleFactory from '@lib/console';
+import { DatabaseWhitelistApprovalsType, DatabaseWhitelistRequestsType } from '../databaseTypes';
+import { DuplicateKeyError, genWhitelistRequestID } from '../dbUtils';
+const console = consoleFactory('DatabaseDao');
+
+
+/**
+ * Data access object for the database whitelist collections.
+ */
+export default class WhitelistDao {
+ constructor(private readonly db: DbInstance) { }
+
+ private get dbo() {
+ if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`);
+ return this.db.obj;
+ }
+
+ private get chain() {
+ if (!this.db.obj || !this.db.isReady) throw new Error(`database not ready yet`);
+ return this.db.obj.chain;
+ }
+
+
+ /**
+ * Returns all whitelist approvals, which can be optionally filtered
+ */
+ findManyApprovals(
+ filter?: object | Function
+ ): DatabaseWhitelistApprovalsType[] {
+ return this.chain.get('whitelistApprovals')
+ .filter(filter as any)
+ .cloneDeep()
+ .value();
+ }
+
+
+ /**
+ * Removes whitelist approvals based on a filter.
+ */
+ removeManyApprovals(
+ filter: object | Function
+ ): DatabaseWhitelistApprovalsType[] {
+ this.db.writeFlag(SavePriority.MEDIUM);
+ return this.chain.get('whitelistApprovals')
+ .remove(filter as any)
+ .value();
+ }
+
+
+ /**
+ * Register a whitelist request to the database
+ */
+ registerApproval(approval: DatabaseWhitelistApprovalsType): void {
+ //TODO: validate player data vs DatabaseWhitelistApprovalsType props
+
+ //Check for duplicated license
+ const found = this.chain.get('whitelistApprovals')
+ .filter({ identifier: approval.identifier })
+ .value();
+ if (found.length) throw new DuplicateKeyError(`this identifier is already whitelisted`);
+
+ //Register new
+ this.db.writeFlag(SavePriority.LOW);
+ this.chain.get('whitelistApprovals')
+ .push(cloneDeep(approval))
+ .value();
+ }
+
+
+ /**
+ * Returns all whitelist approvals, which can be optionally filtered
+ */
+ findManyRequests(
+ filter?: object | Function
+ ): DatabaseWhitelistRequestsType[] {
+ return this.chain.get('whitelistRequests')
+ .filter(filter as any)
+ .cloneDeep()
+ .value();
+ }
+
+
+ /**
+ * Removes whitelist requests based on a filter.
+ */
+ removeManyRequests(
+ filter: object | Function
+ ): DatabaseWhitelistRequestsType[] {
+ this.db.writeFlag(SavePriority.LOW);
+ return this.chain.get('whitelistRequests')
+ .remove(filter as any)
+ .value();
+ }
+
+
+ /**
+ * Updates a whitelist request setting assigning srcData props to the database object.
+ * The source data object is deep cloned to prevent weird side effects.
+ */
+ updateRequest(license: string, srcData: object): DatabaseWhitelistRequestsType {
+ if (typeof (srcData as any).id !== 'undefined' || typeof (srcData as any).license !== 'undefined') {
+ throw new Error(`cannot update id or license fields`);
+ }
+
+ const requestDbObj = this.chain.get('whitelistRequests').find({ license });
+ if (!requestDbObj.value()) throw new Error('Request not found in database');
+ this.db.writeFlag(SavePriority.LOW);
+ return requestDbObj
+ .assign(cloneDeep(srcData))
+ .cloneDeep()
+ .value();
+ }
+
+
+ /**
+ * Register a whitelist request to the database
+ */
+ registerRequest(request: Omit): string {
+ //TODO: validate player data vs DatabaseWhitelistRequestsType props
+ if (typeof (request as any).id !== 'undefined') {
+ throw new Error(`cannot manually set the id field`);
+ }
+
+ const id = genWhitelistRequestID(this.dbo);
+ this.db.writeFlag(SavePriority.LOW);
+ this.chain.get('whitelistRequests')
+ .push({ id, ...cloneDeep(request) })
+ .value();
+ return id;
+ }
+}
diff --git a/core/modules/Database/databaseTypes.ts b/core/modules/Database/databaseTypes.ts
new file mode 100644
index 0000000..aae2850
--- /dev/null
+++ b/core/modules/Database/databaseTypes.ts
@@ -0,0 +1,68 @@
+export type DatabasePlayerType = {
+ license: string;
+ ids: string[];
+ hwids: string[];
+ displayName: string;
+ pureName: string;
+ playTime: number;
+ tsLastConnection: number;
+ tsJoined: number;
+ tsWhitelisted?: number;
+ notes?: {
+ text: string;
+ lastAdmin: string | null;
+ tsLastEdit: number | null;
+ };
+};
+
+export type DatabaseActionBaseType = {
+ id: string;
+ ids: string[];
+ playerName: string | false;
+ reason: string;
+ author: string;
+ timestamp: number;
+ //FIXME: the revocation object itself should be optional instead of nullable properties
+ //BUT DO REMEMBER THE `'XXX' IN YYY` ISSUE!
+ revocation: {
+ timestamp: number | null;
+ author: string | null;
+ };
+};
+export type DatabaseActionBanType = {
+ type: 'ban';
+ hwids?: string[];
+ expiration: number | false;
+} & DatabaseActionBaseType;
+export type DatabaseActionWarnType = {
+ type: 'warn';
+ expiration: false; //FIXME: remove - BUT DO REMEMBER THE `'XXX' IN YYY` ISSUE!
+ acked: boolean; //if the player has acknowledged the warning
+} & DatabaseActionBaseType;
+export type DatabaseActionType = DatabaseActionBanType | DatabaseActionWarnType;
+
+export type DatabaseWhitelistApprovalsType = {
+ identifier: string;
+ playerName: string; //always filled, even with `unknown` or license `xxxxxx...xxxxxx`
+ playerAvatar: string | null,
+ tsApproved: number,
+ approvedBy: string
+};
+
+export type DatabaseWhitelistRequestsType = {
+ id: string, //R####
+ license: string,
+ playerDisplayName: string,
+ playerPureName: string,
+ discordTag?: string,
+ discordAvatar?: string, //first try to get from GuildMember, then client.users.fetch()
+ tsLastAttempt: number,
+};
+
+export type DatabaseDataType = {
+ version: number,
+ players: DatabasePlayerType[],
+ actions: DatabaseActionType[],
+ whitelistApprovals: DatabaseWhitelistApprovalsType[],
+ whitelistRequests: DatabaseWhitelistRequestsType[],
+};
diff --git a/core/modules/Database/dbUtils.ts b/core/modules/Database/dbUtils.ts
new file mode 100644
index 0000000..cc1162d
--- /dev/null
+++ b/core/modules/Database/dbUtils.ts
@@ -0,0 +1,124 @@
+const modulename = 'IDGen';
+import fsp from 'node:fs/promises';
+import * as nanoidSecure from 'nanoid';
+import * as nanoidNonSecure from 'nanoid/non-secure';
+import consts from '@shared/consts';
+import getOsDistro from '@lib/host/getOsDistro.js';
+import { txEnv, txHostConfig } from '@core/globalData';
+import type { DatabaseObjectType } from './instance';
+import consoleFactory from '@lib/console';
+import { msToDuration } from '@lib/misc';
+const console = consoleFactory(modulename);
+
+//Consts
+type IdStorageTypes = DatabaseObjectType | Set;
+const maxAttempts = 10;
+const noIdErrorMessage = 'Unnable to generate new Random ID possibly due to the decreased available entropy. Please send a screenshot of the detailed information in the terminal for the txAdmin devs.';
+
+
+/**
+ * Prints a diagnostics message to the console that should help us identify what is the problem and the potential solution
+ */
+const printDiagnostics = async () => {
+ let uptime;
+ let entropy;
+ try {
+ uptime = msToDuration(process.uptime() * 1000);
+ entropy = (await fsp.readFile('/proc/sys/kernel/random/entropy_avail', 'utf8')).trim();
+ } catch (error) {
+ entropy = (error as Error).message;
+ }
+
+ const secureStorage = new Set();
+ for (let i = 0; i < 100; i++) {
+ const randID = nanoidSecure.customAlphabet(consts.actionIdAlphabet, 4)();
+ if (!secureStorage.has(randID)) secureStorage.add(randID);
+ }
+
+ const nonsecureStorage = new Set();
+ for (let i = 0; i < 100; i++) {
+ const randID = nanoidNonSecure.customAlphabet(consts.actionIdAlphabet, 4)();
+ if (!nonsecureStorage.has(randID)) nonsecureStorage.add(randID);
+ }
+
+ const osDistro = await getOsDistro();
+ console.error(noIdErrorMessage);
+ console.error(`Uptime: ${uptime}`);
+ console.error(`Entropy: ${entropy}`);
+ console.error(`Distro: ${osDistro}`);
+ console.error(`txAdmin: ${txEnv.txaVersion}`);
+ console.error(`FXServer: ${txEnv.fxsVersionTag}`);
+ console.error(`Provider: ${txHostConfig.providerName ?? 'none'}`);
+ console.error(`Unique Test: secure ${secureStorage.size}/100, non-secure ${nonsecureStorage.size}/100`);
+};
+
+
+/**
+ * Check in a storage weather the ID is unique or not.
+ * @param storage the Set or lowdb instance
+ * @param id the ID to check
+ * @param lowdbTable the lowdb table to check
+ * @returns if is unique
+ */
+const checkUniqueness = (storage: IdStorageTypes, id: string, lowdbTable: string) => {
+ if (storage instanceof Set) {
+ return !storage.has(id);
+ } else {
+ //@ts-ignore: typing as ('actions' | 'whitelistRequests') did not work
+ return !storage.chain.get(lowdbTable).find({ id }).value();
+ }
+};
+
+
+/**
+ * Generates an unique whitelist ID, or throws an error
+ * @param storage set or lowdb instance
+ * @returns id
+ */
+export const genWhitelistRequestID = (storage: IdStorageTypes) => {
+ let attempts = 0;
+ while (attempts < maxAttempts) {
+ attempts++;
+ const randFunc = (attempts <= 5) ? nanoidSecure : nanoidNonSecure;
+ const id = 'R' + randFunc.customAlphabet(consts.actionIdAlphabet, 4)();
+ if (checkUniqueness(storage, id, 'whitelistRequests')) {
+ return id;
+ }
+ }
+
+ printDiagnostics().catch((e) => { });
+ throw new Error(noIdErrorMessage);
+};
+
+
+/**
+ * Generates an unique action ID, or throws an error
+ */
+export const genActionID = (storage: IdStorageTypes, actionType: string) => {
+ let attempts = 0;
+ while (attempts < maxAttempts) {
+ attempts++;
+ const randFunc = (attempts <= 5) ? nanoidSecure : nanoidNonSecure;
+ const id = actionType[0].toUpperCase()
+ + randFunc.customAlphabet(consts.actionIdAlphabet, 3)()
+ + '-'
+ + randFunc.customAlphabet(consts.actionIdAlphabet, 4)();
+ if (checkUniqueness(storage, id, 'actions')) {
+ return id;
+ }
+ }
+
+ printDiagnostics().catch((e) => { });
+ throw new Error(noIdErrorMessage);
+};
+
+
+/**
+ * Error class for key uniqueness violations
+ */
+export class DuplicateKeyError extends Error {
+ readonly code = 'DUPLICATE_KEY';
+ constructor(message: string) {
+ super(message);
+ }
+}
diff --git a/core/modules/Database/index.ts b/core/modules/Database/index.ts
new file mode 100644
index 0000000..5bfb74a
--- /dev/null
+++ b/core/modules/Database/index.ts
@@ -0,0 +1,76 @@
+const modulename = 'Database';
+import { DbInstance } from './instance';
+import consoleFactory from '@lib/console';
+
+import PlayersDao from './dao/players';
+import ActionsDao from './dao/actions';
+import WhitelistDao from './dao/whitelist';
+import StatsDao from './dao/stats';
+import CleanupDao from './dao/cleanup';
+import { TxConfigState } from '@shared/enums';
+const console = consoleFactory(modulename);
+
+
+/**
+ * This module is a hub for all database-related operations.
+ */
+export default class Database {
+ readonly #db: DbInstance;
+
+ //Database Methods
+ readonly players: PlayersDao;
+ readonly actions: ActionsDao;
+ readonly whitelist: WhitelistDao;
+ readonly stats: StatsDao;
+ readonly cleanup: CleanupDao;
+
+ constructor() {
+ this.#db = new DbInstance();
+ this.players = new PlayersDao(this.#db);
+ this.actions = new ActionsDao(this.#db);
+ this.whitelist = new WhitelistDao(this.#db);
+ this.stats = new StatsDao(this.#db);
+ this.cleanup = new CleanupDao(this.#db);
+
+ //Database optimization cron function
+ const optimizerTask = () => {
+ if(txManager.configState === TxConfigState.Ready) {
+ this.cleanup.runDailyOptimizer();
+ }
+ }
+ setTimeout(optimizerTask, 30_000);
+ setInterval(optimizerTask, 24 * 60 * 60_000);
+ }
+
+
+ /**
+ * Graceful shutdown handler - passing down to the db instance
+ */
+ public handleShutdown() {
+ this.#db.handleShutdown();
+ }
+
+
+ /**
+ * Returns if the lowdb instance is ready
+ */
+ get isReady() {
+ return this.#db.isReady;
+ }
+
+ /**
+ * Returns if size of the database file
+ */
+ get fileSize() {
+ return (this.#db.obj?.adapter as any)?.fileSize;
+ }
+
+
+ /**
+ * Returns the entire lowdb object. Please be careful with it :)
+ */
+ getDboRef() {
+ if (!this.#db.obj) throw new Error(`database not ready yet`);
+ return this.#db.obj;
+ }
+};
diff --git a/core/modules/Database/instance.ts b/core/modules/Database/instance.ts
new file mode 100644
index 0000000..473216f
--- /dev/null
+++ b/core/modules/Database/instance.ts
@@ -0,0 +1,260 @@
+const modulename = 'Database';
+import fsp from 'node:fs/promises';
+import { ExpChain } from 'lodash';
+//@ts-ignore: I haven o idea why this errors, but I couldn't solve it
+import lodash from 'lodash-es';
+import { Low, Adapter } from 'lowdb';
+import { TextFile } from 'lowdb/node';
+import { txDevEnv, txEnv } from '@core/globalData';
+import { DatabaseDataType } from './databaseTypes.js';
+import migrations from './migrations.js';
+import consoleFactory from '@lib/console.js';
+import fatalError from '@lib/fatalError.js';
+import { TimeCounter } from '@modules/Metrics/statsUtils.js';
+const console = consoleFactory(modulename);
+
+//Consts & helpers
+export const DATABASE_VERSION = 5;
+export const defaultDatabase = {
+ version: DATABASE_VERSION,
+ actions: [],
+ players: [],
+ whitelistApprovals: [],
+ whitelistRequests: [],
+};
+
+export enum SavePriority {
+ STANDBY,
+ LOW,
+ MEDIUM,
+ HIGH,
+}
+
+const SAVE_CONFIG = {
+ [SavePriority.STANDBY]: {
+ name: 'standby',
+ interval: 5 * 60 * 1000,
+ },
+ [SavePriority.LOW]: {
+ name: 'low',
+ interval: 60 * 1000,
+ },
+ [SavePriority.MEDIUM]: {
+ name: 'medium',
+ interval: 30 * 1000,
+ },
+ [SavePriority.HIGH]: {
+ name: 'high',
+ interval: 15 * 1000,
+ },
+} as Record;
+
+
+//Reimplementing the adapter to minify json onm prod builds
+class JSONFile implements Adapter {
+ private readonly adapter: TextFile;
+ private readonly serializer: Function;
+ public fileSize: number = 0;
+
+ constructor(filename: string) {
+ this.adapter = new TextFile(filename);
+ this.serializer = (txDevEnv.ENABLED)
+ ? (obj: any) => JSON.stringify(obj, null, 4)
+ : JSON.stringify;
+ }
+
+ async read(): Promise {
+ const data = await this.adapter.read();
+ if (data === null) {
+ return null;
+ } else {
+ return JSON.parse(data) as T;
+ }
+ }
+
+ write(obj: T): Promise {
+ const serialized = this.serializer(obj);
+ this.fileSize = serialized.length;
+ return this.adapter.write(serialized);
+ }
+}
+
+
+// Extend Low class with a new `chain` field
+//NOTE: lodash-es doesn't have ExpChain exported, so we need it from the original lodash
+class LowWithLodash extends Low {
+ chain: ExpChain = lodash.chain(this).get('data')
+}
+export type DatabaseObjectType = LowWithLodash;
+
+
+export class DbInstance {
+ readonly dbPath: string;
+ readonly backupPath: string;
+ obj: DatabaseObjectType | undefined = undefined;
+ #writePending: SavePriority = SavePriority.STANDBY;
+ lastWrite: number = 0;
+ isReady: boolean = false;
+
+ constructor() {
+ this.dbPath = `${txEnv.profilePath}/data/playersDB.json`;
+ this.backupPath = `${txEnv.profilePath}/data/playersDB.backup.json`;
+
+ //Start database instance
+ this.setupDatabase();
+
+ //Cron functions
+ setInterval(() => {
+ this.checkWriteNeeded();
+ }, SAVE_CONFIG[SavePriority.HIGH].interval);
+ setInterval(() => {
+ this.backupDatabase();
+ }, SAVE_CONFIG[SavePriority.STANDBY].interval);
+ }
+
+
+ /**
+ * Start lowdb instance and set defaults
+ */
+ async setupDatabase() {
+ //Tries to load the database
+ let dbo;
+ try {
+ const adapterAsync = new JSONFile(this.dbPath);
+ dbo = new LowWithLodash(adapterAsync, defaultDatabase);
+ await dbo.read();
+ } catch (errorMain) {
+ const errTitle = 'Your txAdmin player/actions database could not be loaded.';
+ try {
+ await fsp.copyFile(this.backupPath, this.dbPath);
+ const adapterAsync = new JSONFile(this.dbPath);
+ dbo = new LowWithLodash(adapterAsync, defaultDatabase);
+ await dbo.read();
+ console.warn(errTitle);
+ console.warn('The database file was restored with the automatic backup file.');
+ console.warn('A five minute rollback is expected.');
+ } catch (errorBackup) {
+ fatalError.Database(0, [
+ errTitle,
+ 'It was also not possible to load the automatic backup file.',
+ ['Main error', (errorMain as Error).message],
+ ['Backup error', (errorBackup as Error).message],
+ ['Database path', this.dbPath],
+ 'If there is a file in that location, you may try to delete or restore it manually.',
+ ]);
+ }
+ }
+
+ //Setting up loaded database
+ try {
+ //Need to write the database, in case it is new
+ await dbo.write();
+
+ //Need to chain after setting defaults
+ dbo.chain = lodash.chain(dbo.data);
+
+ //If old database
+ if (dbo.data.version !== DATABASE_VERSION) {
+ await this.backupDatabase(`${txEnv.profilePath}/data/playersDB.backup.v${dbo.data.version}.json`);
+ this.obj = await migrations(dbo);
+ } else {
+ this.obj = dbo;
+ }
+
+ //Checking basic structure integrity
+ if (
+ !Array.isArray(this.obj!.data.actions)
+ || !Array.isArray(this.obj!.data.players)
+ || !Array.isArray(this.obj!.data.whitelistApprovals)
+ || !Array.isArray(this.obj!.data.whitelistRequests)
+ ) {
+ fatalError.Database(2, [
+ 'Your txAdmin player/actions database is corrupted!',
+ 'It is missing one of the required arrays (players, actions, whitelistApprovals, whitelistRequests).',
+ 'If you modified the database file manually, you may try to restore it from the automatic backup file.',
+ ['Database path', this.dbPath],
+ ]);
+ }
+
+ this.lastWrite = Date.now();
+ this.isReady = true;
+ } catch (error) {
+ fatalError.Database(1, 'Failed to setup database object.', error);
+ }
+ }
+
+
+ /**
+ * Writes the database to the disk if pending.
+ */
+ public handleShutdown() {
+ if (this.#writePending !== SavePriority.STANDBY) {
+ this.writeDatabase();
+ }
+ }
+
+
+ /**
+ * Creates a copy of the database file
+ */
+ async backupDatabase(targetPath?: string) {
+ try {
+ await fsp.copyFile(this.dbPath, targetPath ?? this.backupPath);
+ // console.verbose.debug('Database file backed up.');
+ } catch (error) {
+ console.error(`Failed to backup database file '${this.dbPath}'`);
+ console.verbose.dir(error);
+ }
+ }
+
+
+ /**
+ * Set write pending flag
+ */
+ writeFlag(flag = SavePriority.MEDIUM) {
+ if (flag < SavePriority.LOW || flag > SavePriority.HIGH) {
+ throw new Error('unknown priority flag!');
+ }
+ if (flag > this.#writePending) {
+ const flagName = SAVE_CONFIG[flag].name;
+ console.verbose.debug(`writeFlag > ${flagName}`);
+ this.#writePending = flag;
+ }
+ }
+
+
+ /**
+ * Checks if it's time to write the database to disk, taking in consideration the priority flag
+ */
+ private async checkWriteNeeded() {
+ //Check if the database is ready
+ if (!this.obj) return;
+
+ const timeStart = Date.now();
+ const sinceLastWrite = timeStart - this.lastWrite;
+
+ if (this.#writePending === SavePriority.HIGH || sinceLastWrite > SAVE_CONFIG[this.#writePending].interval) {
+ const writeTime = new TimeCounter();
+ await this.writeDatabase();
+ const timeElapsed = writeTime.stop();
+ this.#writePending = SavePriority.STANDBY;
+ this.lastWrite = timeStart;
+ // console.verbose.debug(`DB file saved, took ${timeElapsed.milliseconds}ms.`);
+ txCore.metrics.txRuntime.databaseSaveTime.count(timeElapsed.milliseconds);
+ }
+ }
+
+
+ /**
+ * Writes the database to the disk NOW
+ * NOTE: separate function so it can also be called by the shutdown handler
+ */
+ private async writeDatabase() {
+ try {
+ await this.obj?.write();
+ } catch (error) {
+ console.error(`Failed to save players database with error: ${(error as Error).message}`);
+ console.verbose.dir(error);
+ }
+ }
+}
diff --git a/core/modules/Database/migrations.js b/core/modules/Database/migrations.js
new file mode 100644
index 0000000..273e9d7
--- /dev/null
+++ b/core/modules/Database/migrations.js
@@ -0,0 +1,173 @@
+const modulename = 'DBMigration';
+import { genActionID } from './dbUtils.js';
+import cleanPlayerName from '@shared/cleanPlayerName.js';
+import { DATABASE_VERSION, defaultDatabase } from './instance.js'; //FIXME: circular_dependency
+import { now } from '@lib/misc.js';
+import consoleFactory from '@lib/console.js';
+import fatalError from '@lib/fatalError.js';
+const console = consoleFactory(modulename);
+
+
+/**
+ * Handles the migration of the database
+ */
+export default async (dbo) => {
+ if (dbo.data.version === DATABASE_VERSION) {
+ return dbo;
+ }
+ if (typeof dbo.data.version !== 'number') {
+ fatalError.Database(50, 'Your players database version is not a number!');
+ }
+ if (dbo.data.version > DATABASE_VERSION) {
+ fatalError.Database(51, [
+ `Your players database is on v${dbo.data.version}, and this txAdmin supports up to v${DATABASE_VERSION}.`,
+ 'This means you likely downgraded your txAdmin or FXServer.',
+ 'Please make sure your txAdmin is updated!',
+ '',
+ 'If you want to downgrade FXServer (the "artifact") but keep txAdmin updated,',
+ 'you can move the updated "citizen/system_resources/monitor" folder',
+ 'to older FXserver artifact, replacing the old files.',
+ `Alternatively, you can restore the database v${dbo.data.version} backup on the data folder.`,
+ ]);
+ }
+
+ //Migrate database
+ if (dbo.data.version < 1) {
+ console.warn(`Updating your players database from v${dbo.data.version} to v1. Wiping all the data.`);
+ dbo.data = lodash.cloneDeep(defaultDatabase);
+ dbo.data.version = 1;
+ await dbo.write();
+ }
+
+
+ if (dbo.data.version === 1) {
+ console.warn('Updating your players database from v1 to v2.');
+ console.warn('This process will change any duplicated action ID and wipe pending whitelist.');
+ const actionIDStore = new Set();
+ const actionsToFix = [];
+ dbo.chain.get('actions').forEach((a) => {
+ if (!actionIDStore.has(a.id)) {
+ actionIDStore.add(a.id);
+ } else {
+ actionsToFix.push(a);
+ }
+ }).value();
+ console.warn(`Actions to fix: ${actionsToFix.length}`);
+ for (let i = 0; i < actionsToFix.length; i++) {
+ const action = actionsToFix[i];
+ action.id = genActionID(actionIDStore, action.type);
+ actionIDStore.add(action.id);
+ }
+ dbo.data.pendingWL = [];
+ dbo.data.version = 2;
+ await dbo.write();
+ }
+
+ if (dbo.data.version === 2) {
+ console.warn('Updating your players database from v2 to v3.');
+ console.warn('This process will:');
+ console.warn('\t- process player names for better readability/searchability');
+ console.warn('\t- allow txAdmin to save old player identifiers');
+ console.warn('\t- remove the whitelist action in favor of player property');
+ console.warn('\t- remove empty notes');
+ console.warn('\t- improve whitelist handling');
+ console.warn('\t- changing warn action prefix from A to W');
+
+ //Removing all whitelist actions
+ const ts = now();
+ const whitelists = new Map();
+ dbo.data.actions = dbo.data.actions.filter((action) => {
+ if (action.type !== 'whitelist') return true;
+ if (
+ (!action.expiration || action.expiration > ts)
+ && (!action.revocation.timestamp)
+ && action.identifiers.length
+ && typeof action.identifiers[0] === 'string'
+ && action.identifiers[0].startsWith('license:')
+ ) {
+ const license = action.identifiers[0].substring(8);
+ whitelists.set(license, action.timestamp);
+ }
+ return false;
+ });
+
+ //Changing Warn actions id prefix to W
+ dbo.data.actions.forEach((action) => {
+ if (action.type === 'warn') {
+ action.id = `W${action.id.substring(1)}`;
+ }
+ });
+
+ //Migrating players
+ for (const player of dbo.data.players) {
+ const { displayName, pureName } = cleanPlayerName(player.name);
+ player.displayName = displayName;
+ player.pureName = pureName;
+ player.name = undefined;
+ player.ids = [`license:${player.license}`];
+
+ //adding whitelist
+ const tsWhitelisted = whitelists.get(player.license);
+ if (tsWhitelisted) player.tsWhitelisted = tsWhitelisted;
+
+ //removing empty notes
+ if (!player.notes.text) player.notes = undefined;
+ }
+
+ //Setting new whitelist schema
+ dbo.data.pendingWL = undefined;
+ dbo.data.whitelistApprovals = [];
+ dbo.data.whitelistRequests = [];
+
+ //Saving db
+ dbo.data.version = 3;
+ await dbo.write();
+ }
+
+ if (dbo.data.version === 3) {
+ console.warn('Updating your players database from v3 to v4.');
+ console.warn('This process will add a HWIDs array to the player data.');
+ console.warn('As well as rename \'action[].identifiers\' to \'action[].ids\'.');
+
+ //Migrating players
+ for (const player of dbo.data.players) {
+ player.hwids = [];
+ }
+
+ //Migrating actions
+ for (const action of dbo.data.actions) {
+ action.ids = action.identifiers;
+ action.identifiers = undefined;
+ }
+
+ //Saving db
+ dbo.data.version = 4;
+ await dbo.write();
+ }
+
+ if (dbo.data.version === 4) {
+ console.warn('Updating your players database from v4 to v5.');
+ console.warn('This process will allow for offline warns.');
+
+ //Migrating actions
+ for (const action of dbo.data.actions) {
+ if (action.type === 'warn') {
+ action.acked = true;
+ }
+ }
+
+ //Saving db
+ dbo.data.version = 5;
+ await dbo.write();
+ }
+
+ if (dbo.data.version !== DATABASE_VERSION) {
+ fatalError.Database(52, [
+ 'Unexpected migration error: Did not reach the expected database version.',
+ `Your players database is on v${dbo.data.version}, but the expected version is v${DATABASE_VERSION}.`,
+ 'Please make sure your txAdmin is on the most updated version!',
+ ]);
+ }
+ console.ok('Database migrated successfully');
+ return dbo;
+};
diff --git a/core/modules/DiscordBot/commands/info.ts b/core/modules/DiscordBot/commands/info.ts
new file mode 100644
index 0000000..c304553
--- /dev/null
+++ b/core/modules/DiscordBot/commands/info.ts
@@ -0,0 +1,147 @@
+const modulename = 'DiscordBot:cmd:info';
+import { APIEmbedField, CommandInteraction, EmbedBuilder, EmbedData } from 'discord.js';
+import { parsePlayerId } from '@lib/player/idUtils';
+import { embedder } from '../discordHelpers';
+import { findPlayersByIdentifier } from '@lib/player/playerFinder';
+import { txEnv } from '@core/globalData';
+import humanizeDuration from 'humanize-duration';
+import consoleFactory from '@lib/console';
+import { msToShortishDuration } from '@lib/misc';
+const console = consoleFactory(modulename);
+
+
+//Consts
+const footer = {
+ iconURL: 'https://cdn.discordapp.com/emojis/1062339910654246964.webp?size=96&quality=lossless',
+ text: `txAdmin ${txEnv.txaVersion}`,
+}
+
+
+/**
+ * Handler for /info
+ */
+export default async (interaction: CommandInteraction) => {
+ const tsToLocaleDate = (ts: number) => {
+ return new Date(ts * 1000).toLocaleDateString(
+ txCore.translator.canonical,
+ { dateStyle: 'long' }
+ );
+ }
+
+ //Check for admininfo & permission
+ let includeAdminInfo = false;
+ //@ts-ignore: somehow vscode is resolving interaction as CommandInteraction
+ const adminInfoFlag = interaction.options.getBoolean('admininfo');
+ if (adminInfoFlag) {
+ const admin = txCore.adminStore.getAdminByProviderUID(interaction.user.id);
+ if (!admin) {
+ return await interaction.reply(embedder.danger('You cannot use the `admininfo` option if you are not a txAdmin admin.'));
+ } else {
+ includeAdminInfo = true;
+ }
+ }
+
+ //Detect search identifier
+ let searchId;
+ //@ts-ignore: somehow vscode is resolving interaction as CommandInteraction
+ const subcommand = interaction.options.getSubcommand();
+ if (subcommand === 'self') {
+ const targetId = interaction.member?.user.id;
+ if (!targetId) {
+ return await interaction.reply(embedder.danger('Could not resolve your Discord ID.'));
+ }
+ searchId = `discord:${targetId}`;
+
+ } else if (subcommand === 'member') {
+ const member = interaction.options.getMember('member');
+ if(!member || !('user' in member)){
+ return await interaction.reply(embedder.danger(`Failed to resolve member ID.`));
+ }
+ searchId = `discord:${member.user.id}`;
+
+ } else if (subcommand === 'id') {
+ //@ts-ignore: somehow vscode is resolving interaction as CommandInteraction
+ const input = interaction.options.getString('id', true).trim();
+ if (!input.length) {
+ return await interaction.reply(embedder.danger('Invalid identifier.'));
+ }
+
+ const { isIdValid, idType, idValue, idlowerCased } = parsePlayerId(input);
+ if (!isIdValid || !idType || !idValue || !idlowerCased) {
+ return await interaction.reply(embedder.danger(`The provided identifier (\`${input}\`) does not seem to be valid.`));
+ }
+ searchId = idlowerCased;
+
+ } else {
+ throw new Error(`Subcommand ${subcommand} not found.`);
+ }
+
+ //Searching for players
+ const players = findPlayersByIdentifier(searchId);
+ if (!players.length) {
+ return await interaction.reply(embedder.warning(`Identifier (\`${searchId}\`) does not seem to be associated to any player in the txAdmin Database.`));
+ } else if (players.length > 10) {
+ return await interaction.reply(embedder.warning(`The identifier (\`${searchId}\`) is associated with more than 10 players, please use the txAdmin Web Panel to search for it.`));
+ }
+
+ //Format players
+ const embeds = [];
+ for (const player of players) {
+ const dbData = player.getDbData();
+ if (!dbData) continue;
+
+ //Basic data
+ const bodyText: Record = {
+ 'Play time': msToShortishDuration(dbData.playTime * 60 * 1000),
+ 'Join date': tsToLocaleDate(dbData.tsJoined),
+ 'Last connection': tsToLocaleDate(dbData.tsLastConnection),
+ 'Whitelisted': (dbData.tsWhitelisted)
+ ? tsToLocaleDate(dbData.tsWhitelisted)
+ : 'not yet',
+ };
+
+ //If admin query
+ let fields: APIEmbedField[] | undefined;
+ if (includeAdminInfo) {
+ //Counting bans/warns
+ const actionHistory = player.getHistory();
+ const actionCount = { ban: 0, warn: 0 };
+ for (const log of actionHistory) {
+ actionCount[log.type]++;
+ }
+ const banText = (actionCount.ban === 1) ? '1 ban' : `${actionCount.ban} bans`;
+ const warnText = (actionCount.warn === 1) ? '1 warn' : `${actionCount.warn} warns`;
+ bodyText['Log'] = `${banText}, ${warnText}`;
+
+ //Filling notes + identifiers
+ const notesText = (dbData.notes) ? dbData.notes.text : 'nothing here';
+ const idsText = (dbData.ids.length) ? dbData.ids.join('\n') : 'nothing here';
+ fields = [
+ {
+ name: '• Notes:',
+ value: `\`\`\`${notesText}\`\`\``
+ },
+ {
+ name: '• Identifiers:',
+ value: `\`\`\`${idsText}\`\`\``
+ },
+ ];
+
+ }
+
+ //Preparing embed
+ const description = Object.entries(bodyText)
+ .map(([label, value]) => `**• ${label}:** \`${value}\``)
+ .join('\n')
+ const embedData: EmbedData = {
+ title: player.displayName,
+ fields,
+ description,
+ footer,
+ };
+ embeds.push(new EmbedBuilder(embedData).setColor('#4262e2'));
+ }
+
+ //Send embeds :)
+ return await interaction.reply({ embeds });
+}
diff --git a/core/modules/DiscordBot/commands/status.ts b/core/modules/DiscordBot/commands/status.ts
new file mode 100644
index 0000000..c6e0630
--- /dev/null
+++ b/core/modules/DiscordBot/commands/status.ts
@@ -0,0 +1,294 @@
+const modulename = 'DiscordBot:cmd:status';
+import humanizeDuration from 'humanize-duration';
+import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, ChatInputCommandInteraction, ColorResolvable, EmbedBuilder } from 'discord.js';
+import { txEnv } from '@core/globalData';
+import { cloneDeep } from 'lodash-es';
+import { embedder, ensurePermission, isValidButtonEmoji, isValidEmbedUrl, logDiscordAdminAction } from '../discordHelpers';
+import consoleFactory from '@lib/console';
+import { msToShortishDuration } from '@lib/misc';
+import { FxMonitorHealth } from '@shared/enums';
+const console = consoleFactory(modulename);
+
+
+const isValidButtonConfig = (btn: any) => {
+ const btnType = typeof btn;
+ return (
+ btn !== null && btnType === 'object'
+ && typeof btn.label === 'string'
+ && btn.label.length
+ && typeof btn.url === 'string'
+ // && btn.url.length //let the function handle it
+ && (typeof btn.emoji === 'string' || btn.emoji === undefined)
+ );
+}
+
+const invalidUrlMessage = `Every URL must start with one of (\`http://\`, \`https://\`, \`discord://\`).
+URLs cannot be empty, if you do not want a URL then remove the URL line.`;
+
+const invalidPlaceholderMessage = `Your URL starts with \`{{\`, try removing it.
+If you just tried to edit a placeholder like \`{{serverBrowserUrl}}\` or \`{{serverJoinUrl}}\`, remember that those placeholders are replaced automatically by txAdmin, meaning you do not need to edit them at all.`
+
+const invalidEmojiMessage = `All emojis must be one of:
+- UTF-8 emoji ('😄')
+- Valid emoji ID ('1062339910654246964')
+- Discord custom emoji (\`<:name:id>\` or \`\`).
+To get the full emoji code, insert it into discord, and add \`\\\` before it then send the message`
+
+
+export const generateStatusMessage = (
+ rawEmbedJson: string = txConfig.discordBot.embedJson,
+ rawEmbedConfigJson: string = txConfig.discordBot.embedConfigJson
+) => {
+ //Parsing decoded JSONs
+ let embedJson;
+ try {
+ embedJson = JSON.parse(rawEmbedJson);
+ if (!(embedJson instanceof Object)) throw new Error(`not an Object`);
+ } catch (error) {
+ throw new Error(`Embed JSON Error: ${(error as Error).message}`);
+ }
+
+ let embedConfigJson;
+ try {
+ embedConfigJson = JSON.parse(rawEmbedConfigJson);
+ if (!(embedConfigJson instanceof Object)) throw new Error(`not an Object`);
+ } catch (error) {
+ throw new Error(`Embed Config JSON Error: ${(error as Error).message}`);
+ }
+
+ //Prepare placeholders
+ //NOTE: serverCfxId can be undefined, breaking the URLs, but there is no easy clean way to deal with this issue
+ const serverCfxId = txCore.cacheStore.get('fxsRuntime:cfxId');
+ const fxMonitorStatus = txCore.fxMonitor.status;
+ const placeholders = {
+ serverName: txConfig.general.serverName,
+ statusString: 'Unknown',
+ statusColor: '#4C3539',
+ serverCfxId,
+ serverBrowserUrl: `https://servers.fivem.net/servers/detail/${serverCfxId}`,
+ serverJoinUrl: `https://cfx.re/join/${serverCfxId}`,
+ serverMaxClients: txCore.cacheStore.get('fxsRuntime:maxClients') ?? 'unknown',
+ serverClients: txCore.fxPlayerlist.onlineCount,
+ nextScheduledRestart: 'unknown',
+ uptime: (fxMonitorStatus.uptime > 0)
+ ? msToShortishDuration(fxMonitorStatus.uptime)
+ : '--',
+ }
+
+ //Prepare scheduler placeholder
+ const schedule = txCore.fxScheduler.getStatus();
+ if (typeof schedule.nextRelativeMs !== 'number') {
+ placeholders.nextScheduledRestart = 'not scheduled';
+ } else if (schedule.nextSkip) {
+ placeholders.nextScheduledRestart = 'skipped';
+ } else {
+ const tempFlag = (schedule.nextIsTemp) ? '(tmp)' : '';
+ const relativeTime = msToShortishDuration(schedule.nextRelativeMs);
+ const isLessThanMinute = schedule.nextRelativeMs < 60_000;
+ if (isLessThanMinute) {
+ placeholders.nextScheduledRestart = `right now ${tempFlag}`;
+ } else {
+ placeholders.nextScheduledRestart = `in ${relativeTime} ${tempFlag}`;
+ }
+ }
+
+ //Prepare status placeholders
+ if (fxMonitorStatus.health === FxMonitorHealth.ONLINE) {
+ placeholders.statusString = embedConfigJson?.onlineString ?? '🟢 Online';
+ placeholders.statusColor = embedConfigJson?.onlineColor ?? "#0BA70B";
+ } else if (fxMonitorStatus.health === FxMonitorHealth.PARTIAL) {
+ placeholders.statusString = embedConfigJson?.partialString ?? '🟡 Partial';
+ placeholders.statusColor = embedConfigJson?.partialColor ?? "#FFF100";
+ } else if (fxMonitorStatus.health === FxMonitorHealth.OFFLINE) {
+ placeholders.statusString = embedConfigJson?.offlineString ?? '🔴 Offline';
+ placeholders.statusColor = embedConfigJson?.offlineColor ?? "#A70B28";
+ }
+
+ //Processing embed
+ function replacePlaceholders(inputString: string) {
+ Object.entries(placeholders).forEach(([key, value]) => {
+ inputString = inputString.replaceAll(`{{${key}}}`, String(value));
+ });
+ return inputString;
+ }
+ function processValue(inputValue: any): any {
+ if (typeof inputValue === 'string') {
+ return replacePlaceholders(inputValue);
+ } else if (Array.isArray(inputValue)) {
+ return inputValue.map((arrValue) => processValue(arrValue));
+ } else if (inputValue !== null && typeof inputValue === 'object') {
+ return processObject(inputValue);
+ } else {
+ return inputValue;
+ }
+ }
+ function processObject(inputData: object) {
+ const input = cloneDeep(inputData);
+ const out: any = {};
+ for (const [key, value] of Object.entries(input)) {
+ const processed = processValue(value);
+ if (key === 'url' && !isValidEmbedUrl(processed)) {
+ const messageHead = processed.length
+ ? `Invalid URL \`${processed}\`.`
+ : `Empty URL.`;
+ const badPlaceholderMessage = processed.startsWith('{{')
+ ? invalidPlaceholderMessage
+ : '';
+ throw new Error([
+ messageHead,
+ invalidUrlMessage,
+ badPlaceholderMessage
+ ].join('\n'));
+ }
+ out[key] = processed;
+ }
+ return out;
+ }
+ const processedEmbedData = processObject(embedJson);
+
+ //Attempting to instantiate embed class
+ let embed;
+ try {
+ embed = new EmbedBuilder(processedEmbedData);
+ embed.setColor(placeholders.statusColor as ColorResolvable);
+ embed.setTimestamp();
+ embed.setFooter({
+ iconURL: 'https://cdn.discordapp.com/emojis/1062339910654246964.webp?size=96&quality=lossless',
+ text: `txAdmin ${txEnv.txaVersion} • Updated every minute`,
+
+ });
+ } catch (error) {
+ throw new Error(`**Embed Class Error:** ${(error as Error).message}`);
+ }
+
+ //Attempting to instantiate buttons
+ let buttonsRow: ActionRowBuilder | undefined;
+ try {
+ if (Array.isArray(embedConfigJson?.buttons) && embedConfigJson.buttons.length) {
+ if (embedConfigJson.buttons.length > 5) {
+ throw new Error(`Over limit of 5 buttons.`);
+ }
+ buttonsRow = new ActionRowBuilder();
+ for (const cfgButton of embedConfigJson.buttons) {
+ if (!isValidButtonConfig(cfgButton)) {
+ throw new Error(`Invalid button in Discord Status Embed Config.
+ All buttons must have:
+ - Label: string, not empty
+ - URL: string, not empty, valid URL`);
+ }
+ const processedUrl = processValue(cfgButton.url);
+ if (!isValidEmbedUrl(processedUrl)) {
+ const messageHead = processedUrl.length
+ ? `Invalid URL \`${processedUrl}\``
+ : `Empty URL`;
+ const badPlaceholderMessage = processedUrl.startsWith('{{')
+ ? invalidPlaceholderMessage
+ : '';
+ throw new Error([
+ `${messageHead} for button \`${cfgButton.label}\`.`,
+ invalidUrlMessage,
+ badPlaceholderMessage
+ ].join('\n'));
+ }
+ const btn = new ButtonBuilder({
+ style: ButtonStyle.Link,
+ label: processValue(cfgButton.label),
+ url: processedUrl,
+ });
+ if (cfgButton.emoji !== undefined) {
+ if (!isValidButtonEmoji(cfgButton.emoji)) {
+ throw new Error(`Invalid emoji for button \`${cfgButton.label}\`.\n${invalidEmojiMessage}`);
+ }
+ btn.setEmoji(cfgButton.emoji);
+ }
+ buttonsRow.addComponents(btn);
+ }
+ }
+ } catch (error) {
+ throw new Error(`**Embed Buttons Error:** ${(error as Error).message}`);
+ }
+
+ return {
+ embeds: [embed],
+ components: buttonsRow ? [buttonsRow] : undefined,
+ };
+}
+
+export const removeOldEmbed = async (interaction: ChatInputCommandInteraction) => {
+ const oldChannelId = txCore.cacheStore.get('discord:status:channelId');
+ const oldMessageId = txCore.cacheStore.get('discord:status:messageId');
+ if (typeof oldChannelId === 'string' && typeof oldMessageId === 'string') {
+ const oldChannel = await interaction.client.channels.fetch(oldChannelId);
+ if (oldChannel?.type === ChannelType.GuildText || oldChannel?.type === ChannelType.GuildAnnouncement) {
+ await oldChannel.messages.delete(oldMessageId);
+ } else {
+ throw new Error(`oldChannel is not a guild text or announcement channel`);
+ }
+ } else {
+ throw new Error(`no old message id saved, maybe was never sent, maybe it was removed`);
+ }
+}
+
+export default async (interaction: ChatInputCommandInteraction) => {
+ //Check permissions
+ const adminName = await ensurePermission(interaction, 'settings.write');
+ if (typeof adminName !== 'string') return;
+
+ //Attempt to remove old message
+ const isRemoveOnly = (interaction.options.getSubcommand() === 'remove');
+ try {
+ await removeOldEmbed(interaction);
+ txCore.cacheStore.delete('discord:status:channelId');
+ txCore.cacheStore.delete('discord:status:messageId');
+ if (isRemoveOnly) {
+ const msg = `Old status embed removed.`;
+ logDiscordAdminAction(adminName, msg);
+ return await interaction.reply(embedder.success(msg, true));
+ }
+ } catch (error) {
+ if (isRemoveOnly) {
+ return await interaction.reply(
+ embedder.warning(`**Failed to remove old status embed:**\n${(error as Error).message}`, true)
+ );
+ }
+ }
+
+ //Generate new message
+ let newStatusMessage;
+ try {
+ newStatusMessage = generateStatusMessage();
+ } catch (error) {
+ return await interaction.reply(
+ embedder.warning(`**Failed to generate new embed:**\n${(error as Error).message}`, true)
+ );
+ }
+
+ //Attempt to send new message
+ try {
+ if (interaction.channel?.type !== ChannelType.GuildText && interaction.channel?.type !== ChannelType.GuildAnnouncement) {
+ throw new Error(`channel type not supported`);
+ }
+ const placeholderEmbed = new EmbedBuilder({
+ description: '_placeholder message, attempting to edit with embed..._\n**Note:** If you are seeing this message, it probably means that something was wrong with the configured Embed JSONs and Discord\'s API rejected the request to replace this placeholder.'
+ })
+ const newMessage = await interaction.channel.send({ embeds: [placeholderEmbed] });
+ await newMessage.edit(newStatusMessage);
+ txCore.cacheStore.set('discord:status:channelId', interaction.channelId);
+ txCore.cacheStore.set('discord:status:messageId', newMessage.id);
+ } catch (error) {
+ let msg: string;
+ if ((error as any).code === 50013) {
+ msg = `This bot does not have permission to send embed messages in this channel.
+ Please change the channel permissions and give this bot the \`Embed Links\` and \`Send Messages\` permissions.`
+ } else {
+ msg = (error as Error).message;
+ }
+ return await interaction.reply(
+ embedder.warning(`**Failed to send new embed:**\n${msg}`, true)
+ );
+ }
+
+ const msg = `Status embed saved.`;
+ logDiscordAdminAction(adminName, msg);
+ return await interaction.reply(embedder.success(msg, true));
+}
diff --git a/core/modules/DiscordBot/commands/whitelist.ts b/core/modules/DiscordBot/commands/whitelist.ts
new file mode 100644
index 0000000..bd04f70
--- /dev/null
+++ b/core/modules/DiscordBot/commands/whitelist.ts
@@ -0,0 +1,120 @@
+const modulename = 'DiscordBot:cmd:whitelist';
+import { CommandInteraction as ChatInputCommandInteraction, ImageURLOptions } from 'discord.js';
+import { now } from '@lib/misc';
+import { DuplicateKeyError } from '@modules/Database/dbUtils';
+import { embedder, ensurePermission, logDiscordAdminAction } from '../discordHelpers';
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+
+
+/**
+ * Command /whitelist member
+ */
+const handleMemberSubcommand = async (interaction: ChatInputCommandInteraction, adminName: string) => {
+ //Preparing player id/name/avatar
+ const member = interaction.options.getMember('member');
+ if(!member || !('user' in member)){
+ return await interaction.reply(embedder.danger(`Failed to resolve member ID.`));
+ }
+ const identifier = `discord:${member.id}`;
+ const playerName = member.nickname ?? member.user.username;
+ const avatarOptions: ImageURLOptions = { size: 64, forceStatic: true };
+ const playerAvatar = member.displayAvatarURL(avatarOptions) ?? member.user.displayAvatarURL(avatarOptions);
+
+ //Registering approval
+ try {
+ txCore.database.whitelist.registerApproval({
+ identifier,
+ playerName,
+ playerAvatar,
+ tsApproved: now(),
+ approvedBy: adminName,
+ });
+ txCore.fxRunner.sendEvent('whitelistPreApproval', {
+ action: 'added',
+ identifier,
+ playerName,
+ adminName,
+ });
+ } catch (error) {
+ return await interaction.reply(embedder.danger(`Failed to save whitelist approval: ${(error as Error).message}`));
+ }
+
+ const msg = `Added whitelist approval for ${playerName}.`;
+ logDiscordAdminAction(adminName, msg);
+ return await interaction.reply(embedder.success(msg));
+}
+
+
+/**
+ * Command /whitelist request
+ */
+const handleRequestSubcommand = async (interaction: ChatInputCommandInteraction, adminName: string) => {
+ //@ts-ignore: somehow vscode is resolving interaction as CommandInteraction
+ const input = interaction.options.getString('id', true);
+ const reqId = input.trim().toUpperCase();
+ if (reqId.length !== 5 || reqId[0] !== 'R') {
+ return await interaction.reply(embedder.danger('Invalid request ID.'));
+ }
+
+ //Find request
+ const requests = txCore.database.whitelist.findManyRequests({ id: reqId });
+ if (!requests.length) {
+ return await interaction.reply(embedder.warning(`Whitelist request ID \`${reqId}\` not found.`));
+ }
+ const req = requests[0]; //just getting the first
+
+ //Register whitelistApprovals
+ const identifier = `license:${req.license}`;
+ const playerName = req.discordTag ?? req.playerDisplayName;
+ try {
+ txCore.database.whitelist.registerApproval({
+ identifier,
+ playerName,
+ playerAvatar: (req.discordAvatar) ? req.discordAvatar : null,
+ tsApproved: now(),
+ approvedBy: adminName,
+ });
+ txCore.fxRunner.sendEvent('whitelistRequest', {
+ action: 'approved',
+ playerName,
+ requestId: req.id,
+ license: req.license,
+ adminName,
+ });
+ } catch (error) {
+ if (!(error instanceof DuplicateKeyError)) {
+ return await interaction.reply(embedder.danger(`Failed to save wl approval: ${(error as Error).message}`));
+ }
+ }
+
+ //Remove record from whitelistRequests
+ try {
+ txCore.database.whitelist.removeManyRequests({ id: reqId });
+ } catch (error) {
+ return await interaction.reply(embedder.danger(`Failed to remove wl request: ${(error as Error).message}`));
+ }
+
+ const msg = `Approved whitelist request \`${reqId}\` from ${playerName}.`;
+ logDiscordAdminAction(adminName, msg);
+ return await interaction.reply(embedder.success(msg));
+}
+
+
+/**
+ * Handler for /whitelist
+ */
+export default async (interaction: ChatInputCommandInteraction) => {
+ //Check permissions
+ const adminName = await ensurePermission(interaction, 'players.whitelist');
+ if (typeof adminName !== 'string') return;
+
+ //@ts-ignore: somehow vscode is resolving interaction as CommandInteraction
+ const subcommand = interaction.options.getSubcommand();
+ if (subcommand === 'member') {
+ return await handleMemberSubcommand(interaction, adminName);
+ } else if (subcommand === 'request') {
+ return await handleRequestSubcommand(interaction, adminName);
+ }
+ throw new Error(`Subcommand ${subcommand} not found.`);
+}
diff --git a/core/modules/DiscordBot/defaultJsons.ts b/core/modules/DiscordBot/defaultJsons.ts
new file mode 100644
index 0000000..e4182eb
--- /dev/null
+++ b/core/modules/DiscordBot/defaultJsons.ts
@@ -0,0 +1,65 @@
+import { txEnv } from "@core/globalData";
+
+export const defaultEmbedJson = JSON.stringify({
+ "title": "{{serverName}}",
+ "url": "{{serverBrowserUrl}}",
+ "description": "You can configure this embed in `txAdmin > Settings > Discord Bot`, and edit everything from it (except footer).",
+ "fields": [
+ {
+ "name": "> STATUS",
+ "value": "```\n{{statusString}}\n```",
+ "inline": true
+ },
+ {
+ "name": "> PLAYERS",
+ "value": "```\n{{serverClients}}/{{serverMaxClients}}\n```",
+ "inline": true
+ },
+ {
+ "name": "> F8 CONNECT COMMAND",
+ "value": "```\nconnect 123.123.123.123\n```"
+ },
+ {
+ "name": "> NEXT RESTART",
+ "value": "```\n{{nextScheduledRestart}}\n```",
+ "inline": true
+ },
+ {
+ "name": "> UPTIME",
+ "value": "```\n{{uptime}}\n```",
+ "inline": true
+ }
+ ],
+ "image": {
+ "url": "https://forum-cfx-re.akamaized.net/original/5X/e/e/c/b/eecb4664ee03d39e34fcd82a075a18c24add91ed.png"
+ },
+ "thumbnail": {
+ "url": "https://forum-cfx-re.akamaized.net/original/5X/9/b/d/7/9bd744dc2b21804e18c3bb331e8902c930624e44.png"
+ }
+});
+
+export const defaultEmbedConfigJson = JSON.stringify({
+ "onlineString": "🟢 Online",
+ "onlineColor": "#0BA70B",
+ "partialString": "🟡 Partial",
+ "partialColor": "#FFF100",
+ "offlineString": "🔴 Offline",
+ "offlineColor": "#A70B28",
+ "buttons": [
+ {
+ "emoji": "1062338355909640233",
+ "label": "Connect",
+ "url": "{{serverJoinUrl}}"
+ },
+ {
+ "emoji": "1062339910654246964",
+ "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
new file mode 100644
index 0000000..bd35451
--- /dev/null
+++ b/core/modules/DiscordBot/discordHelpers.ts
@@ -0,0 +1,113 @@
+const modulename = 'DiscordBot:cmd';
+import orderedEmojis from 'unicode-emoji-json/data-ordered-emoji';
+import { ColorResolvable, CommandInteraction, EmbedBuilder, InteractionReplyOptions } from "discord.js";
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+const allEmojis = new Set(orderedEmojis);
+
+
+
+/**
+ * Generic embed generation functions
+ */
+const genericEmbed = (
+ msg: string,
+ ephemeral = false,
+ color: ColorResolvable | null = null,
+ emoji?: string
+): InteractionReplyOptions => {
+ return {
+ ephemeral,
+ embeds: [new EmbedBuilder({
+ description: emoji ? `:${emoji}: ${msg}` : msg,
+ }).setColor(color)],
+ }
+}
+
+export const embedColors = {
+ info: '#1D76C9',
+ success: '#0BA70B',
+ warning: '#FFF100',
+ danger: '#A70B28',
+} as const;
+
+export const embedder = {
+ generic: genericEmbed,
+ info: (msg: string, ephemeral = false) => genericEmbed(msg, ephemeral, embedColors.info, 'information_source'),
+ success: (msg: string, ephemeral = false) => genericEmbed(msg, ephemeral, embedColors.success, 'white_check_mark'),
+ warning: (msg: string, ephemeral = false) => genericEmbed(msg, ephemeral, embedColors.warning, 'warning'),
+ danger: (msg: string, ephemeral = false) => genericEmbed(msg, ephemeral, embedColors.danger, 'no_entry_sign'),
+}
+
+
+/**
+ * Ensure that the discord interaction author has the required permission
+ */
+export const ensurePermission = async (interaction: CommandInteraction, reqPerm: string) => {
+ const admin = txCore.adminStore.getAdminByProviderUID(interaction.user.id);
+ if (!admin) {
+ await interaction.reply(
+ embedder.warning(`**Your account does not have txAdmin access.** :face_with_monocle:\nIf you are already registered in txAdmin, visit the Admin Manager page, and make sure the Discord ID for your user is set to \`${interaction.user.id}\`.`, true)
+ );
+ return false;
+ }
+ if (
+ admin.master !== true
+ && !admin.permissions.includes('all_permissions')
+ && !admin.permissions.includes(reqPerm)
+ ) {
+ //@ts-ignore: not important
+ const permName = txCore.adminStore.registeredPermissions[reqPerm] ?? 'Unknown';
+ await interaction.reply(
+ embedder.danger(`Your txAdmin account does not have the "${permName}" permissions required for this action.`, true)
+ );
+ return false;
+ }
+
+ return admin.name;
+}
+
+
+/**
+ * Equivalent to ctx.admin.logAction()
+ */
+export const logDiscordAdminAction = async (adminName: string, message: string) => {
+ txCore.logger.admin.write(adminName, message);
+}
+
+
+/**
+ * Tests if an embed url is valid or not
+ *
+ */
+export const isValidEmbedUrl = (url: unknown) => {
+ return typeof url === 'string' && /^(https?|discord):\/\//.test(url);
+}
+
+
+/**
+ * Tests if an emoji STRING is valid or not.
+ * Acceptable options:
+ * - UTF-8 emoji ('😄')
+ * - Valid emoji ID ('1062339910654246964')
+ * - Discord custom emoji (`<:name:id>` or ``)
+ */
+export const isValidButtonEmoji = (emoji: unknown) => {
+ if (typeof emoji !== 'string') return false;
+ if (/^\d{17,19}$/.test(emoji)) return true;
+ if (/^$/.test(emoji)) return true;
+ return allEmojis.has(emoji);
+}
+
+
+//Works
+// console.dir(isValidEmoji('<:txicon:1062339910654246964>'))
+// console.dir(isValidEmoji('1062339910654246964'))
+// console.dir(isValidEmoji('😄'))
+// console.dir(isValidEmoji('🇵🇼'))
+// console.dir(isValidEmoji('\u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F48B}\u{200D}\u{1F469}'))
+
+//Discord throws api error
+// console.dir(isValidEmoji(':smile:'))
+// console.dir(isValidEmoji('smile'))
+// console.dir(isValidEmoji({name: 'smile'}))
diff --git a/core/modules/DiscordBot/index.ts b/core/modules/DiscordBot/index.ts
new file mode 100644
index 0000000..2594513
--- /dev/null
+++ b/core/modules/DiscordBot/index.ts
@@ -0,0 +1,504 @@
+const modulename = 'DiscordBot';
+import Discord, { ActivityType, ChannelType, Client, EmbedBuilder, GatewayIntentBits } from 'discord.js';
+import slashCommands from './slash';
+import interactionCreateHandler from './interactionCreateHandler';
+import { generateStatusMessage } from './commands/status';
+import consoleFactory from '@lib/console';
+import { embedColors } from './discordHelpers';
+import { DiscordBotStatus } from '@shared/enums';
+import { UpdateConfigKeySet } from '@modules/ConfigStore/utils';
+const console = consoleFactory(modulename);
+
+
+//Types
+type MessageTranslationType = {
+ key: string;
+ data?: object;
+}
+type AnnouncementType = {
+ title?: string | MessageTranslationType;
+ description: string | MessageTranslationType;
+ type: keyof typeof embedColors;
+}
+
+type SpawnConfig = Pick<
+ TxConfigs['discordBot'],
+ 'enabled' | 'token' | 'guild' | 'warningsChannel'
+>;
+
+
+/**
+ * Module that handles the discord bot, provides methods to resolve members and send announcements, as well as
+ * providing discord slash commands.
+ */
+export default class DiscordBot {
+ //NOTE: only listening to embed changes, as the settings page boots the bot if enabled
+ static readonly configKeysWatched = [
+ 'discordBot.embedJson',
+ 'discordBot.embedConfigJson',
+ ];
+
+ readonly #clientOptions: Discord.ClientOptions = {
+ intents: [
+ GatewayIntentBits.Guilds,
+ GatewayIntentBits.GuildMembers,
+ ],
+ allowedMentions: {
+ parse: ['users'],
+ repliedUser: true,
+ },
+ //FIXME: fixme
+ // http: {
+ // agent: {
+ // localAddress: txHostConfig.netInterface,
+ // }
+ // }
+ }
+ readonly cooldowns = new Map();
+ #client: Client | undefined;
+ guild: Discord.Guild | undefined;
+ guildName: string | undefined;
+ announceChannel: Discord.TextBasedChannel | undefined;
+ #lastDisallowedIntentsError: number = 0; //ms
+ #lastGuildMembersCacheRefresh: number = 0; //ms
+ #lastStatus = DiscordBotStatus.Disabled;
+ #lastExplicitStatus = DiscordBotStatus.Disabled;
+
+
+ constructor() {
+ // FIXME: Hacky solution to fix the issue with disallowed intents
+ // Remove this when issue below is fixed
+ // https://github.com/discordjs/discord.js/issues/9621
+ process.on('unhandledRejection', (error: Error) => {
+ if (error.message === 'Used disallowed intents') {
+ this.#lastDisallowedIntentsError = Date.now();
+ }
+ });
+
+ setImmediate(() => {
+ if (txConfig.discordBot.enabled) {
+ this.startBot()?.catch((e) => { });
+ }
+ });
+
+ //Cron
+ setInterval(() => {
+ if (txConfig.discordBot.enabled) {
+ this.updateBotStatus().catch((e) => { });
+ }
+ }, 60_000);
+ //Not sure how often do we want to do this, or if we need to do this at all,
+ //but previously this was indirectly refreshed every 5 seconds by the health monitor
+ setInterval(() => {
+ this.refreshWsStatus();
+ }, 7500);
+ }
+
+
+ /**
+ * Handle updates to the config by resetting the required metrics
+ */
+ public handleConfigUpdate(updatedConfigs: UpdateConfigKeySet) {
+ return this.updateBotStatus();
+ }
+
+
+ /**
+ * Called by settings save to attempt to restart the bot with new settings
+ */
+ async attemptBotReset(botCfg: SpawnConfig | false) {
+ this.#lastGuildMembersCacheRefresh = 0;
+ if (this.#client) {
+ console.warn('Stopping Discord Bot');
+ this.#client.destroy();
+ this.refreshWsStatus();
+ setTimeout(() => {
+ if (!botCfg || !botCfg.enabled) this.#client = undefined;
+ }, 1000);
+ }
+
+ if (botCfg && botCfg.enabled) {
+ return await this.startBot(botCfg);
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Passthrough to discord.js isReady()
+ */
+ get isClientReady() {
+ return (this.#client) ? this.#client.isReady() : false;
+ }
+
+ /**
+ * Passthrough to discord.js websocket status
+ */
+ get status(): DiscordBotStatus {
+ if (!txConfig.discordBot.enabled) {
+ return DiscordBotStatus.Disabled;
+ } else if (this.#client?.ws.status === Discord.Status.Ready) {
+ return DiscordBotStatus.Ready;
+ } else {
+ return this.#lastExplicitStatus;
+ }
+ }
+
+ /**
+ * Updates the bot client status and pushes to the websocket
+ */
+ refreshWsStatus() {
+ if (this.#lastStatus !== this.status) {
+ this.#lastStatus = this.status;
+ txCore.webServer.webSocket.pushRefresh('status');
+ }
+ }
+
+
+ /**
+ * Send an announcement to the configured channel
+ */
+ async sendAnnouncement(content: AnnouncementType) {
+ if (!txConfig.discordBot.enabled) return;
+ if (
+ !txConfig.discordBot.warningsChannel
+ || !this.#client?.isReady()
+ || !this.announceChannel
+ ) {
+ console.verbose.warn('not ready yet to send announcement');
+ return false;
+ }
+
+ try {
+ let title;
+ if (content.title) {
+ title = (typeof content.title === 'string')
+ ? content.title
+ : txCore.translator.t(content.title.key, content.title.data);
+ }
+ let description;
+ if (content.description) {
+ description = (typeof content.description === 'string')
+ ? content.description
+ : txCore.translator.t(content.description.key, content.description.data);
+ }
+
+ const embed = new EmbedBuilder({ title, description }).setColor(embedColors[content.type]);
+ await this.announceChannel.send({ embeds: [embed] });
+ } catch (error) {
+ console.error(`Error sending Discord announcement: ${(error as Error).message}`);
+ }
+ }
+
+
+ /**
+ * Update persistent status and activity
+ */
+ async updateBotStatus() {
+ if (!this.#client?.isReady() || !this.#client.user) {
+ console.verbose.warn('not ready yet to update status');
+ return false;
+ }
+
+ //Updating bot activity
+ try {
+ const serverClients = txCore.fxPlayerlist.onlineCount;
+ const serverMaxClients = txCore.cacheStore.get('fxsRuntime:maxClients') ?? '??';
+ const serverName = txConfig.general.serverName;
+ const message = `[${serverClients}/${serverMaxClients}] on ${serverName}`;
+ this.#client.user.setActivity(message, { type: ActivityType.Watching });
+ } catch (error) {
+ console.verbose.warn(`Failed to set bot activity: ${(error as Error).message}`);
+ }
+
+ //Updating server status embed
+ try {
+ const oldChannelId = txCore.cacheStore.get('discord:status:channelId');
+ const oldMessageId = txCore.cacheStore.get('discord:status:messageId');
+
+ if (typeof oldChannelId === 'string' && typeof oldMessageId === 'string') {
+ const oldChannel = await this.#client.channels.fetch(oldChannelId);
+ if (!oldChannel) throw new Error(`oldChannel could not be resolved`);
+ if (oldChannel.type !== ChannelType.GuildText && oldChannel.type !== ChannelType.GuildAnnouncement) {
+ throw new Error(`oldChannel is not guild text or announcement channel`);
+ }
+ await oldChannel.messages.edit(oldMessageId, generateStatusMessage());
+ }
+ } catch (error) {
+ console.verbose.warn(`Failed to update status embed: ${(error as Error).message}`);
+ }
+ }
+
+
+ /**
+ * Starts the discord client
+ */
+ startBot(botCfg?: SpawnConfig) {
+ const isConfigSaveAttempt = !!botCfg;
+ botCfg ??= {
+ enabled: txConfig.discordBot.enabled,
+ token: txConfig.discordBot.token,
+ guild: txConfig.discordBot.guild,
+ warningsChannel: txConfig.discordBot.warningsChannel,
+ }
+ if (!botCfg.enabled) return;
+
+ return new Promise((resolve, reject) => {
+ type ErrorOptData = {
+ code?: string;
+ clientId?: string;
+ prohibitedPermsInUse?: string[];
+ }
+ const sendError = (msg: string, data: ErrorOptData = {}) => {
+ console.error(msg);
+ const e = new Error(msg);
+ Object.assign(e, data);
+ console.warn('Stopping Discord Bot');
+ this.#client?.destroy();
+ setImmediate(() => {
+ this.#lastExplicitStatus = DiscordBotStatus.Error;
+ this.refreshWsStatus();
+ this.#client = undefined;
+ });
+ return reject(e);
+ }
+
+ //Check for configs
+ if (typeof botCfg.token !== 'string' || !botCfg.token.length) {
+ return sendError('Discord bot enabled while token is not set.');
+ }
+ if (typeof botCfg.guild !== 'string' || !botCfg.guild.length) {
+ return sendError('Discord bot enabled while guild id is not set.');
+ }
+
+ //State check
+ if (this.#client && this.#client.ws.status !== 3 && this.#client.ws.status !== 5) {
+ console.verbose.warn('Destroying client before restart.');
+ this.#client.destroy();
+ }
+
+ //Setting up client object
+ this.#lastExplicitStatus = DiscordBotStatus.Starting;
+ this.refreshWsStatus();
+ this.#client = new Client(this.#clientOptions);
+
+ //Setup disallowed intents unhandled rejection watcher
+ const lastKnownDisallowedIntentsError = this.#lastDisallowedIntentsError;
+ const disallowedIntentsWatcherId = setInterval(() => {
+ if (this.#lastDisallowedIntentsError !== lastKnownDisallowedIntentsError) {
+ clearInterval(disallowedIntentsWatcherId);
+ return sendError(
+ `This bot does not have a required privileged intent.`,
+ { code: 'DisallowedIntents' }
+ );
+ }
+ }, 250);
+
+
+ //Setup Ready listener
+ this.#client.on('ready', async () => {
+ clearInterval(disallowedIntentsWatcherId);
+ if (!this.#client?.isReady() || !this.#client.user) throw new Error(`ready event while not being ready`);
+
+ //Fetching guild
+ const guild = this.#client.guilds.cache.find((guild) => guild.id === botCfg.guild);
+ if (!guild) {
+ return sendError(
+ `Discord bot could not resolve guild/server ID ${botCfg.guild}.`,
+ {
+ code: 'CustomNoGuild',
+ clientId: this.#client.user.id
+ }
+ );
+ }
+ this.guild = guild;
+ this.guildName = guild.name;
+
+ //Checking for dangerous permissions
+ // https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
+ // These are the same perms that require 2fa enabled - although it doesn't apply here
+ const prohibitedPerms = [
+ 'Administrator', //'ADMINISTRATOR',
+ 'BanMembers', //'BAN_MEMBERS'
+ 'KickMembers', //'KICK_MEMBERS'
+ 'ManageChannels', //'MANAGE_CHANNELS',
+ 'ManageGuildExpressions', //'MANAGE_GUILD_EXPRESSIONS'
+ 'ManageGuild', //'MANAGE_GUILD',
+ 'ManageMessages', //'MANAGE_MESSAGES'
+ 'ManageRoles', //'MANAGE_ROLES',
+ 'ManageThreads', //'MANAGE_THREADS'
+ 'ManageWebhooks', //'MANAGE_WEBHOOKS'
+ 'ViewCreatorMonetizationAnalytics', //'VIEW_CREATOR_MONETIZATION_ANALYTICS'
+ ]
+ const botPerms = this.guild.members.me?.permissions.serialize();
+ if (!botPerms) {
+ return sendError(`Discord bot could not detect its own permissions.`);
+ }
+ const prohibitedPermsInUse = Object.entries(botPerms)
+ .filter(([permName, permEnabled]) => prohibitedPerms.includes(permName) && permEnabled)
+ .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' }
+ );
+ }
+
+ //Fetching announcements channel
+ if (botCfg.warningsChannel) {
+ const fetchedChannel = this.#client.channels.cache.find((x) => x.id === botCfg.warningsChannel);
+ if (!fetchedChannel) {
+ return sendError(`Channel ${botCfg.warningsChannel} not found.`);
+ } else if (fetchedChannel.type !== ChannelType.GuildText && fetchedChannel.type !== ChannelType.GuildAnnouncement) {
+ return sendError(`Channel ${botCfg.warningsChannel} - ${(fetchedChannel as any)?.name} is not a text or announcement channel.`);
+ } else {
+ this.announceChannel = fetchedChannel;
+ }
+ }
+
+ // if previously registered by tx before v6 or other bot
+ this.guild.commands.set(slashCommands).catch(console.dir);
+ this.#client.application?.commands.set([]).catch(console.dir);
+
+ //The settings save will the updateBotStatus, so no need to call it here
+ if (!isConfigSaveAttempt) {
+ this.updateBotStatus().catch((e) => { });
+ }
+
+ const successMsg = `Discord bot running as \`${this.#client.user.tag}\` on \`${guild.name}\`.`;
+ console.ok(successMsg);
+ this.refreshWsStatus();
+ return resolve(successMsg);
+ });
+
+ //Setup remaining event listeners
+ this.#client.on('error', (error) => {
+ this.refreshWsStatus();
+ clearInterval(disallowedIntentsWatcherId);
+ console.error(`Error from Discord.js client: ${error.message}`);
+ return reject(error);
+ });
+ this.#client.on('resume', () => {
+ console.verbose.ok('Connection with Discord API server resumed');
+ this.updateBotStatus().catch((e) => { });
+ this.refreshWsStatus();
+ });
+ this.#client.on('interactionCreate', interactionCreateHandler);
+ // this.#client.on('debug', console.verbose.debug);
+
+ //Start bot
+ this.#client.login(botCfg.token).catch((error) => {
+ clearInterval(disallowedIntentsWatcherId);
+
+ //for some reason, this is not throwing unhandled rejection anymore /shrug
+ if (error.message === 'Used disallowed intents') {
+ return sendError(
+ `This bot does not have a required privileged intent.`,
+ { code: 'DisallowedIntents' }
+ );
+ }
+
+ //set status to error
+ this.#lastExplicitStatus = DiscordBotStatus.Error;
+ this.refreshWsStatus();
+
+ //if no message, create one
+ if (!('message' in error) || !error.message) {
+ error.message = 'no reason available - ' + JSON.stringify(error);
+ }
+ console.error(`Discord login failed with error: ${error.message}`);
+ return reject(error);
+ });
+ });
+ }
+
+ /**
+ * Refreshes the bot guild member cache
+ */
+ async refreshMemberCache() {
+ if (!txConfig.discordBot.enabled) throw new Error(`discord bot is disabled`);
+ if (!this.#client?.isReady()) throw new Error(`discord bot not ready yet`);
+ if (!this.guild) throw new Error(`guild not resolved`);
+
+ //Check when the cache was last refreshed
+ const currTs = Date.now();
+ if (currTs - this.#lastGuildMembersCacheRefresh > 60_000) {
+ try {
+ await this.guild.members.fetch();
+ this.#lastGuildMembersCacheRefresh = currTs;
+ return true;
+ } catch (error) {
+ return false;
+ }
+ }
+ return false;
+ }
+
+
+ /**
+ * Return if an ID is a guild member, and their roles
+ */
+ async resolveMemberRoles(uid: string) {
+ if (!txConfig.discordBot.enabled) throw new Error(`discord bot is disabled`);
+ if (!this.#client?.isReady()) throw new Error(`discord bot not ready yet`);
+ if (!this.guild) throw new Error(`guild not resolved`);
+
+ //Try to get member from cache or refresh cache then try again
+ let member = this.guild.members.cache.find(m => m.id === uid);
+ if (!member && await this.refreshMemberCache()) {
+ member = this.guild.members.cache.find(m => m.id === uid);
+ }
+
+ //Return result
+ if (member) {
+ return {
+ isMember: true,
+ memberRoles: member.roles.cache.map((role) => role.id),
+ };
+ } else {
+ return { isMember: false }
+ }
+ }
+
+
+ /**
+ * Resolves a user by its discord identifier.
+ */
+ async resolveMemberProfile(uid: string) {
+ if (!this.#client?.isReady()) throw new Error(`discord bot not ready yet`);
+ const avatarOptions: Discord.ImageURLOptions = { size: 64, forceStatic: true };
+
+ //Check if in guild member
+ if (this.guild) {
+ //Try to get member from cache or refresh cache then try again
+ let member = this.guild.members.cache.find(m => m.id === uid);
+ if (!member && await this.refreshMemberCache()) {
+ member = this.guild.members.cache.find(m => m.id === uid);
+ }
+
+ if (member) {
+ return {
+ tag: member.nickname ?? member.user.username,
+ avatar: member.displayAvatarURL(avatarOptions) ?? member.user.displayAvatarURL(avatarOptions),
+ };
+ }
+ }
+
+ //Checking if user resolvable
+ //NOTE: this one might still spam the API
+ // https://discord.js.org/#/docs/discord.js/14.11.0/class/UserManager?scrollTo=fetch
+ const user = await this.#client.users.fetch(uid);
+ if (user) {
+ return {
+ tag: user.username,
+ avatar: user.displayAvatarURL(avatarOptions),
+ };
+ } else {
+ throw new Error(`could not resolve discord user`);
+ }
+ }
+};
diff --git a/core/modules/DiscordBot/interactionCreateHandler.ts b/core/modules/DiscordBot/interactionCreateHandler.ts
new file mode 100644
index 0000000..ca834c7
--- /dev/null
+++ b/core/modules/DiscordBot/interactionCreateHandler.ts
@@ -0,0 +1,82 @@
+const modulename = 'DiscordBot:interactionHandler';
+import { Interaction, InteractionType } from 'discord.js';
+import infoCommandHandler from './commands/info';
+import statusCommandHandler from './commands/status';
+import whitelistCommandHandler from './commands/whitelist';
+import { embedder } from './discordHelpers';
+import { cloneDeep } from 'lodash-es'; //DEBUG
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+
+
+//All commands
+const handlers = {
+ status: statusCommandHandler,
+ whitelist: whitelistCommandHandler,
+ info: infoCommandHandler,
+}
+
+const noHandlerResponse = async (interaction: Interaction) => {
+ if (interaction.isRepliable()) {
+ //@ts-ignore
+ const identifier = interaction?.commandName ?? interaction?.customId ?? 'unknown';
+ await interaction.reply({
+ content: `No handler available for this interaction (${InteractionType[interaction.type]} > ${identifier})`,
+ ephemeral: true,
+ });
+ }
+}
+
+
+export default async (interaction: Interaction) => {
+ //DEBUG
+ // const copy = Object.assign(cloneDeep(interaction), { user: false, member: false });
+ // console.dir(copy);
+ // return;
+
+ //Handler filter
+ if (interaction.user.bot) return;
+
+ //Process buttons
+ if (interaction.isButton()) {
+ // //Get interaction
+ // const [iid, ...args] = interaction.customId.split(':');
+ // const handler = txChungus.interactionsManager.cache.get(`button:${iid}`);
+ // if (!handler) {
+ // console.error(`No handler available for button interaction ${interaction.customId}`);
+ // return;
+ // }
+ // txCore.metrics.txRuntime.botCommands.count(???);
+ // //Executes interaction
+ // try {
+ // return await handler.execute(interaction, args, txChungus);
+ // } catch (error) {
+ // return await console.error(`Error executing ${interaction.customId}: ${error.message}`);
+ // }
+ }
+
+ //Process Slash commands
+ if (interaction.isChatInputCommand()) {
+ //Get interaction
+ const handler = handlers[interaction.commandName as keyof typeof handlers];
+ if (!handler) {
+ noHandlerResponse(interaction).catch((e) => {});
+ return;
+ }
+ txCore.metrics.txRuntime.botCommands.count(interaction.commandName);
+
+ //Executes interaction
+ try {
+ await handler(interaction);
+ return;
+ } catch (error) {
+ const msg = `Error executing ${interaction.commandName}: ${(error as Error).message}`;
+ console.error(msg);
+ await interaction.reply(embedder.danger(msg, true));
+ return ;
+ }
+ }
+
+ //Unknown type
+ noHandlerResponse(interaction).catch((e) => {});
+};
diff --git a/core/modules/DiscordBot/slash.ts b/core/modules/DiscordBot/slash.ts
new file mode 100644
index 0000000..e8f43dd
--- /dev/null
+++ b/core/modules/DiscordBot/slash.ts
@@ -0,0 +1,115 @@
+import { ApplicationCommandDataResolvable, ApplicationCommandOptionType, ApplicationCommandType } from 'discord.js';
+
+
+const statusCommand: ApplicationCommandDataResolvable = {
+ type: ApplicationCommandType.ChatInput,
+ name: 'status',
+ description: 'Adds or removes the configurable, persistent, auto-updated embed.',
+ options: [
+ {
+ type: ApplicationCommandOptionType.Subcommand,
+ name: 'add',
+ description: 'Creates a configurable, persistent, auto-updated embed with server status.'
+ },
+ {
+ type: ApplicationCommandOptionType.Subcommand,
+ name: 'remove',
+ description: 'Removes the configured persistent txAdmin status embed.'
+ }
+ ]
+}
+
+const whitelistCommand: ApplicationCommandDataResolvable = {
+ type: ApplicationCommandType.ChatInput,
+ name: 'whitelist',
+ description: 'Whitelist embed commands.',
+ options: [
+ {
+ type: ApplicationCommandOptionType.Subcommand,
+ name: 'member',
+ description: 'Adds a member to the whitelist approvals.',
+ options: [
+ {
+ type: ApplicationCommandOptionType.User,
+ name: 'member',
+ description: 'The member that will be whitelisted.',
+ required: true,
+ }
+ ]
+ },
+ {
+ type: ApplicationCommandOptionType.Subcommand,
+ name: 'request',
+ description: 'Approves a whitelist request ID (eg R1234).',
+ options: [
+ {
+ type: ApplicationCommandOptionType.String,
+ name: 'id',
+ description: 'The ID of the request (eg R1234).',
+ required: true,
+ minLength: 5,
+ maxLength: 5,
+ }
+ ]
+ }
+ ]
+}
+
+const infoCommand: ApplicationCommandDataResolvable = {
+ type: ApplicationCommandType.ChatInput,
+ name: 'info',
+ description: 'Searches for a player in the txAdmin Database and prints information.',
+ options: [
+ {
+ type: ApplicationCommandOptionType.Subcommand,
+ name: 'self',
+ description: 'Searches for whomever is using the command.',
+ },
+ {
+ type: ApplicationCommandOptionType.Subcommand,
+ name: 'member',
+ description: 'Searches for a player with matching Discord ID.',
+ options: [
+ {
+ type: ApplicationCommandOptionType.User,
+ name: 'member',
+ description: 'The member that will be searched for.',
+ required: true,
+ },
+ {
+ type: ApplicationCommandOptionType.Boolean,
+ name: 'admininfo',
+ description: 'For admins to show identifiers and history information.'
+ }
+ ]
+ },
+ {
+ type: ApplicationCommandOptionType.Subcommand,
+ name: 'id',
+ description: 'Searches for an identifier.',
+ options: [
+ {
+ type: ApplicationCommandOptionType.String,
+ name: 'id',
+ description: 'The ID to search for (eg fivem:271816).',
+ required: true,
+ minLength: 5,
+ },
+ {
+ type: ApplicationCommandOptionType.Boolean,
+ name: 'admininfo',
+ description: 'For admins to show identifiers and history information.'
+ }
+ ]
+ },
+ ]
+}
+
+/**
+ * Exported commands
+ */
+export default [
+ statusCommand,
+ whitelistCommand,
+ infoCommand,
+] as ApplicationCommandDataResolvable[];
diff --git a/core/modules/FxMonitor/index.ts b/core/modules/FxMonitor/index.ts
new file mode 100644
index 0000000..029899a
--- /dev/null
+++ b/core/modules/FxMonitor/index.ts
@@ -0,0 +1,610 @@
+const modulename = 'FxMonitor';
+import crypto from 'node:crypto';
+import chalk from 'chalk';
+import { txHostConfig } from '@core/globalData';
+import { MonitorState, getMonitorTimeTags, HealthEventMonitor, MonitorIssue, Stopwatch, fetchDynamicJson, fetchInfoJson, cleanMonitorIssuesArray, type VerboseErrorData } from './utils';
+import consoleFactory from '@lib/console';
+import { SYM_SYSTEM_AUTHOR } from '@lib/symbols';
+import { ChildProcessState } from '@modules/FxRunner/ProcessManager';
+import { secsToShortestDuration } from '@lib/misc';
+import { setRuntimeFile } from '@lib/fxserver/runtimeFiles';
+import { FxMonitorHealth } from '@shared/enums';
+import cleanPlayerName from '@shared/cleanPlayerName';
+const console = consoleFactory(modulename);
+
+
+//MARK: Consts
+const HC_CONFIG = {
+ //before first success:
+ bootInitAfterHbLimit: 45,
+
+ //after first success:
+ delayLimit: 10,
+ fatalLimit: 180, //since playerConnecting hangs now are HB, reduced from 5 to 3 minutes
+
+ //other stuff
+ requestTimeout: 1500,
+ requestInterval: 1000,
+};
+const HB_CONFIG = {
+ //before first success:
+ bootNoEventLimit: 30, //scanning resources, license auth, etc
+ bootResEventGapLimit: 45, //time between one 'started' and one the next event (started or server booted)
+ bootResNominalStartTime: 10, //if a resource has been starting for this long, don't even mention it
+
+ //after first success:
+ delayLimit: 10,
+ fatalLimit: 60,
+};
+const MAX_LOG_ENTRIES = 300; //5 minutes in 1s intervals
+const MIN_WARNING_INTERVAL = 10;
+
+
+//MARK:Types
+export type MonitorIssuesArray = (string | MonitorIssue | undefined)[];
+type MonitorRestartCauses = 'bootTimeout' | 'close' | 'healthCheck' | 'heartBeat' | 'both';
+type ProcessStatusResult = {
+ action: 'SKIP';
+ reason: string;
+} | {
+ action: 'WARN';
+ times?: string;
+ reason: string;
+ issues?: MonitorIssuesArray;
+} | {
+ action: 'RESTART';
+ times?: string;
+ reason: string;
+ issues?: MonitorIssuesArray;
+ cause: MonitorRestartCauses;
+}
+type ProcessStatusNote = {
+ action: 'NOTE';
+ reason: string;
+}
+type StatusLogEntry = (ProcessStatusResult | ProcessStatusNote) & { x?: number };
+
+
+//MARK: Utils
+//This is inneficient, so wont put this in utils
+class LimitedArray extends Array {
+ constructor(public readonly limit: number) {
+ super();
+ }
+
+ push(...items: T[]): number {
+ while (this.length + items.length > this.limit) {
+ this.shift();
+ }
+ return super.push(...items);
+ }
+}
+
+
+//MARK: Main Class
+/**
+ * Module responsible for monitoring the FXServer health and status, restarting it if necessary.
+ */
+export default class FxMonitor {
+ public readonly timers: NodeJS.Timer[] = [];
+
+ //Status tracking
+ private readonly statusLog = new LimitedArray(MAX_LOG_ENTRIES);
+ private currentStatus: FxMonitorHealth = FxMonitorHealth.OFFLINE;
+ private lastHealthCheckError: VerboseErrorData | null = null; //to print warning
+ private isAwaitingRestart = false; //to prevent spamming while the server restarts (5s)
+ private tsServerBooted: number | null = null;
+
+ //to prevent DDoS crash false positive
+ private readonly swLastStatusUpdate = new Stopwatch(false);
+
+ //to prevent spamming
+ private readonly swLastPartialHangRestartWarning = new Stopwatch(true);
+ private readonly swLastStatusWarning = new Stopwatch(true);
+
+ //to see if its above limit
+ private readonly heartBeatMonitor = new HealthEventMonitor(HB_CONFIG.delayLimit, HB_CONFIG.fatalLimit);
+ private readonly healthCheckMonitor = new HealthEventMonitor(HC_CONFIG.delayLimit, HC_CONFIG.fatalLimit);
+ private readonly swLastFD3 = new Stopwatch();
+ private readonly swLastHTTP = new Stopwatch();
+
+
+ constructor() {
+ // Cron functions
+ this.timers.push(setInterval(() => this.performHealthCheck(), HC_CONFIG.requestInterval));
+ this.timers.push(setInterval(() => this.updateStatus(), 1000));
+ }
+
+
+ //MARK: State Helpers
+ /**
+ * Sets the current status and propagates the change to the Discord Bot and WebServer
+ */
+ private setCurrentStatus(newStatus: FxMonitorHealth) {
+ if (newStatus === this.currentStatus) return;
+
+ //Set state
+ this.currentStatus = newStatus;
+ txCore.discordBot.updateBotStatus().catch((e) => { });
+ txCore.webServer.webSocket.pushRefresh('status');
+ }
+
+
+ /**
+ * Reset Health Monitor Stats.
+ * This is called internally and by FxRunner
+ */
+ public resetState() {
+ this.setCurrentStatus(FxMonitorHealth.OFFLINE);
+ this.lastHealthCheckError = null;
+ this.isAwaitingRestart = false;
+ this.tsServerBooted = null;
+
+ this.swLastStatusUpdate.reset();
+ this.swLastStatusWarning.reset();
+ this.swLastPartialHangRestartWarning.reset(); //for partial hang
+
+ this.heartBeatMonitor.reset();
+ this.healthCheckMonitor.reset();
+ this.swLastFD3.reset();
+ this.swLastHTTP.reset();
+ }
+
+
+ /**
+ * Processes the status and then deals with the results.
+ * MARK: UPDATE STATUS
+ */
+ private updateStatus() {
+ //Get and cleanup the result
+ const result = this.calculateMonitorStatus();
+ const cleanIssues = 'issues' in result ? cleanMonitorIssuesArray(result.issues) : [];
+ if (result.reason.endsWith('.')) {
+ result.reason = result.reason.slice(0, -1);
+ }
+
+ //Merge with last entry if possible
+ const lastEntry = this.statusLog.at(-1);
+ if (lastEntry && lastEntry.action === result.action && lastEntry.reason === result.reason) {
+ lastEntry.x = (lastEntry.x ?? 1) + 1;
+ if ('times' in lastEntry && 'times' in result) lastEntry.times = result.times;
+ if ('issues' in lastEntry && cleanIssues.length) lastEntry.issues = cleanIssues;
+ } else {
+ this.statusLog.push(result);
+ }
+
+ //Right now we only care about WARN and RESTART
+ if (result.action !== 'WARN' && result.action !== 'RESTART') return;
+
+ //Prepare reason
+ if (!result.reason) {
+ console.error('Missing reason in status update.');
+ return;
+ }
+ const styledIssues = cleanIssues.map((i) => chalk.dim('- ' + i));
+
+ //Warning - check if last warning was long enough ago
+ if (result.action === 'WARN') {
+ if (this.swLastStatusWarning.isOver(MIN_WARNING_INTERVAL)) {
+ this.swLastStatusWarning.restart();
+ const timesPrefix = result.times ? `${result.times} ` : '';
+ console.warn(timesPrefix + result.reason + '.');
+ for (const line of styledIssues) {
+ console.warn(line);
+ }
+ }
+ return;
+ }
+
+ //Restart - log to the required channels + restart the server
+ if (result.action === 'RESTART') {
+ if (txCore.fxRunner.isIdle) return; //should never happen
+
+ //Logging
+ const timesSuffix = result.times ? ` ${result.times}.` : '.';
+ const logMessage = `Restarting server: ${result.reason}` + timesSuffix;
+ console.error(logMessage);
+ txCore.logger.admin.writeSystem('MONITOR', logMessage);
+ txCore.logger.fxserver.logInformational(logMessage); //just for better visibility
+ for (const line of styledIssues) {
+ txCore.logger.fxserver.logInformational(line);
+ console.error(line);
+ }
+ if (this.lastHealthCheckError) {
+ console.verbose.debug('Last HealthCheck error debug data:');
+ console.verbose.dir(this.lastHealthCheckError.debugData);
+ console.verbose.debug('-'.repeat(40));
+ }
+
+ //Restarting the server
+ this.resetState(); //will set the status to OFFLINE
+ this.isAwaitingRestart = true;
+ txCore.fxRunner.restartServer(
+ txCore.translator.t('restarter.server_unhealthy_kick_reason'),
+ SYM_SYSTEM_AUTHOR,
+ );
+ txCore.metrics.txRuntime.registerFxserverRestart(result.cause);
+ }
+ }
+
+
+ /**
+ * MARK: CALCULATE STATUS
+ * Refreshes the Server Status and calls for a restart if neccessary.
+ * - HealthCheck: performing an GET to the /dynamic.json file
+ * - HeartBeat: receiving an intercom POST or FD3 txAdminHeartBeat event
+ */
+ private calculateMonitorStatus(): ProcessStatusResult {
+ //Check if the server is supposed to be offline
+ if (txCore.fxRunner.isIdle) {
+ this.resetState();
+ return {
+ action: 'SKIP',
+ reason: 'Server is idle',
+ };
+ }
+
+ //Ignore check while server is restarting
+ if (this.isAwaitingRestart) {
+ return {
+ action: 'SKIP',
+ reason: 'Server is restarting',
+ };
+ }
+
+ //Check if process was frozen
+ if (this.swLastStatusUpdate.isOver(10)) {
+ console.error(`txAdmin was frozen for ${this.swLastStatusUpdate.elapsed - 1} seconds for unknown reason (random issue, VPS Lag, DDoS, etc).`);
+ this.swLastStatusUpdate.restart();
+ return {
+ action: 'SKIP',
+ reason: 'txAdmin was frozen',
+ }
+ }
+ this.swLastStatusUpdate.restart();
+
+ //Get elapsed times & process status
+ const heartBeat = this.heartBeatMonitor.status;
+ const healthCheck = this.healthCheckMonitor.status;
+ const processUptime = Math.floor((txCore.fxRunner.child?.uptime ?? 0) / 1000);
+ const timeTags = getMonitorTimeTags(heartBeat, healthCheck, processUptime)
+ // console.dir({
+ // timeTag: timeTags.withProc,
+ // heartBeat,
+ // healthCheck,
+ // child: txCore.fxRunner.child?.status,
+ // }, { compact: true }); //DEBUG
+ const secs = (s: number) => Number.isFinite(s) ? secsToShortestDuration(s, { round: false }) : '--';
+
+ //Don't wait for the fail counts if the child was destroyed
+ if (txCore.fxRunner.child?.status === ChildProcessState.Destroyed) {
+ //No need to set any status, this.updateStatus will trigger this.resetState
+ return {
+ action: 'RESTART',
+ reason: 'Server process close detected',
+ cause: 'close',
+ };
+ }
+
+ //Check if its online and return
+ if (heartBeat.state === MonitorState.HEALTHY && healthCheck.state === MonitorState.HEALTHY) {
+ this.setCurrentStatus(FxMonitorHealth.ONLINE);
+ if (!this.tsServerBooted) {
+ this.tsServerBooted = Date.now();
+ this.handleBootCompleted(processUptime).catch(() => { });
+ }
+ return {
+ action: 'SKIP',
+ reason: 'Server is healthy',
+ }
+ }
+
+ //At least one monitor is unhealthy
+ const currentStatusString = (
+ heartBeat.state !== MonitorState.HEALTHY
+ && healthCheck.state !== MonitorState.HEALTHY
+ ) ? FxMonitorHealth.OFFLINE : FxMonitorHealth.PARTIAL
+ this.setCurrentStatus(currentStatusString);
+
+ //Check if still in grace period
+ if (processUptime < txConfig.restarter.bootGracePeriod) {
+ return {
+ action: 'SKIP',
+ reason: `FXServer is ${currentStatusString}, still in grace period of ${txConfig.restarter.bootGracePeriod}s.`,
+ };
+ }
+
+ //Checking all conditions and either filling FailReason or restarting it outright
+ //NOTE: This part MUST restart if boot timed out, but FATAL will be dealt with down below
+ let heartBeatIssue: MonitorIssue | undefined;
+ let healthCheckIssue: MonitorIssue | undefined;
+
+ //If HealthCheck failed
+ if (healthCheck.state !== MonitorState.HEALTHY) {
+ healthCheckIssue = new MonitorIssue('Impossible HealthCheck failure.');
+ healthCheckIssue.addDetail(this.lastHealthCheckError?.error);
+
+ //No HealthCheck received yet
+ if (healthCheck.state === MonitorState.PENDING) {
+ healthCheckIssue.setTitle('No successful HealthCheck yet.');
+
+ //Check if heartbeats started but no healthchecks yet
+ if (heartBeat.state !== MonitorState.PENDING) {
+ healthCheckIssue.addInfo(`Resources started ${secs(heartBeat.secsSinceFirst)} ago but the HTTP endpoint is still unresponsive.`);
+ if (heartBeat.secsSinceFirst > HC_CONFIG.bootInitAfterHbLimit) {
+ return {
+ action: 'RESTART',
+ times: timeTags.withProc,
+ reason: 'Server boot timed out',
+ issues: [healthCheckIssue],
+ cause: 'bootTimeout',
+ }
+ }
+ }
+ } else if (healthCheck.state === MonitorState.DELAYED || healthCheck.state === MonitorState.FATAL) {
+ //Started, but failing
+ healthCheckIssue.setTitle(`HealthChecks failing for the past ${secs(healthCheck.secsSinceLast)}.`);
+ } else {
+ throw new Error(`Unexpected HealthCheck state: ${healthCheck.state}`);
+ }
+ }
+
+ //If HeartBeat failed
+ if (heartBeat.state !== MonitorState.HEALTHY) {
+ heartBeatIssue = new MonitorIssue('Impossible HeartBeat failure.');
+
+ //No HeartBeat received yet
+ if (heartBeat.state === MonitorState.PENDING) {
+ heartBeatIssue.setTitle('No successful HeartBeat yet.');
+ const resBoot = txCore.fxResources.bootStatus;
+ if (resBoot.current?.time.isOver(txConfig.restarter.resourceStartingTolerance)) {
+ //Resource boot timed out
+ return {
+ action: 'RESTART',
+ times: timeTags.withProc,
+ reason: `Resource "${resBoot.current.name}" failed to start within the ${secs(txConfig.restarter.resourceStartingTolerance)} time limit`,
+ cause: 'bootTimeout',
+ }
+ } else if (resBoot.current?.time.isOver(HB_CONFIG.bootResNominalStartTime)) {
+ //Resource booting for too long, but still within tolerance
+ heartBeatIssue.addInfo(`Resource "${resBoot.current.name}" has been loading for ${secs(resBoot.current.time.elapsed)}.`);
+ } else if (resBoot.current) {
+ //Resource booting at nominal time
+ heartBeatIssue.addInfo(`Resources are still booting at nominal time.`);
+ } else {
+ // No resource currently booting
+ heartBeatIssue.addInfo(`No resource currently loading.`);
+ if (resBoot.elapsedSinceLast === null) {
+ //No event received yet
+ if (processUptime > HB_CONFIG.bootNoEventLimit) {
+ heartBeatIssue.addInfo(`No resource event received within the ${secs(HB_CONFIG.bootNoEventLimit)} limit.`);
+ return {
+ action: 'RESTART',
+ times: timeTags.withProc,
+ reason: 'Server boot timed out',
+ issues: [heartBeatIssue],
+ cause: 'bootTimeout',
+ }
+ } else {
+ heartBeatIssue.addInfo(`No resource event received yet.`);
+ }
+ } else {
+ //Last event was recent
+ heartBeatIssue.addInfo(`Last resource finished loading ${secs(resBoot.elapsedSinceLast)} ago.`);
+ if (resBoot.elapsedSinceLast > HB_CONFIG.bootResEventGapLimit) {
+ //Last event was too long ago
+ return {
+ action: 'RESTART',
+ times: timeTags.withProc,
+ reason: 'Server boot timed out',
+ issues: [heartBeatIssue],
+ cause: 'bootTimeout',
+ }
+ }
+ }
+ }
+ } else if (heartBeat.state === MonitorState.DELAYED || heartBeat.state === MonitorState.FATAL) {
+ //HeartBeat started, but failing
+ heartBeatIssue.setTitle(`Stopped receiving HeartBeats ${secs(heartBeat.secsSinceLast)} ago.`);
+ } else {
+ throw new Error(`Unexpected HeartBeat state: ${heartBeat.state}`);
+ }
+ }
+
+ //Check if either HB or HC are FATAL, restart the server
+ if (heartBeat.state === MonitorState.FATAL || healthCheck.state === MonitorState.FATAL) {
+ let cause: MonitorRestartCauses;
+ if (heartBeat.state === MonitorState.FATAL && healthCheck.state === MonitorState.FATAL) {
+ cause = 'both';
+ } else if (heartBeat.state === MonitorState.FATAL) {
+ cause = 'heartBeat';
+ } else if (healthCheck.state === MonitorState.FATAL) {
+ cause = 'healthCheck';
+ } else {
+ throw new Error(`Unexpected fatal state: HB:${heartBeat.state} HC:${healthCheck.state}`);
+ }
+
+ return {
+ action: 'RESTART',
+ cause,
+ times: timeTags.simple,
+ reason: 'Server is not responding',
+ issues: [heartBeatIssue, healthCheckIssue],
+ }
+ }
+
+ //If http-only hang, warn 1 minute before restart
+ if (
+ heartBeat.state === MonitorState.HEALTHY
+ && this.swLastPartialHangRestartWarning.isOver(120)
+ && healthCheck.secsSinceLast > (HC_CONFIG.fatalLimit - 60)
+ ) {
+ this.swLastPartialHangRestartWarning.restart();
+
+ //Sending discord announcement
+ txCore.discordBot.sendAnnouncement({
+ type: 'danger',
+ description: {
+ key: 'restarter.partial_hang_warn_discord',
+ data: { servername: txConfig.general.serverName },
+ },
+ });
+
+ // Dispatch `txAdmin:events:announcement`
+ txCore.fxRunner.sendEvent('announcement', {
+ author: 'txAdmin',
+ message: txCore.translator.t('restarter.partial_hang_warn'),
+ });
+ }
+
+ //Return warning
+ const isStillBooting = heartBeat.state === MonitorState.PENDING || healthCheck.state === MonitorState.PENDING;
+ return {
+ action: 'WARN',
+ times: isStillBooting ? timeTags.withProc : timeTags.simple,
+ reason: isStillBooting ? 'Server is taking too long to start' : 'Server is not responding',
+ issues: [heartBeatIssue, healthCheckIssue],
+ }
+ }
+
+
+ //MARK: HC & HB
+ /**
+ * Sends a HTTP GET request to the /dynamic.json endpoint of FXServer to check if it's healthy.
+ */
+ private async performHealthCheck() {
+ //Check if the server is supposed to be offline
+ const childState = txCore.fxRunner.child;
+ if (!childState?.isAlive || !childState?.netEndpoint) return;
+
+ //Make request
+ const reqResult = await fetchDynamicJson(childState.netEndpoint, HC_CONFIG.requestTimeout);
+ if (!reqResult.success) {
+ this.lastHealthCheckError = reqResult;
+ return;
+ }
+ const dynamicJson = reqResult.data;
+
+ //Successfull healthcheck
+ this.lastHealthCheckError = null;
+ this.healthCheckMonitor.markHealthy();
+
+ //Checking for the maxClients
+ if (dynamicJson.sv_maxclients) {
+ const maxClients = dynamicJson.sv_maxclients;
+ txCore.cacheStore.set('fxsRuntime:maxClients', maxClients);
+ if (txHostConfig.forceMaxClients && maxClients > txHostConfig.forceMaxClients) {
+ txCore.fxRunner.sendCommand(
+ 'sv_maxclients',
+ [txHostConfig.forceMaxClients],
+ SYM_SYSTEM_AUTHOR
+ );
+ console.error(`${txHostConfig.sourceName}: Detected that the server has sv_maxclients above the limit (${txHostConfig.forceMaxClients}). Changing back to the limit.`);
+ txCore.logger.admin.writeSystem('SYSTEM', `changing sv_maxclients back to ${txHostConfig.forceMaxClients}`);
+ }
+ }
+ }
+
+
+ /**
+ * Handles the HeartBeat event from the server.
+ */
+ public handleHeartBeat(source: 'fd3' | 'http') {
+ this.heartBeatMonitor.markHealthy();
+ if (source === 'fd3') {
+ if (
+ this.swLastHTTP.started
+ && this.swLastHTTP.elapsed > 15
+ && this.swLastFD3.elapsed < 5
+ ) {
+ txCore.metrics.txRuntime.registerFxserverHealthIssue('http');
+ }
+ this.swLastFD3.restart();
+ } else if (source === 'http') {
+ if (
+ this.swLastFD3.started
+ && this.swLastFD3.elapsed > 15
+ && this.swLastHTTP.elapsed < 5
+ ) {
+ txCore.metrics.txRuntime.registerFxserverHealthIssue('fd3');
+ }
+ this.swLastHTTP.restart();
+ }
+ }
+
+
+ //MARK: ON BOOT
+ /**
+ * Handles the HeartBeat event from the server.
+ */
+ private async handleBootCompleted(bootDuration: number) {
+ //Check if the server is supposed to be offline
+ const childState = txCore.fxRunner.child;
+ if (!childState?.isAlive || !childState?.netEndpoint) return;
+
+ //Registering the boot
+ txCore.metrics.txRuntime.registerFxserverBoot(bootDuration);
+ txCore.metrics.svRuntime.logServerBoot(bootDuration);
+ txCore.fxRunner.signalSpawnBackoffRequired(false);
+
+ //Fetching runtime data
+ const infoJson = await fetchInfoJson(childState.netEndpoint);
+ if (!infoJson) return;
+
+ //Save icon base64 to file
+ const iconCacheKey = 'fxsRuntime:iconFilename';
+ if (infoJson.icon) {
+ try {
+ const iconHash = crypto
+ .createHash('shake256', { outputLength: 8 })
+ .update(infoJson.icon)
+ .digest('hex')
+ .padStart(16, '0');
+ const iconFilename = `icon-${iconHash}.png`;
+ await setRuntimeFile(iconFilename, Buffer.from(infoJson.icon, 'base64'));
+ txCore.cacheStore.set(iconCacheKey, iconFilename);
+ } catch (error) {
+ console.error(`Failed to save server icon: ${(error as any).message ?? 'Unknown error'}`);
+ }
+ } else {
+ txCore.cacheStore.delete(iconCacheKey);
+ }
+
+ //Upserts the runtime data
+ txCore.cacheStore.upsert('fxsRuntime:bannerConnecting', infoJson.bannerConnecting);
+ txCore.cacheStore.upsert('fxsRuntime:bannerDetail', infoJson.bannerDetail);
+ txCore.cacheStore.upsert('fxsRuntime:locale', infoJson.locale);
+ txCore.cacheStore.upsert('fxsRuntime:projectDesc', infoJson.projectDesc);
+ if (infoJson.projectName) {
+ txCore.cacheStore.set('fxsRuntime:projectName', cleanPlayerName(infoJson.projectName).displayName);
+ } else {
+ txCore.cacheStore.delete('fxsRuntime:projectName');
+ }
+ txCore.cacheStore.upsert('fxsRuntime:tags', infoJson.tags);
+ }
+
+
+ //MARK: Getters
+ /**
+ * Returns the current status object that is sent to the host status endpoint
+ */
+ public get status() {
+ let healthReason = 'Unknown - no log entries.';
+ const lastEntry = this.statusLog.at(-1);
+ if (lastEntry) {
+ const healthReasonLines = [
+ lastEntry.reason + '.',
+ ];
+ if ('issues' in lastEntry) {
+ const cleanIssues = cleanMonitorIssuesArray(lastEntry.issues);
+ healthReasonLines.push(...cleanIssues);
+ }
+ healthReason = healthReasonLines.join('\n');
+ }
+ return {
+ health: this.currentStatus,
+ healthReason,
+ uptime: this.tsServerBooted ? Date.now() - this.tsServerBooted : 0,
+ }
+ }
+};
diff --git a/core/modules/FxMonitor/utils.ts b/core/modules/FxMonitor/utils.ts
new file mode 100644
index 0000000..1ea3277
--- /dev/null
+++ b/core/modules/FxMonitor/utils.ts
@@ -0,0 +1,393 @@
+import got from "@lib/got";
+import { secsToShortestDuration } from "@lib/misc";
+import bytes from "bytes";
+import { RequestError, TimeoutError, type Response as GotResponse } from "got";
+import { z } from "zod";
+import { fromZodError } from "zod-validation-error";
+import type { MonitorIssuesArray } from "./index";
+
+
+/**
+ * Class to easily check elapsed time.
+ * Seconds precision, rounded down, consistent.
+ */
+export class Stopwatch {
+ private readonly autoStart: boolean = false;
+ private tsStart: number | null = null;
+
+ constructor(autoStart?: boolean) {
+ if (autoStart) {
+ this.autoStart = true;
+ this.restart();
+ }
+ }
+
+ /**
+ * Reset the stopwatch (stop and clear).
+ */
+ reset() {
+ if (this.autoStart) {
+ this.restart();
+ } else {
+ this.tsStart = null;
+ }
+ }
+
+ /**
+ * Start or restart the stopwatch.
+ */
+ restart() {
+ this.tsStart = Date.now();
+ }
+
+ /**
+ * Returns if the timer is over a certain amount of time.
+ * Always false if not started.
+ */
+ isOver(secs: number) {
+ const elapsed = this.elapsed;
+ if (elapsed === Infinity) {
+ return false;
+ } else {
+ return elapsed >= secs;
+ }
+ }
+
+ /**
+ * Returns true if the stopwatch is running.
+ */
+ get started() {
+ return this.tsStart !== null;
+ }
+
+ /**
+ * Returns the elapsed time in seconds or Infinity if not started.
+ */
+ get elapsed() {
+ if (this.tsStart === null) {
+ return Infinity;
+ } else {
+ const elapsedMs = Date.now() - this.tsStart;
+ return Math.floor(elapsedMs / 1000);
+ }
+ }
+}
+
+
+/**
+ * Exported enum
+ */
+export enum MonitorState {
+ PENDING = 'PENDING',
+ HEALTHY = 'HEALTHY',
+ DELAYED = 'DELAYED',
+ FATAL = 'FATAL',
+}
+
+
+/**
+ * Class to easily check elapsed time.
+ * Seconds precision, rounded down, consistent.
+ */
+export class HealthEventMonitor {
+ private readonly swLastHealthyEvent = new Stopwatch();
+ private firstHealthyEvent: number | undefined;
+
+ constructor(
+ private readonly delayLimit: number,
+ private readonly fatalLimit: number,
+ ) { }
+
+ /**
+ * Resets the state of the monitor.
+ */
+ public reset() {
+ this.swLastHealthyEvent.reset();
+ this.firstHealthyEvent = undefined;
+ }
+
+ /**
+ * Register a successful event
+ */
+ public markHealthy() {
+ this.swLastHealthyEvent.restart();
+ this.firstHealthyEvent ??= Date.now();
+ }
+
+ /**
+ * Returns the current status of the monitor.
+ */
+ public get status() {
+ let state: MonitorState;
+ if (!this.swLastHealthyEvent.started) {
+ state = MonitorState.PENDING;
+ } else if (this.swLastHealthyEvent.isOver(this.fatalLimit)) {
+ state = MonitorState.FATAL;
+ } else if (this.swLastHealthyEvent.isOver(this.delayLimit)) {
+ state = MonitorState.DELAYED;
+ } else {
+ state = MonitorState.HEALTHY;
+ }
+ return {
+ state,
+ secsSinceLast: this.swLastHealthyEvent.elapsed,
+ secsSinceFirst: this.firstHealthyEvent
+ ? Math.floor((Date.now() - this.firstHealthyEvent) / 1000)
+ : Infinity,
+ }
+ }
+}
+
+type HealthEventMonitorStatus = HealthEventMonitor['status'];
+
+
+/**
+ * Helper to get the time tags for error messages
+ */
+export const getMonitorTimeTags = (
+ heartBeat: HealthEventMonitorStatus,
+ healthCheck: HealthEventMonitorStatus,
+ processUptime: number,
+) => {
+ const secs = (s: number) => Number.isFinite(s) ? secsToShortestDuration(s, { round: false }) : '--';
+ const procTime = secsToShortestDuration(processUptime);
+ const hbTime = secs(heartBeat.secsSinceLast);
+ const hcTime = secs(healthCheck.secsSinceLast);
+ return {
+ simple: `(HB:${hbTime}|HC:${hcTime})`,
+ withProc: `(P:${procTime}|HB:${hbTime}|HC:${hcTime})`,
+ }
+}
+
+
+/**
+ * Processes a MonitorIssuesArray and returns a clean array of strings.
+ */
+export const cleanMonitorIssuesArray = (issues: MonitorIssuesArray | undefined) => {
+ if (!issues || !Array.isArray(issues)) return [];
+
+ let cleanIssues: string[] = [];
+ for (const issue of issues) {
+ if (!issue) continue;
+ if (typeof issue === 'string') {
+ cleanIssues.push(issue);
+ } else {
+ cleanIssues.push(...issue.all.filter(Boolean));
+ }
+ }
+ return cleanIssues;
+}
+
+
+/**
+ * Helper class to organize monitor issues.
+ */
+export class MonitorIssue {
+ private readonly infos: string[] = [];
+ private readonly details: string[] = [];
+ constructor(public title: string) { }
+ setTitle(title: string) {
+ this.title = title;
+ }
+ addInfo(info: string | undefined) {
+ if (!info) return;
+ this.infos.push(info);
+ }
+ addDetail(detail: string | undefined) {
+ if (!detail) return;
+ this.details.push(detail);
+ }
+ get all() {
+ return [this.title, ...this.infos, ...this.details];
+ }
+}
+
+
+/**
+ * Helper to get debug data from a Got error
+ */
+const getRespDebugData = (resp?: GotResponse) => {
+ if (!resp) return { error: 'Response object is undefined.' };
+ try {
+ let truncatedBody = '[resp.body is not a string]';
+ if (typeof resp?.body === 'string') {
+ const bodyCutoff = 512;
+ truncatedBody = resp.body.length > bodyCutoff
+ ? resp.body.slice(0, bodyCutoff) + '[...]'
+ : resp.body;
+ }
+ return {
+ URL: String(resp?.url),
+ Status: `${resp?.statusCode} ${resp?.statusMessage}`,
+ Server: String(resp?.headers?.['server']),
+ Location: String(resp?.headers?.['location']),
+ ContentType: String(resp?.headers?.['content-type']),
+ ContentLength: String(resp?.headers?.['content-length']),
+ BodyLength: bytes(resp?.body?.length),
+ Body: truncatedBody,
+ } as Record;
+ } catch (error) {
+ return {
+ error: `Error getting debug data: ${(error as any).message}`,
+ };
+ }
+}
+
+
+/**
+ * Do a HTTP GET to the /dynamic.json endpoint and parse the JSON response.
+ */
+export const fetchDynamicJson = async (
+ netEndpoint: string,
+ timeout: number
+): Promise => {
+ let resp: GotResponse | undefined;
+ try {
+ resp = await got.get({
+ url: `http://${netEndpoint}/dynamic.json`,
+ maxRedirects: 0,
+ timeout: { request: timeout },
+ retry: { limit: 0 },
+ throwHttpErrors: false,
+ });
+ } catch (error) {
+ let msg, code;
+ if (error instanceof RequestError) {
+ msg = error.message;
+ code = error.code;
+ } else if (error instanceof TimeoutError) {
+ msg = error.message;
+ code = error.code;
+ } else {
+ const err = error as any;
+ msg = err?.message ?? '';
+ code = err?.code ?? '';
+ }
+
+ return {
+ success: false,
+ error: `HealthCheck Request error: ${msg}`,
+ debugData: {
+ message: msg,
+ code: code,
+ ...getRespDebugData(resp),
+ },
+ };
+ }
+
+ //Precalculating error message
+ const debugData = getRespDebugData(resp);
+
+ //Checking response status
+ if (resp.statusCode !== 200) {
+ return {
+ success: false,
+ error: `HealthCheck HTTP status: ${debugData.Status}`,
+ debugData,
+ }
+ }
+
+ //Parsing response
+ if (typeof resp.body !== 'string') {
+ return {
+ success: false,
+ error: `HealthCheck response body is not a string.`,
+ debugData,
+ }
+ }
+ if (!resp.body.length) {
+ return {
+ success: false,
+ error: `HealthCheck response body is empty.`,
+ debugData,
+ }
+ }
+ if (resp.body.toLocaleLowerCase().includes(',
+}
+type FetchDynamicJsonError = {
+ success: false;
+} & VerboseErrorData;
+type FetchDynamicJsonSuccess = {
+ success: true;
+ data: z.infer;
+};
+
+
+/**
+ * Do a HTTP GET to the /info.json endpoint and parse the JSON response.
+ */
+export const fetchInfoJson = async (netEndpoint: string) => {
+ let info: any;
+ try {
+ info = await got.get({
+ url: `http://${netEndpoint}/info.json`,
+ maxRedirects: 0,
+ timeout: { request: 15_000 },
+ retry: { limit: 6 },
+ }).json();
+ } catch (error) {
+ return;
+ }
+
+ const schemas = {
+ icon: z.string().base64(),
+ locale: z.string().nonempty(),
+ projectName: z.string().nonempty(),
+ projectDesc: z.string().nonempty(),
+ bannerConnecting: z.string().url(),
+ bannerDetail: z.string().url(),
+ tags: z.string().nonempty(),
+ };
+
+ return {
+ icon: schemas.icon.safeParse(info.icon)?.data,
+ locale: schemas.locale.safeParse(info.vars?.locale)?.data,
+ projectName: schemas.projectName.safeParse(info.vars?.sv_projectName)?.data,
+ projectDesc: schemas.projectDesc.safeParse(info.vars?.sv_projectDesc)?.data,
+ bannerConnecting: schemas.bannerConnecting.safeParse(info.vars?.banner_connecting)?.data,
+ bannerDetail: schemas.bannerDetail.safeParse(info.vars?.banner_detail)?.data,
+ tags: schemas.tags.safeParse(info.vars?.tags)?.data,
+ };
+}
diff --git a/core/modules/FxPlayerlist/index.ts b/core/modules/FxPlayerlist/index.ts
new file mode 100644
index 0000000..117c7f9
--- /dev/null
+++ b/core/modules/FxPlayerlist/index.ts
@@ -0,0 +1,237 @@
+const modulename = 'FxPlayerlist';
+import { cloneDeep } from 'lodash-es';
+import { ServerPlayer } from '@lib/player/playerClasses.js';
+import { DatabaseActionWarnType, DatabasePlayerType } from '@modules/Database/databaseTypes';
+import consoleFactory from '@lib/console';
+import { PlayerDroppedEventType, PlayerJoiningEventType } from '@shared/socketioTypes';
+import { SYM_SYSTEM_AUTHOR } from '@lib/symbols';
+const console = consoleFactory(modulename);
+
+
+export type PlayerDropEvent = {
+ type: 'txAdminPlayerlistEvent',
+ event: 'playerDropped',
+ id: number,
+ reason: string, //need to check if this is always a string
+ resource?: string,
+ category?: number,
+}
+
+
+/**
+ * Module that holds the server playerlist to mirrors FXServer's internal playerlist state, as well as recently
+ * disconnected players' licenses for quick searches. This also handles the playerJoining and playerDropped events.
+ *
+ * NOTE: licenseCache will keep an array of ['mutex#id', license], to be used for searches from server log clicks.
+ * The licenseCache will contain only the licenses from last 50k disconnected players, which should be one entire
+ * session for the q99.9 servers out there and weight around 4mb.
+ * The idea is: all players with license will be in the database, so storing only license is enough to find them.
+ *
+ * NOTE: #playerlist keeps all players in this session, a heap snapshot revealed that an
+ * average player (no actions) will weight about 520 bytes, and the q9999 of max netid is ~22k,
+ * meaning that for 99.99 of the servers, the list will be under 11mb.
+ * A list with 50k connected players will weight around 26mb, meaning no optimization is required there.
+ */
+export default class FxPlayerlist {
+ #playerlist: (ServerPlayer | undefined)[] = [];
+ licenseCache: [mutexid: string, license: string][] = [];
+ licenseCacheLimit = 50_000; //mutex+id+license * 50_000 = ~4mb
+ joinLeaveLog: [ts: number, isJoin: boolean][] = [];
+ joinLeaveLogLimitTime = 30 * 60 * 1000; //30 mins, [ts+isJoin] * 100_000 = ~4.3mb
+
+
+ /**
+ * Number of online/connected players.
+ */
+ get onlineCount() {
+ return this.#playerlist.filter(p => p && p.isConnected).length;
+ }
+
+
+ /**
+ * Number of players that joined/left in the last hour.
+ */
+ get joinLeaveTally() {
+ let toRemove = 0;
+ const out = { joined: 0, left: 0 };
+ const tsWindowStart = Date.now() - this.joinLeaveLogLimitTime;
+ for (const [ts, isJoin] of this.joinLeaveLog) {
+ if (ts > tsWindowStart) {
+ out[isJoin ? 'joined' : 'left']++;
+ } else {
+ toRemove++;
+ }
+ }
+ this.joinLeaveLog.splice(0, toRemove);
+ return out;
+ }
+
+
+ /**
+ * Handler for server restart - it will kill all players
+ * We MUST do .disconnect() for all players to clear the timers.
+ * NOTE: it's ok for us to overfill before slicing the licenseCache because it's at most ~4mb
+ */
+ handleServerClose(oldMutex: string) {
+ for (const player of this.#playerlist) {
+ if (player) {
+ player.disconnect();
+ if (player.license) {
+ this.licenseCache.push([`${oldMutex}#${player.netid}`, player.license]);
+ }
+ }
+ }
+ this.licenseCache = this.licenseCache.slice(-this.licenseCacheLimit);
+ this.#playerlist = [];
+ this.joinLeaveLog = [];
+ txCore.webServer.webSocket!.buffer('playerlist', {
+ mutex: oldMutex,
+ type: 'fullPlayerlist',
+ playerlist: [],
+ });
+ }
+
+
+ /**
+ * To guarantee multiple instances of the same player license have their dbData synchronized,
+ * this function (called by database.players.update) goes through every matching player
+ * (except the source itself) to update their dbData.
+ */
+ handleDbDataSync(dbData: DatabasePlayerType, srcUniqueId: Symbol) {
+ for (const player of this.#playerlist) {
+ if (
+ player instanceof ServerPlayer
+ && player.isRegistered
+ && player.license === dbData.license
+ && player.uniqueId !== srcUniqueId
+ ) {
+ player.syncUpstreamDbData(dbData);
+ }
+ }
+ }
+
+
+ /**
+ * Returns a playerlist array with ServerPlayer data of all connected players.
+ * The data is cloned to prevent pollution.
+ */
+ getPlayerList() {
+ return this.#playerlist
+ .filter(p => p?.isConnected)
+ .map((p) => {
+ return cloneDeep({
+ 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];
+ }
+
+ /**
+ * Returns a specifc ServerPlayer or undefined.
+ * NOTE: this returns the actual object and not a deep clone!
+ */
+ getOnlinePlayersByLicense(searchLicense: string) {
+ return this.#playerlist.filter(p => p && p.license === searchLicense && p.isConnected) as ServerPlayer[];
+ }
+
+ /**
+ * Returns a set of all online players' licenses.
+ */
+ getOnlinePlayersLicenses() {
+ return new Set(this.#playerlist.filter(p => p && p.isConnected).map(p => p!.license));
+ }
+
+ /**
+ * Receives initial data callback from ServerPlayer and dispatches to the server as stdin.
+ */
+ dispatchInitialPlayerData(playerId: number, pendingWarn: DatabaseActionWarnType) {
+ const cmdData = {
+ netId: playerId,
+ pendingWarn: {
+ author: pendingWarn.author,
+ reason: pendingWarn.reason,
+ actionId: pendingWarn.id,
+ targetNetId: playerId,
+ targetIds: pendingWarn.ids, //not used in the playerWarned handler
+ targetName: pendingWarn.playerName,
+ }
+ }
+ txCore.fxRunner.sendCommand('txaInitialData', [cmdData], SYM_SYSTEM_AUTHOR);
+ }
+
+
+ /**
+ * Handler for all txAdminPlayerlistEvent structured trace events
+ * TODO: use zod for type safety
+ */
+ async handleServerEvents(payload: any, mutex: string) {
+ const currTs = Date.now();
+ if (payload.event === 'playerJoining') {
+ try {
+ if (typeof payload.id !== 'number') throw new Error(`invalid player id`);
+ if (this.#playerlist[payload.id] !== undefined) {
+ this.#playerlist[payload.id]!.disconnect();
+ this.#playerlist[payload.id] = undefined;
+ };
+ const svPlayer = new ServerPlayer(payload.id, payload.player, this);
+ this.#playerlist[payload.id] = svPlayer;
+ this.joinLeaveLog.push([currTs, true]);
+ txCore.logger.server.write([{
+ type: 'playerJoining',
+ src: payload.id,
+ ts: currTs,
+ data: { ids: this.#playerlist[payload.id]!.ids }
+ }], mutex);
+ txCore.webServer.webSocket.buffer('playerlist', {
+ mutex,
+ type: 'playerJoining',
+ netid: svPlayer.netid,
+ displayName: svPlayer.displayName,
+ pureName: svPlayer.pureName,
+ ids: svPlayer.ids,
+ license: svPlayer.license,
+ });
+ } catch (error) {
+ console.log(`playerJoining event error: ${(error as Error).message}`);
+ }
+
+ } else if (payload.event === 'playerDropped') {
+ try {
+ if (typeof payload.id !== 'number') throw new Error(`invalid player id`);
+ if (!(this.#playerlist[payload.id] instanceof ServerPlayer)) throw new Error(`player id not found`);
+ this.#playerlist[payload.id]!.disconnect();
+ this.joinLeaveLog.push([currTs, false]);
+ const reasonCategory = txCore.metrics.playerDrop.handlePlayerDrop(payload);
+ if (reasonCategory !== false) {
+ txCore.logger.server.write([{
+ type: 'playerDropped',
+ src: payload.id,
+ ts: currTs,
+ data: { reason: payload.reason }
+ }], mutex);
+ }
+ txCore.webServer.webSocket.buffer('playerlist', {
+ mutex,
+ type: 'playerDropped',
+ netid: this.#playerlist[payload.id]!.netid,
+ reasonCategory: reasonCategory ? reasonCategory : undefined,
+ });
+ } catch (error) {
+ console.verbose.warn(`playerDropped event error: ${(error as Error).message}`);
+ }
+ } else {
+ console.warn(`Invalid event: ${payload?.event}`);
+ }
+ }
+};
diff --git a/core/modules/FxResources.ts b/core/modules/FxResources.ts
new file mode 100644
index 0000000..4fc7b3b
--- /dev/null
+++ b/core/modules/FxResources.ts
@@ -0,0 +1,143 @@
+const modulename = 'FxResources';
+import consoleFactory from '@lib/console';
+import { Stopwatch } from './FxMonitor/utils';
+const console = consoleFactory(modulename);
+
+
+type ResourceEventType = {
+ type: 'txAdminResourceEvent';
+ resource: string;
+ event: 'onResourceStarting'
+ | 'onResourceStart'
+ | 'onServerResourceStart'
+ | 'onResourceListRefresh'
+ | 'onResourceStop'
+ | 'onServerResourceStop';
+};
+
+type ResourceReportType = {
+ ts: Date,
+ resources: any[]
+}
+
+type ResPendingStartState = {
+ name: string;
+ time: Stopwatch;
+}
+
+type ResBootLogEntry = {
+ tsBooted: number;
+ resource: string;
+ duration: number;
+};
+
+
+/**
+ * Module responsible to track FXServer resource states.
+ * NOTE: currently it is not tracking the state during runtime, and it is just being used
+ * to assist with tracking the boot process.
+ */
+export default class FxResources {
+ public resourceReport?: ResourceReportType;
+ private resBooting: ResPendingStartState | null = null;
+ private resBootLog: ResBootLogEntry[] = [];
+
+
+ /**
+ * Reset boot state on server close
+ */
+ handleServerClose() {
+ this.resBooting = null;
+ this.resBootLog = [];
+ }
+
+
+ /**
+ * Handler for all txAdminResourceEvent FD3 events
+ */
+ handleServerEvents(payload: ResourceEventType, mutex: string) {
+ const { resource, event } = payload;
+ if (!resource || !event) {
+ console.verbose.error(`Invalid txAdminResourceEvent payload: ${JSON.stringify(payload)}`);
+ } else if (event === 'onResourceStarting') {
+ //Resource will start
+ this.resBooting = {
+ name: resource,
+ time: new Stopwatch(true),
+ }
+ } else if (event === 'onResourceStart') {
+ //Resource started
+ this.resBootLog.push({
+ resource,
+ duration: this.resBooting?.time.elapsed ?? 0,
+ tsBooted: Date.now(),
+ })
+ }
+ }
+
+
+ /**
+ * Returns the status of the resource boot process
+ */
+ public get bootStatus() {
+ let elapsedSinceLast = null;
+ if (this.resBootLog.length > 0) {
+ const tsMs = this.resBootLog[this.resBootLog.length - 1].tsBooted;
+ elapsedSinceLast = Math.floor((Date.now() - tsMs) / 1000);
+ }
+ return {
+ current: this.resBooting,
+ elapsedSinceLast,
+ }
+ }
+
+
+ /**
+ * Handle resource report.
+ * NOTE: replace this when we start tracking resource states internally
+ */
+ tmpUpdateResourceList(resources: any[]) {
+ this.resourceReport = {
+ ts: new Date(),
+ resources,
+ }
+ }
+};
+
+/*
+NOTE Resource load scenarios knowledge base:
+- resource lua error:
+ - `onResourceStarting` sourceRes
+ - print lua error
+ - `onResourceStart` sourceRes
+- resource lua crash/hang:
+ - `onResourceStarting` sourceRes
+ - crash/hang
+- dependency missing:
+ - `onResourceStarting` sourceRes
+ - does not get to `onResourceStart`
+- dependency success:
+ - `onResourceStarting` sourceRes
+ - `onResourceStarting` dependency
+ - `onResourceStart` dependency
+ - `onResourceStart` sourceRes
+- webpack/yarn fail:
+ - `onResourceStarting` sourceRes
+ - does not get to `onResourceStart`
+- webpack/yarn success:
+ - `onResourceStarting` chat
+ - `onResourceStarting` yarn
+ - `onResourceStart` yarn
+ - `onResourceStarting` webpack
+ - `onResourceStart` webpack
+ - server first tick
+ - wait for build
+ - `onResourceStarting` chat
+ - `onResourceStart` chat
+- ensure started resource:
+ - `onResourceStop` sourceRes
+ - `onResourceStarting` sourceRes
+ - `onResourceStart` sourceRes
+ - `onServerResourceStop` sourceRes
+ - `onServerResourceStart` sourceRes
+*/
diff --git a/core/modules/FxRunner/ProcessManager.ts b/core/modules/FxRunner/ProcessManager.ts
new file mode 100644
index 0000000..b4fb7e0
--- /dev/null
+++ b/core/modules/FxRunner/ProcessManager.ts
@@ -0,0 +1,220 @@
+// The objective of this file is to isolate the process management logic from the main process.
+// Here no references to txCore, txConfig, or txManager should happen.
+import { childProcessEventBlackHole, type ValidChildProcess } from "./utils";
+import consoleFactory, { processStdioEnsureEol } from "@lib/console";
+
+
+/**
+ * Returns a string with the exit/close code & signal of a child process, properly formatted
+ */
+const getFxChildCodeSignalString = (code?: number | null, signal?: string | null) => {
+ const details = [];
+ if (typeof code === 'number') {
+ details.push(`0x${code.toString(16).toUpperCase()}`);
+ }
+ if (typeof signal === 'string') {
+ details.push(signal.toUpperCase());
+ }
+ if (!details.length) return '--';
+ return details.join(', ');
+}
+
+
+/**
+ * Manages the lifecycle of a child process, isolating process management logic from the main application.
+ */
+export default class ProcessManager {
+ public readonly pid: number;
+ public readonly mutex: string;
+ public readonly netEndpoint: string;
+ private readonly statusCallback: () => void;
+
+ public readonly tsStart = Date.now();
+ private tsKill: number | undefined;
+ private tsExit: number | undefined;
+ private tsClose: number | undefined;
+ private fxs: ValidChildProcess | null;
+ private exitCallback: (() => void) | undefined;
+
+ //TODO: register input/output stats? good for debugging
+
+ constructor(fxs: ValidChildProcess, props: ChildStateProps) {
+ //Sanity check
+ if (!fxs?.stdin?.writable) throw new Error(`Child process stdin is not writable.`);
+ if (!props.mutex) throw new Error(`Invalid mutex.`);
+ if (!props.netEndpoint) throw new Error(`Invalid netEndpoint.`);
+ if (!props.onStatusUpdate) throw new Error(`Invalid status callback.`);
+
+ //Instance properties
+ this.pid = fxs.pid;
+ this.fxs = fxs;
+ this.mutex = props.mutex;
+ this.netEndpoint = props.netEndpoint;
+ this.statusCallback = props.onStatusUpdate;
+ const console = consoleFactory(`FXProc][${this.pid}`);
+
+ //The 'exit' event is emitted after the child process ends,
+ // but the stdio streams might still be open.
+ this.fxs.once('exit', (code, signal) => {
+ this.tsExit = Date.now();
+ const info = getFxChildCodeSignalString(code, signal);
+ processStdioEnsureEol();
+ console.warn(`FXServer Exited (${info}).`);
+ this.exitCallback && this.exitCallback();
+ this.triggerStatusUpdate();
+ if (this.tsExit - this.tsStart <= 5000) {
+ console.defer(500).warn('FXServer didn\'t start. This is not an issue with txAdmin.');
+ }
+ });
+
+ //The 'close' event is emitted after a process has ended _and_
+ // the stdio streams of the child process have been closed.
+ // This event will never be emitted before 'exit'.
+ this.fxs.once('close', (code, signal) => {
+ this.tsClose = Date.now();
+ const info = getFxChildCodeSignalString(code, signal);
+ processStdioEnsureEol();
+ console.warn(`FXServer Closed (${info}).`);
+ this.destroy();
+ });
+
+ //The 'error' event is only relevant for `.kill()` method errors.
+ this.fxs.on('error', (err) => {
+ console.error(`FXServer Error Event:`);
+ console.dir(err);
+ });
+
+ //Signaling the start of the server
+ console.ok(`FXServer Started!`);
+ this.triggerStatusUpdate();
+ }
+
+
+ /**
+ * Safely triggers the status update callback
+ */
+ private triggerStatusUpdate() {
+ try {
+ this.statusCallback();
+ } catch (error) {
+ childProcessEventBlackHole('ProcessManager:statusCallback', error);
+ }
+ }
+
+
+ /**
+ * Ensures we did everything we can to send a kill signal to the child process
+ * and that we are freeing up resources.
+ */
+ public destroy() {
+ if (!this.fxs) return; //Already disposed
+ try {
+ this.tsKill = Date.now();
+ this.fxs?.kill();
+ } catch (error) {
+ childProcessEventBlackHole('ProcessManager:destroy', error);
+ } finally {
+ this.fxs = null;
+ this.triggerStatusUpdate();
+ }
+ }
+
+
+ /**
+ * Registers a callback to be called when the child process is destroyed
+ */
+ public onExit(callback: () => void) {
+ this.exitCallback = callback;
+ }
+
+ /**
+ * Get the proc info/stats for the history
+ */
+ public get stateInfo(): ChildProcessStateInfo {
+ return {
+ pid: this.pid,
+ mutex: this.mutex,
+ netEndpoint: this.netEndpoint,
+
+ tsStart: this.tsStart,
+ tsKill: this.tsKill,
+ tsExit: this.tsExit,
+ tsClose: this.tsClose,
+
+ isAlive: this.isAlive,
+ status: this.status,
+ uptime: this.uptime,
+ };
+ }
+
+
+ /**
+ * If the child process is alive, meaning it has process running and the pipes open
+ */
+ public get isAlive() {
+ return !!this.fxs && !this.tsExit && !this.tsClose;
+ }
+
+
+ /**
+ * The overall state of the child process
+ */
+ public get status(): ChildProcessState {
+ if (!this.fxs) return ChildProcessState.Destroyed;
+ if (this.tsExit) return ChildProcessState.Exited; //should be awaiting close
+ return ChildProcessState.Alive;
+ }
+
+
+ /**
+ * Uptime of the child process, until now or the last event
+ */
+ public get uptime() {
+ const now = Date.now();
+ return Math.min(
+ this.tsKill ?? now,
+ this.tsExit ?? now,
+ this.tsClose ?? now,
+ ) - this.tsStart;
+ }
+
+
+ /**
+ * The stdin stream of the child process, SHOULD be writable if this.isAlive
+ */
+ public get stdin() {
+ return this.fxs?.stdin;
+ }
+}
+
+
+export enum ChildProcessState {
+ Alive = 'ALIVE',
+ Exited = 'EXITED',
+ Destroyed = 'DESTROYED',
+}
+
+type ChildStateProps = {
+ mutex: string;
+ netEndpoint: string;
+ onStatusUpdate: () => void;
+}
+
+
+export type ChildProcessStateInfo = {
+ //Input
+ pid: number;
+ mutex: string;
+ netEndpoint: string;
+
+ //Timings
+ tsStart: number;
+ tsKill?: number;
+ tsExit?: number;
+ tsClose?: number;
+
+ //Status
+ isAlive: boolean; //Same as ChildState.Alive for convenience
+ status: ChildProcessState;
+ uptime: number;
+}
diff --git a/core/modules/FxRunner/handleFd3Messages.ts b/core/modules/FxRunner/handleFd3Messages.ts
new file mode 100644
index 0000000..25a53b3
--- /dev/null
+++ b/core/modules/FxRunner/handleFd3Messages.ts
@@ -0,0 +1,159 @@
+import { anyUndefined } from '@lib/misc';
+import consoleFactory from '@lib/console';
+const console = consoleFactory('FXProc:FD3');
+
+
+//Types
+type StructuredTraceType = {
+ key: number;
+ value: {
+ channel: string;
+ data: any;
+ file: string;
+ func: string;
+ line: number;
+ }
+}
+
+
+/**
+ * Handles bridged commands from txResource.
+ * TODO: use zod for type safety
+ */
+const handleBridgedCommands = (payload: any) => {
+ if (payload.command === 'announcement') {
+ try {
+ //Validate input
+ if (typeof payload.author !== 'string') throw new Error(`invalid author`);
+ if (typeof payload.message !== 'string') throw new Error(`invalid message`);
+ const message = (payload.message ?? '').trim();
+ if (!message.length) throw new Error(`empty message`);
+
+ //Resolve admin
+ const author = payload.author;
+ txCore.logger.admin.write(author, `Sending announcement: ${message}`);
+
+ // Dispatch `txAdmin:events:announcement`
+ txCore.fxRunner.sendEvent('announcement', { message, author });
+
+ // Sending discord announcement
+ const publicAuthor = txCore.adminStore.getAdminPublicName(payload.author, 'message');
+ txCore.discordBot.sendAnnouncement({
+ type: 'info',
+ title: {
+ key: 'nui_menu.misc.announcement_title',
+ data: { author: publicAuthor }
+ },
+ description: message
+ });
+ } catch (error) {
+ console.verbose.warn(`handleBridgedCommands handler error:`);
+ console.verbose.dir(error);
+ }
+ } else {
+ console.warn(`Command bridge received invalid command:`);
+ console.dir(payload);
+ }
+}
+
+
+/**
+ * Processes FD3 Messages
+ *
+ * Mapped message types:
+ * - nucleus_connected
+ * - watchdog_bark
+ * - bind_error
+ * - script_log
+ * - script_structured_trace (handled by server logger)
+ */
+const handleFd3Messages = (mutex: string, trace: StructuredTraceType) => {
+ //Filter valid and fresh packages
+ if (!mutex || mutex !== txCore.fxRunner.child?.mutex) return;
+ if (anyUndefined(trace, trace.value, trace.value.data, trace.value.channel)) return;
+ const { channel, data } = trace.value;
+
+ //Handle bind errors
+ if (channel === 'citizen-server-impl' && data?.type === 'bind_error') {
+ try {
+ const newDelayBackoffMs = txCore.fxRunner.signalSpawnBackoffRequired(true);
+ const [_ip, port] = data.address.split(':');
+ const secs = Math.floor(newDelayBackoffMs / 1000);
+ console.defer().error(`Detected FXServer error: Port ${port} is busy! Setting backoff delay to ${secs}s.`);
+ } catch (e) { }
+ return;
+ }
+
+ //Handle nucleus auth
+ if (channel === 'citizen-server-impl' && data.type === 'nucleus_connected') {
+ if (typeof data.url !== 'string') {
+ console.error(`FD3 nucleus_connected event without URL.`);
+ } else {
+ try {
+ const matches = /^(https:\/\/)?.*-([0-9a-z]{6,})\.users\.cfx\.re\/?$/.exec(data.url);
+ if (!matches || !matches[2]) throw new Error(`invalid cfxid`);
+ txCore.cacheStore.set('fxsRuntime:cfxId', matches[2]);
+ } catch (error) {
+ console.error(`Error decoding server nucleus URL.`);
+ }
+ }
+ return;
+ }
+
+ //Handle watchdog
+ if (channel === 'citizen-server-impl' && data.type === 'watchdog_bark') {
+ setTimeout(() => {
+ const thread = data?.thread ?? 'UNKNOWN';
+ if(!data?.stack || data.stack.trim() === 'root'){
+ console.error(`Detected server thread ${thread} hung without a stack trace.`);
+ } else {
+ console.error(`Detected server thread ${thread} hung with stack:`);
+ console.error(`- ${data.stack}`);
+ console.error('Please check the resource above to prevent server restarts.');
+ }
+ }, 250);
+ return;
+ }
+
+ // if (data.type == 'script_log') {
+ // return console.dir(data);
+ // }
+
+ //Handle script traces
+ if (
+ channel === 'citizen-server-impl'
+ && data.type === 'script_structured_trace'
+ && data.resource === 'monitor'
+ ) {
+ if (data.payload.type === 'txAdminHeartBeat') {
+ txCore.fxMonitor.handleHeartBeat('fd3');
+ } else if (data.payload.type === 'txAdminLogData') {
+ txCore.logger.server.write(data.payload.logs, mutex);
+ } else if (data.payload.type === 'txAdminLogNodeHeap') {
+ txCore.metrics.svRuntime.logServerNodeMemory(data.payload);
+ } else if (data.payload.type === 'txAdminResourceEvent') {
+ txCore.fxResources.handleServerEvents(data.payload, mutex);
+ } else if (data.payload.type === 'txAdminPlayerlistEvent') {
+ txCore.fxPlayerlist.handleServerEvents(data.payload, mutex);
+ } else if (data.payload.type === 'txAdminCommandBridge') {
+ handleBridgedCommands(data.payload);
+ } else if (data.payload.type === 'txAdminAckWarning') {
+ txCore.database.actions.ackWarn(data.payload.actionId);
+ }
+ }
+
+}
+
+
+/**
+ * Handles all the FD3 traces from the FXServer
+ * NOTE: this doesn't need to be a class, but might need to hold state in the future
+ */
+export default (mutex: string, trace: StructuredTraceType) => {
+ try {
+ handleFd3Messages(mutex, trace);
+ } catch (error) {
+ console.verbose.error('Error processing FD3 stream output:');
+ console.verbose.dir(error);
+ }
+};
diff --git a/core/modules/FxRunner/index.ts b/core/modules/FxRunner/index.ts
new file mode 100644
index 0000000..4a2c6e9
--- /dev/null
+++ b/core/modules/FxRunner/index.ts
@@ -0,0 +1,530 @@
+import { spawn } from 'node:child_process';
+import { setTimeout as sleep } from 'node:timers/promises';
+import StreamValues from 'stream-json/streamers/StreamValues';
+import { customAlphabet } from 'nanoid/non-secure';
+import dict49 from 'nanoid-dictionary/nolookalikes';
+import consoleFactory from '@lib/console';
+import { resolveCFGFilePath, validateFixServerConfig } from '@lib/fxserver/fxsConfigHelper';
+import { msToShortishDuration } from '@lib/misc';
+import { SYM_SYSTEM_AUTHOR } from '@lib/symbols';
+import { UpdateConfigKeySet } from '@modules/ConfigStore/utils';
+import { childProcessEventBlackHole, getFxSpawnVariables, getMutableConvars, isValidChildProcess, mutableConvarConfigDependencies, setupCustomLocaleFile, stringifyConsoleArgs } from './utils';
+import ProcessManager, { ChildProcessStateInfo } from './ProcessManager';
+import handleFd3Messages from './handleFd3Messages';
+import ConsoleLineEnum from '@modules/Logger/FXServerLogger/ConsoleLineEnum';
+import { txHostConfig } from '@core/globalData';
+import path from 'node:path';
+const console = consoleFactory('FxRunner');
+const genMutex = customAlphabet(dict49, 5);
+
+const MIN_KILL_DELAY = 250;
+
+
+/**
+ * Module responsible for handling the FXServer process.
+ *
+ * FIXME: the methods that return error string should either throw or return
+ * a more detailed and better formatted object
+ */
+export default class FxRunner {
+ static readonly configKeysWatched = [...mutableConvarConfigDependencies];
+
+ public readonly history: ChildProcessStateInfo[] = [];
+ private proc: ProcessManager | null = null;
+ private isAwaitingShutdownNoticeDelay = false;
+ private isAwaitingRestartSpawnDelay = false;
+ private restartSpawnBackoffDelay = 0;
+
+
+ //MARK: SIGNALS
+ /**
+ * Triggers a convar update
+ */
+ public handleConfigUpdate(updatedConfigs: UpdateConfigKeySet) {
+ this.updateMutableConvars().catch(() => { });
+ }
+
+
+ /**
+ * Gracefully shutdown when txAdmin gets an exit event.
+ * There is no time for a more graceful shutdown with announcements and events.
+ * Will only use the quit command and wait for the process to exit.
+ */
+ public handleShutdown() {
+ if (!this.proc?.isAlive || !this.proc.stdin) return null;
+ this.proc.stdin.write('quit "host shutting down"\n');
+ return new Promise((resolve) => {
+ this.proc?.onExit(resolve); //will let fxserver finish by itself
+ });
+ }
+
+
+ /**
+ * Receives the signal that all the start banner was already printed and other modules loaded
+ */
+ public signalStartReady() {
+ if (!txConfig.server.autoStart) return;
+
+ if (!this.isConfigured) {
+ return console.warn('Please open txAdmin on the browser to configure your server.');
+ }
+
+ if (!txCore.adminStore.hasAdmins()) {
+ return console.warn('The server will not auto start because there are no admins configured.');
+ }
+
+ if (txConfig.server.quiet || txHostConfig.forceQuietMode) {
+ console.defer(1000).warn('FXServer Quiet mode is enabled. Access the Live Console to see the logs.');
+ }
+
+ this.spawnServer(true);
+ }
+
+
+ /**
+ * Handles boot signals related to bind errors and sets the backoff delay.
+ * On successfull bind, the backoff delay is reset to 0.
+ * On bind error, the backoff delay is increased by 5s, up to 45s.
+ * @returns the new backoff delay in ms
+ */
+ public signalSpawnBackoffRequired(required: boolean) {
+ if (required) {
+ this.restartSpawnBackoffDelay = Math.min(
+ this.restartSpawnBackoffDelay + 5_000,
+ 45_000
+ );
+ } else {
+ if (this.restartSpawnBackoffDelay) {
+ console.verbose.debug('Server booted successfully, resetting spawn backoff delay.');
+ }
+ this.restartSpawnBackoffDelay = 0;
+ }
+ return this.restartSpawnBackoffDelay;
+ }
+
+
+ //MARK: SPAWN
+ /**
+ * Spawns the FXServer and sets up all the event handlers.
+ * NOTE: Don't use txConfig in here to avoid race conditions.
+ */
+ public async spawnServer(shouldAnnounce = false) {
+ //If txAdmin is shutting down
+ if(txManager.isShuttingDown) {
+ const msg = `Cannot start the server while txAdmin is shutting down.`;
+ console.error(msg);
+ return msg;
+ }
+
+ //If the server is already alive
+ if (this.proc !== null) {
+ const msg = `The server has already started.`;
+ console.error(msg);
+ return msg;
+ }
+
+ //Setup spawn variables & locale file
+ let fxSpawnVars;
+ const newServerMutex = genMutex();
+ try {
+ txCore.webServer.resetToken();
+ fxSpawnVars = getFxSpawnVariables();
+ // debugPrintSpawnVars(fxSpawnVars); //DEBUG
+ } catch (error) {
+ const errMsg = `Error setting up spawn variables: ${(error as any).message}`;
+ console.error(errMsg);
+ return errMsg;
+ }
+ try {
+ await setupCustomLocaleFile();
+ } catch (error) {
+ const errMsg = `Error copying custom locale: ${(error as any).message}`;
+ console.error(errMsg);
+ return errMsg;
+ }
+
+ //If there is any FXServer configuration missing
+ if (!this.isConfigured) {
+ const msg = `Cannot start the server with missing configuration (serverDataPath || cfgPath).`;
+ console.error(msg);
+ return msg;
+ }
+
+ //Validating server.cfg & configuration
+ let netEndpointDetected: string;
+ try {
+ const result = await validateFixServerConfig(fxSpawnVars.cfgPath, fxSpawnVars.dataPath);
+ if (result.errors || !result.connectEndpoint) {
+ const msg = `**Unable to start the server due to error(s) in your config file(s):**\n${result.errors}`;
+ console.error(msg);
+ return msg;
+ }
+ if (result.warnings) {
+ const msg = `**Warning regarding your configuration file(s):**\n${result.warnings}`;
+ console.warn(msg);
+ }
+
+ netEndpointDetected = result.connectEndpoint;
+ } catch (error) {
+ const errMsg = `server.cfg error: ${(error as any).message}`;
+ console.error(errMsg);
+ if ((error as any).message.includes('unreadable')) {
+ console.error('That is the file where you configure your server and start resources.');
+ console.error('You likely moved/deleted your server files or copied the txData folder from another server.');
+ console.error('To fix this issue, open the txAdmin web interface then go to "Settings > FXServer" and fix the "Server Data Folder" and "CFG File Path".');
+ }
+ return errMsg;
+ }
+
+ //Reseting monitor stats
+ txCore.fxMonitor.resetState();
+
+ //Resetting frontend playerlist
+ txCore.webServer.webSocket.buffer('playerlist', {
+ mutex: newServerMutex,
+ type: 'fullPlayerlist',
+ playerlist: [],
+ });
+
+ //Announcing
+ if (shouldAnnounce) {
+ txCore.discordBot.sendAnnouncement({
+ type: 'success',
+ description: {
+ key: 'server_actions.spawning_discord',
+ data: { servername: fxSpawnVars.serverName },
+ },
+ });
+ }
+
+ //Starting server
+ const childProc = spawn(
+ fxSpawnVars.bin,
+ fxSpawnVars.args,
+ {
+ cwd: fxSpawnVars.dataPath,
+ stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
+ },
+ );
+ if (!isValidChildProcess(childProc)) {
+ const errMsg = `Failed to run \n${fxSpawnVars.bin}`;
+ console.error(errMsg);
+ return errMsg;
+ }
+ this.proc = new ProcessManager(childProc, {
+ mutex: newServerMutex,
+ netEndpoint: netEndpointDetected,
+ onStatusUpdate: () => {
+ txCore.webServer.webSocket.pushRefresh('status');
+ }
+ });
+ txCore.logger.fxserver.logFxserverSpawn(this.proc.pid.toString());
+
+ //Setting up StdIO
+ childProc.stdout.setEncoding('utf8');
+ childProc.stdout.on('data',
+ txCore.logger.fxserver.writeFxsOutput.bind(
+ txCore.logger.fxserver,
+ ConsoleLineEnum.StdOut,
+ ),
+ );
+ childProc.stderr.on('data',
+ txCore.logger.fxserver.writeFxsOutput.bind(
+ txCore.logger.fxserver,
+ ConsoleLineEnum.StdErr,
+ ),
+ );
+ const jsoninPipe = childProc.stdio[3].pipe(StreamValues.withParser() as any);
+ jsoninPipe.on('data', handleFd3Messages.bind(null, newServerMutex));
+
+ //_Almost_ don't care
+ childProc.stdin.on('error', childProcessEventBlackHole);
+ childProc.stdin.on('data', childProcessEventBlackHole);
+ childProc.stdout.on('error', childProcessEventBlackHole);
+ childProc.stderr.on('error', childProcessEventBlackHole);
+ childProc.stdio[3].on('error', childProcessEventBlackHole);
+
+ //FIXME: return a more detailed object
+ return null;
+ }
+
+
+ //MARK: CONTROL
+ /**
+ * Restarts the FXServer
+ */
+ public async restartServer(reason: string, author: string | typeof SYM_SYSTEM_AUTHOR) {
+ //Prevent concurrent restart request
+ const respawnDelay = this.restartSpawnDelay;
+ if (this.isAwaitingRestartSpawnDelay) {
+ const durationStr = msToShortishDuration(
+ respawnDelay.ms,
+ { units: ['m', 's', 'ms'] }
+ );
+ return `A restart is already in progress, with a delay of ${durationStr}.`;
+ }
+
+ try {
+ //Restart server
+ const killError = await this.killServer(reason, author, true);
+ if (killError) return killError;
+
+ //Give time for the OS to release the ports
+
+ if (respawnDelay.isBackoff) {
+ console.warn(`Restarting the fxserver with backoff delay of ${respawnDelay.ms}ms`);
+ }
+ this.isAwaitingRestartSpawnDelay = true;
+ await sleep(respawnDelay.ms);
+ this.isAwaitingRestartSpawnDelay = false;
+
+ //Start server again :)
+ return await this.spawnServer();
+ } catch (error) {
+ const errMsg = `Couldn't restart the server.`;
+ console.error(errMsg);
+ console.verbose.dir(error);
+ return errMsg;
+ } finally {
+ //Make sure the flag is reset
+ this.isAwaitingRestartSpawnDelay = false;
+ }
+ }
+
+
+ /**
+ * Kills the FXServer child process.
+ * NOTE: isRestarting might be true even if not called by this.restartServer().
+ */
+ public async killServer(reason: string, author: string | typeof SYM_SYSTEM_AUTHOR, isRestarting = false) {
+ if (!this.proc) return null; //nothing to kill
+
+ //Prepare vars
+ const shutdownDelay = Math.max(txConfig.server.shutdownNoticeDelayMs, MIN_KILL_DELAY);
+ const reasonString = reason ?? 'no reason provided';
+ const messageType = isRestarting ? 'restarting' : 'stopping';
+ const messageColor = isRestarting ? 'warning' : 'danger';
+ const tOptions = {
+ servername: txConfig.general.serverName,
+ reason: reasonString,
+ };
+
+ //Prevent concurrent kill request
+ if (this.isAwaitingShutdownNoticeDelay) {
+ const durationStr = msToShortishDuration(
+ shutdownDelay,
+ { units: ['m', 's', 'ms'] }
+ );
+ return `A shutdown is already in progress, with a delay of ${durationStr}.`;
+ }
+
+ try {
+ //If the process is alive, send warnings event and await the delay
+ if (this.proc.isAlive) {
+ this.sendEvent('serverShuttingDown', {
+ delay: txConfig.server.shutdownNoticeDelayMs,
+ author: typeof author === 'string' ? author : 'txAdmin',
+ message: txCore.translator.t(`server_actions.${messageType}`, tOptions),
+ });
+ this.isAwaitingShutdownNoticeDelay = true;
+ await sleep(shutdownDelay);
+ this.isAwaitingShutdownNoticeDelay = false;
+ }
+
+ //Stopping server
+ this.proc.destroy();
+ const debugInfo = this.proc.stateInfo;
+ this.history.push(debugInfo);
+ this.proc = null;
+
+ //Cleanup
+ txCore.fxScheduler.handleServerClose();
+ txCore.fxResources.handleServerClose();
+ txCore.fxPlayerlist.handleServerClose(debugInfo.mutex);
+ txCore.metrics.svRuntime.logServerClose(reasonString);
+ txCore.discordBot.sendAnnouncement({
+ type: messageColor,
+ description: {
+ key: `server_actions.${messageType}_discord`,
+ data: tOptions,
+ },
+ }).catch(() => { });
+ return null;
+ } catch (error) {
+ const msg = `Couldn't kill the server. Perhaps What Is Dead May Never Die.`;
+ console.error(msg);
+ console.verbose.dir(error);
+ this.proc = null;
+ return msg;
+ } finally {
+ //Make sure the flag is reset
+ this.isAwaitingShutdownNoticeDelay = false;
+ }
+ }
+
+
+ //MARK: COMMANDS
+ /**
+ * Resets the convars in the server.
+ * 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() {
+ console.log('Updating FXServer ConVars.');
+ try {
+ await setupCustomLocaleFile();
+ const convarList = getMutableConvars(false);
+ for (const [set, convar, value] of convarList) {
+ this.sendCommand(set, [convar, value], SYM_SYSTEM_AUTHOR);
+ }
+ return this.sendEvent('configChanged');
+ } catch (error) {
+ console.verbose.error('Error updating FXServer ConVars');
+ console.verbose.dir(error);
+ return false;
+ }
+ }
+
+
+ /**
+ * Fires an `txAdmin:event` inside the server via srvCmd > stdin > command > lua broadcaster.
+ * @returns true if the command was sent successfully, false otherwise.
+ */
+ public sendEvent(eventType: string, data = {}) {
+ if (typeof eventType !== 'string' || !eventType) throw new Error('invalid eventType');
+ try {
+ return this.sendCommand('txaEvent', [eventType, data], SYM_SYSTEM_AUTHOR);
+ } catch (error) {
+ console.verbose.error(`Error writing firing server event ${eventType}`);
+ console.verbose.dir(error);
+ return false;
+ }
+ }
+
+
+ /**
+ * Formats and sends commands to fxserver's stdin.
+ */
+ public sendCommand(
+ cmdName: string,
+ cmdArgs: (string | number | object)[],
+ author: string | typeof SYM_SYSTEM_AUTHOR
+ ) {
+ if (!this.proc?.isAlive) return false;
+ if (typeof cmdName !== 'string' || !cmdName.length) throw new Error('cmdName is empty');
+ if (!Array.isArray(cmdArgs)) throw new Error('cmdArgs is not an array');
+ //NOTE: technically fxserver accepts anything but space and ; in the command name
+ if (!/^\w+$/.test(cmdName)) {
+ throw new Error('invalid cmdName string');
+ }
+
+ // Send the command to the server
+ const rawInput = `${cmdName} ${stringifyConsoleArgs(cmdArgs)}`;
+ return this.sendRawCommand(rawInput, author);
+ }
+
+
+ /**
+ * Writes to fxchild's stdin.
+ * NOTE: do not send commands with \n at the end, this function will add it.
+ */
+ public sendRawCommand(command: string, author: string | typeof SYM_SYSTEM_AUTHOR) {
+ if (!this.proc?.isAlive) return false;
+ if (typeof command !== 'string') throw new Error('Expected command as String!');
+ if (author !== SYM_SYSTEM_AUTHOR && (typeof author !== 'string' || !author.length)) {
+ throw new Error('Expected non-empty author as String or Symbol!');
+ }
+ try {
+ const success = this.proc.stdin?.write(command + '\n');
+ if (author === SYM_SYSTEM_AUTHOR) {
+ txCore.logger.fxserver.logSystemCommand(command);
+ } else {
+ txCore.logger.fxserver.logAdminCommand(author, command);
+ }
+ return success;
+ } catch (error) {
+ console.error('Error writing to fxChild\'s stdin.');
+ console.verbose.dir(error);
+ return false;
+ }
+ }
+
+
+ //MARK: GETTERS
+ /**
+ * The ChildProcessStateInfo of the current FXServer, or null
+ */
+ public get child() {
+ return this.proc?.stateInfo;
+ }
+
+
+ /**
+ * If the server is _supposed to_ not be running.
+ * It takes into consideration the RestartSpawnDelay.
+ * - TRUE: server never started, or failed during a start/restart.
+ * - FALSE: server started, but might have been killed or crashed.
+ */
+ public get isIdle() {
+ return !this.proc && !this.isAwaitingRestartSpawnDelay;
+ }
+
+
+ /**
+ * True if both the serverDataPath and cfgPath are configured
+ */
+ public get isConfigured() {
+ return typeof txConfig.server.dataPath === 'string'
+ && txConfig.server.dataPath.length > 0
+ && typeof txConfig.server.cfgPath === 'string'
+ && txConfig.server.cfgPath.length > 0;
+ }
+
+
+ /**
+ * The resolved paths of the server
+ * FIXME: check where those paths are needed and only calculate what is relevant
+ */
+ public get serverPaths() {
+ if (!this.isConfigured) return;
+ return {
+ dataPath: path.normalize(txConfig.server.dataPath!), //to maintain consistency
+ cfgPath: resolveCFGFilePath(txConfig.server.cfgPath, txConfig.server.dataPath!),
+ }
+ // return {
+ // data: {
+ // absolute: 'xxx',
+ // },
+ // //TODO: cut paste logic from resolveCFGFilePath
+ // resources: {
+ // //???
+ // },
+ // cfg: {
+ // fileName: 'xxx',
+ // relativePath: 'xxx',
+ // absolutePath: 'xxx',
+ // }
+ // };
+ }
+
+
+ /**
+ * The duration in ms that FxRunner should wait between killing the server and starting it again.
+ * This delay is present to avoid weird issues with the OS not releasing the endpoint in time.
+ * NOTE: reminder that the config might be 0ms
+ */
+ public get restartSpawnDelay() {
+ let ms = txConfig.server.restartSpawnDelayMs;
+ let isBackoff = false;
+ if (this.restartSpawnBackoffDelay >= ms) {
+ ms = this.restartSpawnBackoffDelay;
+ isBackoff = true;
+ }
+
+ return {
+ ms,
+ isBackoff,
+ // isDefault: ms === ConfigStore.SchemaDefaults.server.restartSpawnDelayMs
+ }
+ }
+};
diff --git a/core/modules/FxRunner/utils.test.ts b/core/modules/FxRunner/utils.test.ts
new file mode 100644
index 0000000..966fadc
--- /dev/null
+++ b/core/modules/FxRunner/utils.test.ts
@@ -0,0 +1,159 @@
+import { suite, it, expect, test } from 'vitest';
+import { sanitizeConsoleArgString, stringifyConsoleArgs } from './utils';
+
+const fxsArgsParser = (str: string) => {
+ const args: string[] = [];
+ let current = ''
+ let inQuotes = false
+ for (let i = 0; i < str.length; i++) {
+ const char = str[i]
+ if (char === '\\' && i + 1 < str.length && str[i + 1] === '"') {
+ current += '"'
+ i++
+ continue
+ }
+ if (char === '"') {
+ inQuotes = !inQuotes
+ continue
+ }
+ if (/\s/.test(char) && !inQuotes) {
+ if (current.length) {
+ args.push(current)
+ current = ''
+ }
+ continue
+ }
+ //test for semicolon - if odd number of escaped double quotes
+ //NOTE: you can escape " but not a \
+ if (char === ';') {
+ if (inQuotes) {
+ const escapedQuotes = current.match(/(? {
+ expect(fxsArgsParser('')).toEqual(['']);
+ expect(fxsArgsParser('a')).toEqual(['a']);
+ expect(fxsArgsParser('ab')).toEqual(['ab']);
+
+ expect(fxsArgsParser('a b')).toEqual(['a', 'b']);
+ expect(fxsArgsParser('"a b"')).toEqual(['a b']);
+ expect(fxsArgsParser('"a b\\"')).toEqual(['a b"']);
+ expect(fxsArgsParser('"a b')).toEqual(['a b']);
+ expect(fxsArgsParser('"a\\ b"')).toEqual(['a\\ b']);
+ expect(fxsArgsParser('"a\\" b"')).toEqual(['a" b']);
+ expect(fxsArgsParser('"a\\"\\" b"')).toEqual(['a"" b']);
+ expect(fxsArgsParser('"a;b"')).toEqual(['a;b']);
+
+ expect(fxsArgsParser('"a\\b"')).toEqual(['a\\b']);
+ expect(fxsArgsParser('"a\\\\b"')).toEqual(['a\\\\b']);
+
+ expect(fxsArgsParser('"a;b;c"')).toEqual(['a;b;c']);
+ expect(() => fxsArgsParser('"a\\";b;c"')).toThrow(); //cmd b + cmd c
+ expect(() => fxsArgsParser('"a;b\\";c"')).toThrow(); //cmd c only
+
+ expect(() => fxsArgsParser('a;b')).toThrow(); //cmd
+ expect(() => fxsArgsParser('"a\\";b"')).toThrow(); //cmd
+ expect(fxsArgsParser('"a\\"\\";b"')).toEqual(['a"";b']);
+ expect(fxsArgsParser('"a;\\"b"')).toEqual(['a;"b']);
+ expect(fxsArgsParser('"a;\\"\\"b"')).toEqual(['a;""b']);
+ expect(() => fxsArgsParser('"a\\";\\"b"')).toThrow(); //cmd
+ expect(fxsArgsParser('"a\\"\\";\\"b"')).toEqual(['a"";"b']);
+ expect(() => fxsArgsParser('"a\\"\\"\\";b"')).toThrow(); //cmd
+
+ //testing separators
+ expect(fxsArgsParser('a')).toEqual(['a']);
+ expect(fxsArgsParser('a.b')).toEqual(['a.b']);
+ expect(fxsArgsParser('a,b')).toEqual(['a,b']);
+ expect(fxsArgsParser('a!b')).toEqual(['a!b']);
+ expect(fxsArgsParser('a-b')).toEqual(['a-b']);
+ expect(fxsArgsParser('a_b')).toEqual(['a_b']);
+ expect(fxsArgsParser('a:b')).toEqual(['a:b']);
+ expect(fxsArgsParser('a^b')).toEqual(['a^b']);
+ expect(fxsArgsParser('a~b')).toEqual(['a~b']);
+});
+
+
+suite('sanitizeConsoleArgString', () => {
+ it('should throw for non-strings', () => {
+ //@ts-expect-error
+ expect(() => sanitizeConsoleArgString()).toThrow();
+ //@ts-expect-error
+ expect(() => sanitizeConsoleArgString(123)).toThrow();
+ //@ts-expect-error
+ expect(() => sanitizeConsoleArgString([])).toThrow();
+ //@ts-expect-error
+ expect(() => sanitizeConsoleArgString({})).toThrow();
+ //@ts-expect-error
+ expect(() => sanitizeConsoleArgString(null)).toThrow();
+ });
+
+ it('should correctly stringify simple strings', () => {
+ expect(sanitizeConsoleArgString('')).toBe('');
+ expect(sanitizeConsoleArgString('test')).toBe('test');
+ expect(sanitizeConsoleArgString('aa bb')).toBe('aa bb');
+ });
+ it('should correctly handle strings with double quotes', () => {
+ expect(sanitizeConsoleArgString('te"st')).toBe('te"st');
+ expect(sanitizeConsoleArgString('"quoted"')).toBe('"quoted"');
+ });
+ it('should handle semicolons', () => {
+ expect(sanitizeConsoleArgString(';')).toBe('\u037e');
+ expect(sanitizeConsoleArgString('a;b')).toBe('a\u037eb');
+ });
+});
+
+
+suite('stringifyConsoleArgs', () => {
+ suite('string args', () => {
+ it('should correctly stringify simple strings', () => {
+ expect(stringifyConsoleArgs([''])).toEqual('""');
+ expect(stringifyConsoleArgs(['test'])).toEqual('"test"');
+ expect(stringifyConsoleArgs(['aa bb'])).toEqual('"aa bb"');
+ });
+ it('should correctly handle strings with double quotes', () => {
+ expect(stringifyConsoleArgs(['te"st'])).toEqual('"te\\"st"');
+ expect(stringifyConsoleArgs(['"quoted"'])).toEqual('"\\"quoted\\""');
+ });
+ it('should handle semicolons', () => {
+ expect(stringifyConsoleArgs([';'])).toEqual('"\u037e"');
+ expect(stringifyConsoleArgs(['a;b'])).toEqual('"a\u037eb"');
+ });
+ });
+
+ suite('non-string arg', () => {
+ //The decoder mimics fxserver + lua behavior
+ const nonStringDecoder = (str: string) => {
+ const args = fxsArgsParser(str);
+ if (!args.length) throw new Error('no args');
+ const cleanedUp = args[0].replaceAll('\u037e', ';');
+ return JSON.parse(cleanedUp);
+ }
+ const both = (input: any) => nonStringDecoder(stringifyConsoleArgs([input]));
+
+ it('should correctly stringify numbers', () => {
+ expect(both(123)).toEqual(123);
+ expect(both(123.456)).toEqual(123.456);
+ });
+ it('should correctly handle simple objects', () => {
+ expect(both({ a: true })).toEqual({ a: true });
+ expect(both({ a: true, b: "bb", c: 123 })).toEqual({ a: true, b: "bb", c: 123 });
+ expect(both({ a: { test: true } })).toEqual({ a: { test: true } });
+ });
+ it('should correctly handle gotcha objects', () => {
+ expect(both({ a: 'aa"bb' })).toEqual({ a: 'aa"bb' });
+ expect(both({ a: 'aa;bb' })).toEqual({ a: 'aa;bb' });
+ });
+ });
+
+});
diff --git a/core/modules/FxRunner/utils.ts b/core/modules/FxRunner/utils.ts
new file mode 100644
index 0000000..1d4670e
--- /dev/null
+++ b/core/modules/FxRunner/utils.ts
@@ -0,0 +1,240 @@
+import fsp from 'node:fs/promises';
+import type { ChildProcessWithoutNullStreams } from "node:child_process";
+import { Readable, Writable } from "node:stream";
+import { txEnv, txHostConfig } from "@core/globalData";
+import { redactStartupSecrets } from "@lib/misc";
+import path from "path";
+
+
+/**
+ * Blackhole event logger
+ */
+let lastBlackHoleSpewTime = 0;
+const blackHoleSpillMaxInterval = 5000;
+export const childProcessEventBlackHole = (...args: any[]) => {
+ const currentTime = Date.now();
+ if (currentTime - lastBlackHoleSpewTime > blackHoleSpillMaxInterval) {
+ //Let's call this "hawking radiation"
+ console.verbose.error('ChildProcess unexpected event:');
+ console.verbose.dir(args);
+ lastBlackHoleSpewTime = currentTime;
+ }
+};
+
+
+/**
+ * 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 convars: RawConvarSetTuple[] = [
+ ['setr', 'locale', txConfig.general.language ?? 'en'],
+ ['set', 'serverName', txConfig.general.serverName ?? 'txAdmin'],
+ ['set', 'checkPlayerJoin', checkPlayerJoin],
+ ['set', 'menuAlignRight', txConfig.gameFeatures.menuAlignRight],
+ ['set', 'menuPageKey', txConfig.gameFeatures.menuPageKey],
+ ['set', 'playerModePtfx', txConfig.gameFeatures.playerModePtfx],
+ ['set', 'hideAdminInPunishments', txConfig.gameFeatures.hideAdminInPunishments],
+ ['set', 'hideAdminInMessages', txConfig.gameFeatures.hideAdminInMessages],
+ ['set', 'hideDefaultAnnouncement', txConfig.gameFeatures.hideDefaultAnnouncement],
+ ['set', 'hideDefaultDirectMessage', txConfig.gameFeatures.hideDefaultDirectMessage],
+ ['set', 'hideDefaultWarning', txConfig.gameFeatures.hideDefaultWarning],
+ ['set', 'hideDefaultScheduledRestartWarning', txConfig.gameFeatures.hideDefaultScheduledRestartWarning],
+
+ // //NOTE: no auto update, maybe we shouldn't tie core and server verbosity anyways
+ // ['setr', 'verbose', console.isVerbose],
+ ];
+ return convars.map((c) => polishConvarSetTuple(c, isCmdLine));
+};
+
+type RawConvarSetTuple = [setter: string, name: string, value: any];
+type ConvarSetTuple = [setter: string, name: string, value: string];
+
+const polishConvarSetTuple = ([setter, name, value]: RawConvarSetTuple, isCmdLine = false): ConvarSetTuple => {
+ return [
+ isCmdLine ? `+${setter}` : setter,
+ 'txAdmin-' + name,
+ value.toString(),
+ ];
+}
+
+export const mutableConvarConfigDependencies = [
+ 'general.*',
+ 'gameFeatures.*',
+ 'banlist.enabled',
+ 'whitelist.mode',
+];
+
+
+/**
+ * Pre calculating HOST dependent spawn variables
+ */
+const txCoreEndpoint = txHostConfig.netInterface
+ ? `${txHostConfig.netInterface}:${txHostConfig.txaPort}`
+ : `127.0.0.1:${txHostConfig.txaPort}`;
+let osSpawnVars: OsSpawnVars;
+if (txEnv.isWindows) {
+ osSpawnVars = {
+ bin: `${txEnv.fxsPath}/FXServer.exe`,
+ args: [],
+ };
+} else {
+ const alpinePath = path.resolve(txEnv.fxsPath, '../../');
+ osSpawnVars = {
+ bin: `${alpinePath}/opt/cfx-server/ld-musl-x86_64.so.1`,
+ args: [
+ '--library-path', `${alpinePath}/usr/lib/v8/:${alpinePath}/lib/:${alpinePath}/usr/lib/`,
+ '--',
+ `${alpinePath}/opt/cfx-server/FXServer`,
+ '+set', 'citizen_dir', `${alpinePath}/opt/cfx-server/citizen/`,
+ ],
+ };
+}
+
+type OsSpawnVars = {
+ bin: string;
+ args: string[];
+}
+
+
+/**
+ * Returns the variables needed to spawn the server
+ */
+export const getFxSpawnVariables = (): FxSpawnVariables => {
+ if (!txConfig.server.dataPath) throw new Error('Missing server data path');
+
+ const cmdArgs = [
+ ...osSpawnVars.args,
+ getMutableConvars(true), //those are the ones that can change without restart
+ txConfig.server.startupArgs,
+ '+set', 'onesync', txConfig.server.onesync,
+ '+sets', 'txAdmin-version', txEnv.txaVersion,
+ '+setr', 'txAdmin-menuEnabled', txConfig.gameFeatures.menuEnabled,
+ '+set', 'txAdmin-luaComHost', txCoreEndpoint,
+ '+set', 'txAdmin-luaComToken', txCore.webServer.luaComToken,
+ '+set', 'txAdminServerMode', 'true', //Can't change this one due to fxserver code compatibility
+ '+exec', txConfig.server.cfgPath,
+ ].flat(2).map(String);
+
+ return {
+ bin: osSpawnVars.bin,
+ args: cmdArgs,
+ serverName: txConfig.general.serverName,
+ dataPath: txConfig.server.dataPath,
+ cfgPath: txConfig.server.cfgPath,
+ }
+}
+
+type FxSpawnVariables = OsSpawnVars & {
+ dataPath: string;
+ cfgPath: string;
+ serverName: string;
+}
+
+
+/**
+ * Print debug information about the spawn variables
+ */
+export const debugPrintSpawnVars = (fxSpawnVars: FxSpawnVariables) => {
+ if (!console.verbose) return; //can't console.verbose.table
+
+ console.debug('Spawn Bin:', fxSpawnVars.bin);
+ const args = redactStartupSecrets(fxSpawnVars.args)
+ console.debug('Spawn Args:');
+ const argsTable = [];
+ let currArgs: string[] | undefined;
+ for (const arg of args) {
+ if (arg.startsWith('+')) {
+ if (currArgs) argsTable.push(currArgs);
+ currArgs = [arg];
+ } else {
+ if (!currArgs) currArgs = [];
+ currArgs.push(arg);
+ }
+ }
+ if (currArgs) argsTable.push(currArgs);
+ console.table(argsTable);
+}
+
+
+/**
+ * Type guard for a valid child process
+ */
+export const isValidChildProcess = (p: any): p is ValidChildProcess => {
+ if (!p) return false;
+ if (typeof p.pid !== 'number') return false;
+ if (!Array.isArray(p.stdio)) return false;
+ if (p.stdio.length < 4) return false;
+ if (!(p.stdio[3] instanceof Readable)) return false;
+ return true;
+};
+export type ValidChildProcess = ChildProcessWithoutNullStreams & {
+ pid: number;
+ readonly stdio: [
+ Writable,
+ Readable,
+ Readable,
+ Readable,
+ Readable | Writable | null | undefined, // extra
+ ];
+};
+
+
+/**
+ * Sanitizes an argument for console input.
+ */
+export const sanitizeConsoleArgString = (arg: string) => {
+ if (typeof arg !== 'string') throw new Error('unexpected type');
+ return arg.replaceAll(/(? {
+ const cleanArgs: string[] = [];
+ for (const arg of args) {
+ if (typeof arg === 'string') {
+ cleanArgs.push(sanitizeConsoleArgString(JSON.stringify(arg)));
+ } else if (typeof arg === 'number') {
+ cleanArgs.push(sanitizeConsoleArgString(JSON.stringify(arg.toString())));
+ } else if (typeof arg === 'object' && arg !== null) {
+ const json = JSON.stringify(arg);
+ const escaped = json.replaceAll(/"/g, '\\"');
+ cleanArgs.push(`"${sanitizeConsoleArgString(escaped)}"`);
+ } else {
+ throw new Error('arg expected to be string or object');
+ }
+ }
+
+ return cleanArgs.join(' ');
+}
+
+
+/**
+ * Copies the custom locale file from txData to the 'monitor' path, due to sandboxing.
+ * FIXME: move to core/lib/fxserver/runtimeFiles.ts
+ */
+export const setupCustomLocaleFile = async () => {
+ if (txConfig.general.language !== 'custom') return;
+ const srcPath = txCore.translator.customLocalePath;
+ const destRuntimePath = path.resolve(txEnv.txaPath, '.runtime');
+ const destFilePath = path.resolve(destRuntimePath, 'locale.json');
+ try {
+ await fsp.mkdir(destRuntimePath, { recursive: true });
+ await fsp.copyFile(srcPath, destFilePath);
+ } catch (error) {
+ console.tag('FXRunner').error(`Failed to copy custom locale file: ${(error as any).message}`);
+ }
+}
diff --git a/core/modules/FxScheduler.ts b/core/modules/FxScheduler.ts
new file mode 100644
index 0000000..45a9395
--- /dev/null
+++ b/core/modules/FxScheduler.ts
@@ -0,0 +1,319 @@
+const modulename = 'FxScheduler';
+import { parseSchedule } from '@lib/misc';
+import consoleFactory from '@lib/console';
+import { SYM_SYSTEM_AUTHOR } from '@lib/symbols';
+import type { UpdateConfigKeySet } from './ConfigStore/utils';
+const console = consoleFactory(modulename);
+
+
+//Types
+type RestartInfo = {
+ string: string;
+ minuteFloorTs: number;
+}
+type ParsedTime = {
+ string: string;
+ hours: number;
+ minutes: number;
+}
+
+
+//Consts
+const scheduleWarnings = [30, 15, 10, 5, 4, 3, 2, 1];
+
+
+/**
+ * Processes an array of HH:MM, gets the next timestamp (sorted by closest).
+ * When time matches, it will be dist: 0, distMins: 0, and nextTs likely in the
+ * past due to seconds and milliseconds being 0.
+ */
+const getNextScheduled = (parsedSchedule: ParsedTime[]): RestartInfo => {
+ const thisMinuteTs = new Date().setSeconds(0, 0);
+ const processed = parsedSchedule.map((t) => {
+ const nextDate = new Date();
+ let minuteFloorTs = nextDate.setHours(t.hours, t.minutes, 0, 0);
+ if (minuteFloorTs < thisMinuteTs) {
+ minuteFloorTs = nextDate.setHours(t.hours + 24, t.minutes, 0, 0);
+ }
+ return {
+ string: t.string,
+ minuteFloorTs,
+ };
+ });
+ return processed.sort((a, b) => a.minuteFloorTs - b.minuteFloorTs)[0];
+};
+
+
+/**
+ * Module responsible for restarting the FXServer on a schedule defined in the config,
+ * or a temporary schedule set by the user at runtime.
+ */
+export default class FxScheduler {
+ static readonly configKeysWatched = ['restarter.schedule'];
+ private nextTempSchedule: RestartInfo | false = false;
+ private calculatedNextRestartMinuteFloorTs: number | false = false;
+ private nextSkip: number | false = false;
+
+ constructor() {
+ //Initial check to update status
+ setImmediate(() => {
+ this.checkSchedule();
+ });
+
+ //Cron Function
+ setInterval(() => {
+ this.checkSchedule();
+ txCore.webServer.webSocket.pushRefresh('status');
+ }, 60 * 1000);
+ }
+
+
+ /**
+ * Refresh configs, resets skip and temp scheduled, runs checkSchedule.
+ */
+ handleConfigUpdate(updatedConfigs: UpdateConfigKeySet) {
+ this.nextSkip = false;
+ this.nextTempSchedule = false;
+ this.checkSchedule();
+ txCore.webServer.webSocket.pushRefresh('status');
+ }
+
+
+ /**
+ * Updates state when server closes.
+ * Clear temp skips and skips next scheduled if it's in less than 2 hours.
+ */
+ handleServerClose() {
+ //Clear temp schedule, recalculates next restart
+ if (this.nextTempSchedule) this.nextTempSchedule = false;
+ this.checkSchedule(true);
+
+ //Check if next scheduled restart is in less than 2 hours
+ const inTwoHours = Date.now() + 2 * 60 * 60 * 1000;
+ if (
+ this.calculatedNextRestartMinuteFloorTs
+ && this.calculatedNextRestartMinuteFloorTs < inTwoHours
+ && this.nextSkip !== this.calculatedNextRestartMinuteFloorTs
+ ) {
+ console.warn('Server closed, skipping next scheduled restart because it\'s in less than 2 hours.');
+ this.nextSkip = this.calculatedNextRestartMinuteFloorTs;
+ }
+ this.checkSchedule(true);
+
+ //Push UI update
+ txCore.webServer.webSocket.pushRefresh('status');
+ }
+
+
+ /**
+ * Returns the current status of scheduler
+ * NOTE: sending relative because server might have clock skew
+ */
+ getStatus() {
+ if (this.calculatedNextRestartMinuteFloorTs) {
+ const thisMinuteTs = new Date().setSeconds(0, 0);
+ return {
+ nextRelativeMs: this.calculatedNextRestartMinuteFloorTs - thisMinuteTs,
+ nextSkip: this.nextSkip === this.calculatedNextRestartMinuteFloorTs,
+ nextIsTemp: !!this.nextTempSchedule,
+ };
+ } else {
+ return {
+ nextRelativeMs: false,
+ nextSkip: false,
+ nextIsTemp: false,
+ } as const;
+ }
+ }
+
+
+ /**
+ * Sets this.nextSkip.
+ * Cancel scheduled button -> setNextSkip(true)
+ * Enable scheduled button -> setNextSkip(false)
+ */
+ setNextSkip(enabled: boolean, author?: string) {
+ if (enabled) {
+ let prevMinuteFloorTs, temporary;
+ if (this.nextTempSchedule) {
+ prevMinuteFloorTs = this.nextTempSchedule.minuteFloorTs;
+ temporary = true;
+ this.nextTempSchedule = false;
+ } else if (this.calculatedNextRestartMinuteFloorTs) {
+ prevMinuteFloorTs = this.calculatedNextRestartMinuteFloorTs;
+ temporary = false;
+ this.nextSkip = this.calculatedNextRestartMinuteFloorTs;
+ }
+
+ if (prevMinuteFloorTs) {
+ //Dispatch `txAdmin:events:scheduledRestartSkipped`
+ txCore.fxRunner.sendEvent('scheduledRestartSkipped', {
+ secondsRemaining: Math.floor((prevMinuteFloorTs - Date.now()) / 1000),
+ temporary,
+ author,
+ });
+
+ //FIXME: deprecate
+ txCore.fxRunner.sendEvent('skippedNextScheduledRestart', {
+ secondsRemaining: Math.floor((prevMinuteFloorTs - Date.now()) / 1000),
+ temporary
+ });
+ }
+ } else {
+ this.nextSkip = false;
+ }
+
+ //This is needed to refresh this.calculatedNextRestartMinuteFloorTs
+ this.checkSchedule();
+
+ //Refresh UI
+ txCore.webServer.webSocket.pushRefresh('status');
+ }
+
+
+ /**
+ * Sets this.nextTempSchedule.
+ * The value MUST be before the next setting scheduled time.
+ */
+ setNextTempSchedule(timeString: string) {
+ //Process input
+ if (typeof timeString !== 'string') throw new Error('expected string');
+ const thisMinuteTs = new Date().setSeconds(0, 0);
+ let scheduledString, scheduledMinuteFloorTs;
+
+ if (timeString.startsWith('+')) {
+ const minutes = parseInt(timeString.slice(1));
+ if (isNaN(minutes) || minutes < 1 || minutes >= 1440) {
+ throw new Error('invalid minutes');
+ }
+ const nextDate = new Date(thisMinuteTs + (minutes * 60 * 1000));
+ scheduledMinuteFloorTs = nextDate.getTime();
+ scheduledString = nextDate.getHours().toString().padStart(2, '0') + ':' + nextDate.getMinutes().toString().padStart(2, '0');
+ } else {
+ const [hours, minutes] = timeString.split(':', 2).map((x) => parseInt(x));
+ if (typeof hours === 'undefined' || isNaN(hours) || hours < 0 || hours > 23) throw new Error('invalid hours');
+ if (typeof minutes === 'undefined' || isNaN(minutes) || minutes < 0 || minutes > 59) throw new Error('invalid minutes');
+
+ const nextDate = new Date();
+ scheduledMinuteFloorTs = nextDate.setHours(hours, minutes, 0, 0);
+ if (scheduledMinuteFloorTs === thisMinuteTs) {
+ throw new Error('Due to the 1 minute precision of the restart scheduler, you cannot schedule a restart in the same minute.');
+ }
+ if (scheduledMinuteFloorTs < thisMinuteTs) {
+ scheduledMinuteFloorTs = nextDate.setHours(hours + 24, minutes, 0, 0);
+ }
+ scheduledString = hours.toString().padStart(2, '0') + ':' + minutes.toString().padStart(2, '0');
+ }
+
+ //Check validity
+ if (Array.isArray(txConfig.restarter.schedule) && txConfig.restarter.schedule.length) {
+ const { valid } = parseSchedule(txConfig.restarter.schedule);
+ const nextSettingRestart = getNextScheduled(valid);
+ if (nextSettingRestart.minuteFloorTs < scheduledMinuteFloorTs) {
+ throw new Error(`You already have one restart scheduled for ${nextSettingRestart.string}, which is before the time you specified.`);
+ }
+ }
+
+ // Set next temp schedule
+ this.nextTempSchedule = {
+ string: scheduledString,
+ minuteFloorTs: scheduledMinuteFloorTs,
+ };
+
+ //This is needed to refresh this.calculatedNextRestartMinuteFloorTs
+ this.checkSchedule();
+
+ //Refresh UI
+ txCore.webServer.webSocket.pushRefresh('status');
+ }
+
+
+ /**
+ * Checks the schedule to see if it's time to announce or restart the server
+ */
+ async checkSchedule(calculateOnly = false) {
+ //Check settings and temp scheduled restart
+ let nextRestart: RestartInfo;
+ if (this.nextTempSchedule) {
+ nextRestart = this.nextTempSchedule;
+ } else if (Array.isArray(txConfig.restarter.schedule) && txConfig.restarter.schedule.length) {
+ const { valid } = parseSchedule(txConfig.restarter.schedule);
+ nextRestart = getNextScheduled(valid);
+ } else {
+ //nothing scheduled
+ this.calculatedNextRestartMinuteFloorTs = false;
+ return;
+ }
+ this.calculatedNextRestartMinuteFloorTs = nextRestart.minuteFloorTs;
+ if (calculateOnly) return;
+
+ //Checking if skipped
+ if (this.nextSkip === this.calculatedNextRestartMinuteFloorTs) {
+ console.verbose.log(`Skipping next scheduled restart`);
+ return;
+ }
+
+ //Calculating dist
+ const thisMinuteTs = new Date().setSeconds(0, 0);
+ const nextDistMs = nextRestart.minuteFloorTs - thisMinuteTs;
+ const nextDistMins = Math.floor(nextDistMs / 60_000);
+
+ //Checking if server restart or warning time
+ if (nextDistMins === 0) {
+ //restart server
+ this.triggerServerRestart(
+ `scheduled restart at ${nextRestart.string}`,
+ txCore.translator.t('restarter.schedule_reason', { time: nextRestart.string }),
+ );
+
+ //Check if server is in boot cooldown
+ const processUptime = Math.floor((txCore.fxRunner.child?.uptime ?? 0) / 1000);
+ if (processUptime < txConfig.restarter.bootGracePeriod) {
+ console.verbose.log(`Server is in boot cooldown, skipping scheduled restart.`);
+ return;
+ }
+
+ //reset next scheduled
+ this.nextTempSchedule = false;
+
+ } else if (scheduleWarnings.includes(nextDistMins)) {
+ const tOptions = {
+ smart_count: nextDistMins,
+ servername: txConfig.general.serverName,
+ };
+
+ //Send discord warning
+ txCore.discordBot.sendAnnouncement({
+ type: 'warning',
+ description: {
+ key: 'restarter.schedule_warn_discord',
+ data: tOptions
+ }
+ });
+
+ //Dispatch `txAdmin:events:scheduledRestart`
+ txCore.fxRunner.sendEvent('scheduledRestart', {
+ secondsRemaining: nextDistMins * 60,
+ translatedMessage: txCore.translator.t('restarter.schedule_warn', tOptions)
+ });
+ }
+ }
+
+
+ /**
+ * Triggers FXServer restart and logs the reason.
+ */
+ async triggerServerRestart(reasonInternal: string, reasonTranslated: string) {
+ //Sanity check
+ if (txCore.fxRunner.isIdle || !txCore.fxRunner.child?.isAlive) {
+ console.verbose.warn('Server not running, skipping scheduled restart.');
+ return false;
+ }
+
+ //Restart server
+ const logMessage = `Restarting server: ${reasonInternal}`;
+ txCore.logger.admin.write('SCHEDULER', logMessage);
+ txCore.logger.fxserver.logInformational(logMessage); //just for better visibility
+ txCore.fxRunner.restartServer(reasonTranslated, SYM_SYSTEM_AUTHOR);
+ }
+};
diff --git a/core/modules/Logger/FXServerLogger/ConsoleLineEnum.ts b/core/modules/Logger/FXServerLogger/ConsoleLineEnum.ts
new file mode 100644
index 0000000..db03030
--- /dev/null
+++ b/core/modules/Logger/FXServerLogger/ConsoleLineEnum.ts
@@ -0,0 +1,10 @@
+//In a separate file to avoid circular dependencies
+enum ConsoleLineEnum {
+ StdOut,
+ StdErr,
+ MarkerAdminCmd,
+ MarkerSystemCmd,
+ MarkerInfo,
+}
+
+export default ConsoleLineEnum;
diff --git a/core/modules/Logger/FXServerLogger/ConsoleTransformer.ts b/core/modules/Logger/FXServerLogger/ConsoleTransformer.ts
new file mode 100644
index 0000000..188e922
--- /dev/null
+++ b/core/modules/Logger/FXServerLogger/ConsoleTransformer.ts
@@ -0,0 +1,213 @@
+import ConsoleLineEnum from './ConsoleLineEnum';
+import { prefixMultiline, splitFirstLine, stripLastEol } from './fxsLoggerUtils';
+import chalk, { ChalkInstance } from 'chalk';
+
+
+//Types
+export type MultiBuffer = {
+ webBuffer: string;
+ stdoutBuffer: string;
+ fileBuffer: string;
+}
+
+type StylesLibrary = {
+ [key in ConsoleLineEnum]: StyleConfig | null;
+};
+type StyleConfig = {
+ web: StyleChannelConfig;
+ stdout: StyleChannelConfig | false;
+}
+type StyleChannelConfig = {
+ prefix?: ChalkInstance;
+ line?: ChalkInstance;
+}
+
+
+//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 precalcMarkerInfo = chalkToStr(chalk.bgBlueBright.black);
+const ANSI_RESET = '\x1B[0m';
+const ANSI_ERASE_LINE = '\x1b[K';
+
+const STYLES = {
+ [ConsoleLineEnum.StdOut]: null, //fully shortcircuited
+ [ConsoleLineEnum.StdErr]: {
+ web: {
+ prefix: chalk.bgRedBright.bold.black,
+ line: chalk.bold.redBright,
+ },
+ stdout: {
+ prefix: chalk.bgRedBright.bold.black,
+ line: chalk.bold.redBright,
+ },
+ },
+ [ConsoleLineEnum.MarkerAdminCmd]: {
+ web: {
+ prefix: chalk.bold,
+ line: x => `${precalcMarkerAdminCmd}${x}${ANSI_ERASE_LINE}${ANSI_RESET}`,
+ },
+ stdout: false,
+ },
+ [ConsoleLineEnum.MarkerSystemCmd]: {
+ web: {
+ prefix: chalk.bold,
+ line: x => `${precalcMarkerSystemCmd}${x}${ANSI_ERASE_LINE}${ANSI_RESET}`,
+ },
+ stdout: false,
+ },
+ [ConsoleLineEnum.MarkerInfo]: {
+ web: {
+ prefix: chalk.bold,
+ line: x => `${precalcMarkerInfo}${x}${ANSI_ERASE_LINE}${ANSI_RESET}`,
+ },
+ stdout: {
+ prefix: chalk.bgBlueBright.black,
+ line: chalk.bgBlueBright.black,
+ },
+ },
+} as StylesLibrary;
+
+export const FORCED_EOL = '\u21A9\n'; //used in test file only
+
+//NOTE: [jan/2025] Changed from [] to make it easier to find tx stdin in the log files
+const prefixChar = '║' //Alternatives: | & ┇
+const getConsoleLinePrefix = (prefix: string) => prefixChar + prefix.padStart(20, ' ') + prefixChar;
+
+//NOTE: the \n must come _after_ the color so LiveConsolePage.tsx can know when it's an incomplete line
+const colorLines = (str: string, color: ChalkInstance | undefined) => {
+ if (!color) return str;
+ const stripped = stripLastEol(str);
+ return color(stripped.str) + stripped.eol;
+};
+
+
+/**
+ * Handles fxserver stdio and turns it into a cohesive textual output
+ */
+export default class ConsoleTransformer {
+ public lastEol = true;
+ private lastSrc = '0:undefined';
+ private lastMarkerTs = 0; //in seconds
+ private STYLES = STYLES;
+ private PREFIX_SYSTEM = getConsoleLinePrefix('TXADMIN');
+ private PREFIX_STDERR = getConsoleLinePrefix('STDERR');
+
+ public process(type: ConsoleLineEnum, data: string, context?: string): MultiBuffer {
+ //Shortcircuiting for empty strings
+ if (!data.length) return { webBuffer: '', stdoutBuffer: '', fileBuffer: '' };
+ const src = `${type}:${context}`;
+ if (data === '\n' || data === '\r\n') {
+ this.lastEol = true;
+ this.lastSrc = src;
+ return { webBuffer: '\n', stdoutBuffer: '\n', fileBuffer: '\n' };
+ }
+ let webBuffer = '';
+ let stdoutBuffer = '';
+ let fileBuffer = '';
+
+ //incomplete
+ if (!this.lastEol) {
+ //diff source
+ if (src !== this.lastSrc) {
+ webBuffer += FORCED_EOL;
+ stdoutBuffer += FORCED_EOL;
+ fileBuffer += FORCED_EOL;
+ const prefixed = this.prefixChunk(type, data, context);
+ webBuffer += this.getTimeMarker() + prefixed.webBuffer;
+ stdoutBuffer += prefixed.stdoutBuffer;
+ fileBuffer += prefixed.fileBuffer;
+ this.lastEol = data[data.length - 1] === '\n';
+ this.lastSrc = src;
+ return { webBuffer, stdoutBuffer, fileBuffer };
+ }
+ //same source
+ const parts = splitFirstLine(data);
+ fileBuffer += parts.first;
+ const style = this.STYLES[type];
+ if (style === null) {
+ webBuffer += parts.first;
+ stdoutBuffer += parts.first;
+ } else {
+ webBuffer += colorLines(
+ parts.first,
+ style.web.line,
+ );
+ stdoutBuffer += style.stdout ? colorLines(
+ parts.first,
+ style.stdout.line,
+ ) : '';
+ }
+ if (parts.rest) {
+ const prefixed = this.prefixChunk(type, parts.rest, context);
+ webBuffer += this.getTimeMarker() + prefixed.webBuffer;
+ stdoutBuffer += prefixed.stdoutBuffer;
+ fileBuffer += prefixed.fileBuffer;
+ }
+ this.lastEol = parts.eol;
+ return { webBuffer, stdoutBuffer, fileBuffer };
+ }
+
+ //complete
+ const prefixed = this.prefixChunk(type, data, context);
+ webBuffer += this.getTimeMarker() + prefixed.webBuffer;
+ stdoutBuffer += prefixed.stdoutBuffer;
+ fileBuffer += prefixed.fileBuffer;
+ this.lastEol = data[data.length - 1] === '\n';
+ this.lastSrc = src;
+
+ return { webBuffer, stdoutBuffer, fileBuffer };
+ }
+
+ private getTimeMarker() {
+ const currMarkerTs = Math.floor(Date.now() / 1000);
+ if (currMarkerTs !== this.lastMarkerTs) {
+ this.lastMarkerTs = currMarkerTs;
+ return `{§${currMarkerTs.toString(16)}}`
+ }
+ return '';
+ }
+
+ private prefixChunk(type: ConsoleLineEnum, chunk: string, context?: string): MultiBuffer {
+ //NOTE: as long as stdout is shortcircuited, the other ones don't need to be micro-optimized
+ const style = this.STYLES[type];
+ if (style === null) {
+ return {
+ webBuffer: chunk,
+ stdoutBuffer: chunk,
+ fileBuffer: chunk,
+ };
+ }
+
+ //selecting prefix and color
+ let prefix = '';
+ if (type === ConsoleLineEnum.StdErr) {
+ prefix = this.PREFIX_STDERR;
+ } else if (type === ConsoleLineEnum.MarkerAdminCmd) {
+ prefix = getConsoleLinePrefix(context ?? '?');
+ } else if (type === ConsoleLineEnum.MarkerSystemCmd) {
+ prefix = this.PREFIX_SYSTEM;
+ } else if (type === ConsoleLineEnum.MarkerInfo) {
+ prefix = this.PREFIX_SYSTEM;
+ }
+
+ const webPrefix = style.web.prefix ? style.web.prefix(prefix) : prefix;
+ const webBuffer = colorLines(
+ prefixMultiline(chunk, webPrefix + ' '),
+ style.web.line,
+ );
+
+ let stdoutBuffer = '';
+ if (style.stdout) {
+ const stdoutPrefix = style.stdout.prefix ? style.stdout.prefix(prefix) : prefix;
+ stdoutBuffer = colorLines(
+ prefixMultiline(chunk, stdoutPrefix + ' '),
+ style.stdout.line,
+ );
+ }
+ const fileBuffer = prefixMultiline(chunk, prefix + ' ');
+
+ return { webBuffer, stdoutBuffer, fileBuffer };
+ }
+};
diff --git a/core/modules/Logger/FXServerLogger/fxsLogger.test.ts b/core/modules/Logger/FXServerLogger/fxsLogger.test.ts
new file mode 100644
index 0000000..0bf985f
--- /dev/null
+++ b/core/modules/Logger/FXServerLogger/fxsLogger.test.ts
@@ -0,0 +1,290 @@
+//@ts-nocheck
+import { test, expect, suite, it, vitest, vi } from 'vitest';
+import { prefixMultiline, splitFirstLine, stripLastEol } from './fxsLoggerUtils';
+import ConsoleTransformer, { FORCED_EOL } from './ConsoleTransformer';
+import ConsoleLineEnum from './ConsoleLineEnum';
+
+
+//MARK: splitFirstLine
+suite('splitFirstLine', () => {
+ it('normal single full line', () => {
+ const result = splitFirstLine('Hello\n');
+ expect(result).toEqual({ first: 'Hello\n', rest: undefined, eol: true });
+ });
+ it('should split a string with a newline into two parts', () => {
+ const result = splitFirstLine('Hello\nWorld');
+ expect(result).toEqual({ first: 'Hello\n', rest: 'World', eol: false });
+ });
+ it('should return the whole string as the first part if there is no newline', () => {
+ const result = splitFirstLine('HelloWorld');
+ expect(result).toEqual({ first: 'HelloWorld', rest: undefined, eol: false });
+ });
+ it('should handle an empty string', () => {
+ const result = splitFirstLine('');
+ expect(result).toEqual({ first: '', rest: undefined, eol: false });
+ });
+ it('should handle multiple newlines correctly', () => {
+ const result = splitFirstLine('Hello\nWorld\nAgain');
+ expect(result).toEqual({ first: 'Hello\n', rest: 'World\nAgain', eol: false });
+ });
+});
+
+
+//MARK: stripLastEol
+suite('stripLastEol', () => {
+ it('should strip \\r\\n from the end of the string', () => {
+ const result = stripLastEol('Hello World\r\n');
+ expect(result).toEqual({ str: 'Hello World', eol: '\r\n' });
+ });
+
+ it('should strip \\n from the end of the string', () => {
+ const result = stripLastEol('Hello World\n');
+ expect(result).toEqual({ str: 'Hello World', eol: '\n' });
+ });
+
+ it('should return the same string if there is no EOL character', () => {
+ const result = stripLastEol('Hello World');
+ expect(result).toEqual({ str: 'Hello World', eol: '' });
+ });
+
+ it('should return the same string if it ends with other characters', () => {
+ const result = stripLastEol('Hello World!');
+ expect(result).toEqual({ str: 'Hello World!', eol: '' });
+ });
+});
+
+
+//MARK: prefixMultiline
+suite('prefixMultiline', () => {
+ it('should prefix every line in a multi-line string', () => {
+ const result = prefixMultiline('Hello\nWorld\nAgain', '!!');
+ expect(result).toBe('!!Hello\n!!World\n!!Again');
+ });
+ it('should prefix a single-line string', () => {
+ const result = prefixMultiline('HelloWorld', '!!');
+ expect(result).toBe('!!HelloWorld');
+ });
+ it('should handle an empty string', () => {
+ const result = prefixMultiline('', '!!');
+ expect(result).toBe('');
+ });
+ it('should handle a string with a newline at the end', () => {
+ const result = prefixMultiline('Hello\n', '!!');
+ expect(result).toBe('!!Hello\n');
+ });
+ it('should handle a string with multiple newlines at the end', () => {
+ const result = prefixMultiline('Hello\nWorld\n\n', '!!');
+ expect(result).toBe('!!Hello\n!!World\n!!\n');
+ });
+ it('should handle a string with newlines only', () => {
+ const result = prefixMultiline('\n\n\n', '!!');
+ expect(result).toBe('!!\n!!\n!!\n');
+ });
+ it('should handle a string with two full lines', () => {
+ const result = prefixMultiline('test\nabcde\n', '!!');
+ expect(result).toBe('!!test\n!!abcde\n');
+ });
+});
+
+
+
+//MARK: Transformer parts
+suite('transformer: prefixChunk', () => {
+ const transformer = new ConsoleTransformer();
+ test('shortcut stdout string', () => {
+ const result = transformer.prefixChunk(ConsoleLineEnum.StdOut, 'xxxx\nxxx\n');
+ expect(result.fileBuffer).toEqual('xxxx\nxxx\n');
+ });
+ test('empty string', () => {
+ const result = transformer.prefixChunk(ConsoleLineEnum.StdOut, '');
+ expect(result.fileBuffer).toEqual('');
+ });
+});
+
+
+suite('transformer: marker', () => {
+ test('0ms delay', () => {
+ const transformer = new ConsoleTransformer();
+ vi.spyOn(Date, 'now').mockReturnValue(0);
+ const res = transformer.getTimeMarker();
+ expect(res).toBe('');
+ expect(transformer.lastMarkerTs).toEqual(0);
+ vi.restoreAllMocks();
+ });
+ test('250ms delay', () => {
+ const transformer = new ConsoleTransformer();
+ vi.spyOn(Date, 'now').mockReturnValue(250);
+ const res = transformer.getTimeMarker();
+ expect(res).toBe('');
+ expect(transformer.lastMarkerTs).toEqual(0);
+ vi.restoreAllMocks();
+ });
+ test('1250ms delay', () => {
+ const transformer = new ConsoleTransformer();
+ vi.spyOn(Date, 'now').mockReturnValue(1250);
+ const res = transformer.getTimeMarker();
+ expect(res).toBeTruthy();
+ expect(transformer.lastMarkerTs).toEqual(1);
+ vi.restoreAllMocks();
+ });
+ test('2250ms delay', () => {
+ const transformer = new ConsoleTransformer();
+ vi.spyOn(Date, 'now').mockReturnValue(2250);
+ const res = transformer.getTimeMarker();
+ expect(res).toBeTruthy();
+ expect(transformer.lastMarkerTs).toEqual(2);
+ vi.restoreAllMocks();
+ });
+});
+
+//MARK: Transformer ingest
+const jp = (arr: string[]) => arr.join('');
+const getPatchedTransformer = () => {
+ const t = new ConsoleTransformer();
+ t.STYLES = {
+ [ConsoleLineEnum.StdOut]: null,
+ [ConsoleLineEnum.StdErr]: { web: {} },
+ [ConsoleLineEnum.MarkerAdminCmd]: { web: {} },
+ [ConsoleLineEnum.MarkerSystemCmd]: { web: {} },
+ [ConsoleLineEnum.MarkerInfo]: { web: {} },
+ };
+ t.PREFIX_SYSTEM = '-';
+ t.PREFIX_STDERR = '-';
+ return t;
+}
+suite('transformer: source', () => {
+ suite('incomplete', () => {
+ test('StdOut', () => {
+ const transformer = getPatchedTransformer();
+ transformer.lastEol = false;
+ transformer.process(ConsoleLineEnum.StdOut, 'x');
+ expect(transformer.lastSrc).toEqual('0:undefined');
+ });
+ test('StdErr', () => {
+ const transformer = getPatchedTransformer();
+ transformer.lastEol = false;
+ transformer.process(ConsoleLineEnum.StdErr, 'x');
+ expect(transformer.lastSrc).toEqual('1:undefined');
+ });
+ });
+ suite('no context', () => {
+ test('StdOut', () => {
+ const transformer = getPatchedTransformer();
+ transformer.process(ConsoleLineEnum.StdOut, 'x');
+ expect(transformer.lastSrc).toEqual('0:undefined');
+ });
+ test('StdErr', () => {
+ const transformer = getPatchedTransformer();
+ transformer.process(ConsoleLineEnum.StdErr, 'x');
+ expect(transformer.lastSrc).toEqual('1:undefined');
+ });
+ });
+ suite('context', () => {
+ test('StdOut', () => {
+ const transformer = getPatchedTransformer();
+ transformer.process(ConsoleLineEnum.StdOut, 'x', 'y');
+ expect(transformer.lastSrc).toEqual('0:y');
+ });
+ test('StdErr', () => {
+ const transformer = getPatchedTransformer();
+ transformer.process(ConsoleLineEnum.StdErr, 'x', 'y');
+ expect(transformer.lastSrc).toEqual('1:y');
+ });
+ });
+});
+suite('transformer: shortcuts', () => {
+ test('empty string', () => {
+ const transformer = getPatchedTransformer();
+ const result = transformer.process(ConsoleLineEnum.StdOut, '');
+ expect(result.webBuffer).toEqual('');
+ });
+ test('\\n', () => {
+ const transformer = getPatchedTransformer();
+ const result = transformer.process(ConsoleLineEnum.StdErr, '\n');
+ expect(result.webBuffer).toEqual('\n');
+ });
+ test('\\r\\n', () => {
+ const transformer = getPatchedTransformer();
+ const result = transformer.process(ConsoleLineEnum.StdErr, '\r\n');
+ expect(result.webBuffer).toEqual('\n');
+ });
+});
+
+suite('transformer: new line', () => {
+ const expectedTimeMarker = `{§${Math.floor(Date.now() / 1000).toString(16)}}`;
+
+ test('single line same src', () => {
+ const transformer = getPatchedTransformer();
+ const result = transformer.process(ConsoleLineEnum.StdOut, 'test');
+ expect(result.webBuffer).toEqual(jp([expectedTimeMarker, 'test']));
+ expect(transformer.lastEol).toEqual(false);
+ });
+ test('single line diff src', () => {
+ const transformer = getPatchedTransformer();
+ const result = transformer.process(ConsoleLineEnum.StdErr, 'test');
+ expect(result.webBuffer).toEqual(jp([expectedTimeMarker, '- ', 'test']));
+ expect(transformer.lastEol).toEqual(false);
+ });
+ test('multi line same src', () => {
+ const transformer = getPatchedTransformer();
+ const result = transformer.process(ConsoleLineEnum.StdOut, 'test\ntest2');
+ expect(result.webBuffer).toEqual(jp([expectedTimeMarker, 'test\ntest2']));
+ expect(transformer.lastEol).toEqual(false);
+ });
+ test('multi line diff src', () => {
+ const transformer = getPatchedTransformer();
+ const result = transformer.process(ConsoleLineEnum.StdErr, 'test\ntest2');
+ expect(result.webBuffer).toEqual(jp([expectedTimeMarker, '- ', 'test\n', '- ', 'test2']));
+ expect(transformer.lastEol).toEqual(false);
+ });
+});
+
+
+suite('transformer: postfix', () => {
+ const expectedTimeMarker = `{§${Math.floor(Date.now() / 1000).toString(16)}}`;
+
+ test('same source incomplete line', () => {
+ const transformer = getPatchedTransformer();
+ transformer.lastEol = false;
+ const result = transformer.process(ConsoleLineEnum.StdOut, 'test');
+ expect(result.webBuffer).toEqual(jp(['test']));
+ expect(transformer.lastEol).toEqual(false);
+ });
+ test('same source complete line', () => {
+ const transformer = getPatchedTransformer();
+ transformer.lastEol = false;
+ const result = transformer.process(ConsoleLineEnum.StdOut, 'test\n');
+ expect(result.webBuffer).toEqual(jp(['test\n']));
+ expect(transformer.lastEol).toEqual(true);
+ });
+ test('same source multi line', () => {
+ const transformer = getPatchedTransformer();
+ transformer.lastEol = false;
+ const result = transformer.process(ConsoleLineEnum.StdOut, 'test\nxx\n');
+ // console.dir(result); return;
+ expect(result.webBuffer).toEqual(jp(['test\n', expectedTimeMarker, 'xx\n']));
+ expect(transformer.lastEol).toEqual(true);
+ });
+
+ test('diff source incomplete line', () => {
+ const transformer = getPatchedTransformer();
+ transformer.lastEol = false;
+ const result = transformer.process(ConsoleLineEnum.StdErr, 'test');
+ expect(result.webBuffer).toEqual(jp([FORCED_EOL, expectedTimeMarker, '- ', 'test']));
+ expect(transformer.lastEol).toEqual(false);
+ });
+ test('diff source complete line', () => {
+ const transformer = getPatchedTransformer();
+ transformer.lastEol = false;
+ const result = transformer.process(ConsoleLineEnum.StdErr, 'test\n');
+ expect(result.webBuffer).toEqual(jp([FORCED_EOL, expectedTimeMarker, '- ', 'test\n']));
+ expect(transformer.lastEol).toEqual(true);
+ });
+ test('diff source multi line', () => {
+ const transformer = getPatchedTransformer();
+ transformer.lastEol = false;
+ const result = transformer.process(ConsoleLineEnum.StdErr, 'test\nabcde\n');
+ expect(result.webBuffer).toEqual(jp([FORCED_EOL, expectedTimeMarker, '- ', 'test\n', '- ', 'abcde\n']));
+ expect(transformer.lastEol).toEqual(true);
+ });
+});
diff --git a/core/modules/Logger/FXServerLogger/fxsLoggerUtils.ts b/core/modules/Logger/FXServerLogger/fxsLoggerUtils.ts
new file mode 100644
index 0000000..0cbd3cb
--- /dev/null
+++ b/core/modules/Logger/FXServerLogger/fxsLoggerUtils.ts
@@ -0,0 +1,75 @@
+/**
+ * Splits a string into two parts: the first line and the rest of the string.
+ * Returns an object indicating whether an end-of-line (EOL) character was found.
+ * If the string ends with a line break, `rest` is set to `undefined`.
+ * Supports both Unix (`\n`) and Windows (`\r\n`) line breaks.
+ */
+export const splitFirstLine = (str: string): SplitFirstLineResult => {
+ const firstEolIndex = str.indexOf('\n');
+ if (firstEolIndex === -1) {
+ return { first: str, rest: undefined, eol: false };
+ }
+
+ const isEolCrLf = firstEolIndex > 0 && str[firstEolIndex - 1] === '\r';
+ const foundEolLength = isEolCrLf ? 2 : 1;
+ const firstEolAtEnd = firstEolIndex === str.length - foundEolLength;
+ if (firstEolAtEnd) {
+ return { first: str, rest: undefined, eol: true };
+ }
+
+ const first = str.substring(0, firstEolIndex + foundEolLength);
+ const rest = str.substring(firstEolIndex + foundEolLength);
+ const eol = rest[rest.length - 1] === '\n';
+ return { first, rest, eol };
+};
+type SplitFirstLineResult = {
+ first: string;
+ rest: string | undefined;
+ eol: boolean;
+};
+
+
+/**
+ * Strips the last end-of-line (EOL) character from a string.
+ */
+export const stripLastEol = (str: string) => {
+ if (str.endsWith('\r\n')) {
+ return {
+ str: str.slice(0, -2),
+ eol: '\r\n',
+ }
+ } else if (str.endsWith('\n')) {
+ return {
+ str: str.slice(0, -1),
+ eol: '\n',
+ }
+ }
+ return { str, eol: '' };
+}
+
+
+/**
+ * Adds a given prefix to each line in the input string.
+ * Does not add a prefix to the very last empty line, if it exists.
+ * Efficiently handles strings without line breaks by returning the prefixed string.
+ */
+export const prefixMultiline = (str: string, prefix: string): string => {
+ if (str.length === 0 || str === '\n') return '';
+ let newlineIndex = str.indexOf('\n');
+
+ // If there is no newline, append the whole string and return
+ if (newlineIndex === -1 || newlineIndex === str.length - 1) {
+ return prefix + str;
+ }
+
+ let result = prefix; // Start by prefixing the first line
+ let start = 0;
+ while (newlineIndex !== -1 && newlineIndex !== str.length - 1) {
+ result += str.substring(start, newlineIndex + 1) + prefix;
+ start = newlineIndex + 1;
+ newlineIndex = str.indexOf('\n', start);
+ }
+
+ // Append the remaining part of the string after the last newline
+ return result + str.substring(start);
+};
diff --git a/core/modules/Logger/FXServerLogger/index.ts b/core/modules/Logger/FXServerLogger/index.ts
new file mode 100644
index 0000000..eeed9b9
--- /dev/null
+++ b/core/modules/Logger/FXServerLogger/index.ts
@@ -0,0 +1,178 @@
+const modulename = 'Logger:FXServer';
+import bytes from 'bytes';
+import type { Options as RfsOptions } from 'rotating-file-stream';
+import { getLogDivider } from '../loggerUtils.js';
+import consoleFactory, { processStdioWriteRaw } from '@lib/console.js';
+import { LoggerBase } from '../LoggerBase.js';
+import ConsoleTransformer from './ConsoleTransformer.js';
+import ConsoleLineEnum from './ConsoleLineEnum.js';
+import { txHostConfig } from '@core/globalData.js';
+const console = consoleFactory(modulename);
+
+
+//This regex was done in the first place to prevent fxserver output to be interpreted as txAdmin output by the host terminal
+//IIRC the issue was that one user with a TM on their nick was making txAdmin's console to close or freeze. I couldn't reproduce the issue.
+// \x00-\x08 Control characters in the ASCII table.
+// allow \r and \t
+// \x0B-\x1A Vertical tab and control characters from shift out to substitute.
+// allow \x1B (escape for colors n stuff)
+// \x1C-\x1F Control characters (file separator, group separator, record separator, unit separator).
+// allow all printable
+// \x7F Delete character.
+const regexControls = /[\x00-\x08\x0B-\x1A\x1C-\x1F\x7F]|(?:\x1B\[|\x9B)[\d;]+[@-K]/g;
+const regexColors = /\x1B[^m]*?m/g;
+
+
+export default class FXServerLogger extends LoggerBase {
+ private readonly transformer = new ConsoleTransformer();
+ private fileBuffer = '';
+ private recentBuffer = '';
+ private readonly recentBufferMaxSize = 256 * 1024; //kb
+ private readonly recentBufferTrimSliceSize = 32 * 1024; //how much will be cut when overflows
+
+ constructor(basePath: string, lrProfileConfig: RfsOptions | false) {
+ const lrDefaultOptions = {
+ path: basePath,
+ intervalBoundary: true,
+ initialRotation: true,
+ history: 'fxserver.history',
+ // compress: 'gzip',
+ interval: '1d',
+ maxFiles: 7,
+ maxSize: '5G',
+ };
+ super(basePath, 'fxserver', lrDefaultOptions, lrProfileConfig);
+
+ setInterval(() => {
+ this.flushFileBuffer();
+ }, 5000);
+ }
+
+
+ /**
+ * Returns a string with short usage stats
+ */
+ getUsageStats() {
+ return `Buffer: ${bytes(this.recentBuffer.length)}, lrErrors: ${this.lrErrors}`;
+ }
+
+
+ /**
+ * Returns the recent fxserver buffer containing HTML markers, and not XSS escaped.
+ * The size of this buffer is usually above 64kb, never above 128kb.
+ */
+ getRecentBuffer() {
+ return this.recentBuffer;
+ }
+
+
+ /**
+ * Strips color of the file buffer and flushes it.
+ * FIXME: this will still allow colors to be written to the file if the buffer cuts
+ * in the middle of a color sequence, but less often since we are buffering more data.
+ */
+ flushFileBuffer() {
+ this.lrStream.write(this.fileBuffer.replace(regexColors, ''));
+ this.fileBuffer = '';
+ }
+
+
+ /**
+ * Receives the assembled console blocks, stringifies, marks, colors them and dispatches it to
+ * lrStream, websocket, and process stdout.
+ */
+ private ingest(type: ConsoleLineEnum, data: string, context?: string) {
+ //Process the data
+ const { webBuffer, stdoutBuffer, fileBuffer } = this.transformer.process(type, data, context);
+
+ //To file
+ this.fileBuffer += fileBuffer;
+
+ //For the terminal
+ if (!txConfig.server.quiet && !txHostConfig.forceQuietMode) {
+ processStdioWriteRaw(stdoutBuffer);
+ }
+
+ //For the live console
+ txCore.webServer.webSocket.buffer('liveconsole', webBuffer);
+ this.appendRecent(webBuffer);
+ }
+
+
+ /**
+ * Writes to the log an informational message
+ */
+ public logInformational(msg: string) {
+ this.ingest(ConsoleLineEnum.MarkerInfo, msg + '\n');
+ }
+
+
+ /**
+ * Writes to the log that the server is booting
+ */
+ public logFxserverSpawn(pid: string) {
+ //force line skip to create separation
+ if (this.recentBuffer.length) {
+ const lineBreak = this.transformer.lastEol ? '\n' : '\n\n';
+ this.ingest(ConsoleLineEnum.MarkerInfo, lineBreak);
+ }
+ //need to break line
+ const multiline = getLogDivider(`[${pid}] FXServer Starting`);
+ for (const line of multiline.split('\n')) {
+ if (!line.length) break;
+ this.ingest(ConsoleLineEnum.MarkerInfo, line + '\n');
+ }
+ }
+
+
+ /**
+ * Writes to the log an admin command
+ */
+ public logAdminCommand(author: string, cmd: string) {
+ this.ingest(ConsoleLineEnum.MarkerAdminCmd, cmd + '\n', author);
+ }
+
+
+ /**
+ * Writes to the log a system command.
+ */
+ public logSystemCommand(cmd: string) {
+ if(cmd.startsWith('txaEvent "consoleCommand"')) return;
+ // if (/^txaEvent \w+ /.test(cmd)) {
+ // const [event, payload] = cmd.substring(9).split(' ', 2);
+ // cmd = chalk.italic(``);
+ // }
+ this.ingest(ConsoleLineEnum.MarkerSystemCmd, cmd + '\n');
+ }
+
+
+ /**
+ * Handles all stdio data.
+ */
+ public writeFxsOutput(
+ source: ConsoleLineEnum.StdOut | ConsoleLineEnum.StdErr,
+ data: string | Buffer
+ ) {
+ if (typeof data !== 'string') {
+ data = data.toString();
+ }
+ this.ingest(source, data.replace(regexControls, ''));
+ }
+
+
+ /**
+ * Appends data to the recent buffer and recycles it when necessary
+ */
+ private appendRecent(data: string) {
+ this.recentBuffer += data;
+ if (this.recentBuffer.length > this.recentBufferMaxSize) {
+ this.recentBuffer = this.recentBuffer.slice(this.recentBufferTrimSliceSize - this.recentBufferMaxSize);
+ this.recentBuffer = this.recentBuffer.substring(this.recentBuffer.indexOf('\n'));
+ //FIXME: precisa encontrar o próximo tsMarker ao invés de \n
+ //usar String.prototype.search() com regex
+
+ //FIXME: salvar em 8 blocos de 32kb
+ // quando atingir 32, quebrar no primeiro tsMarker
+ }
+ }
+};
diff --git a/core/modules/Logger/LoggerBase.ts b/core/modules/Logger/LoggerBase.ts
new file mode 100644
index 0000000..b70f100
--- /dev/null
+++ b/core/modules/Logger/LoggerBase.ts
@@ -0,0 +1,79 @@
+const modulename = 'Logger:Base';
+import fs from 'node:fs';
+import path from 'node:path';
+import * as rfs from 'rotating-file-stream';
+import { cloneDeep, defaultsDeep } from 'lodash-es';
+import consoleFactory from '@lib/console';
+import { getLogSizes, getLogDivider } from './loggerUtils';
+import { getTimeFilename } from '@lib/misc';
+const console = consoleFactory(modulename);
+
+
+/**
+ * Default class for logger instances.
+ * Implements log-rotate, listLogFiles() and getLogFile()
+ */
+export class LoggerBase {
+ lrStream: rfs.RotatingFileStream;
+ lrErrors = 0;
+ public activeFilePath: string;
+ private lrLastError: string | undefined;
+ private basePath: string;
+ private logNameRegex: RegExp;
+
+ constructor(
+ basePath: string,
+ logName: string,
+ lrDefaultOptions: rfs.Options,
+ lrProfileConfig: rfs.Options | false = false
+ ) {
+ //Sanity check
+ if (!basePath || !logName) throw new Error('Missing constructor parameters');
+ this.basePath = basePath;
+ this.activeFilePath = path.join(basePath, `${logName}.log`);
+ this.logNameRegex = new RegExp(`^${logName}(_\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}(_\\d+)?)?.log$`);
+
+ //If disabled
+ if (lrProfileConfig === false) {
+ console.warn('persistent logging disabled for:', logName);
+ this.lrStream = {
+ write: () => { },
+ } as any as rfs.RotatingFileStream;
+ return;
+ }
+
+ //Setting log rotate up
+ const lrOptions = cloneDeep(lrProfileConfig);
+ if (typeof lrProfileConfig === 'object') {
+ defaultsDeep(lrOptions, lrDefaultOptions);
+ }
+
+ const filenameGenerator: rfs.Generator = (time, index) => {
+ return time
+ ? `${logName}_${getTimeFilename(time)}_${index}.log`
+ : `${logName}.log`;
+ };
+
+ this.lrStream = rfs.createStream(filenameGenerator, lrOptions);
+ this.lrStream.on('rotated', (filename) => {
+ this.lrStream.write(getLogDivider('Log Rotated'));
+ console.verbose.log(`Rotated file ${filename}`);
+ });
+ this.lrStream.on('error', (error) => {
+ if ((error as any).code !== 'ERR_STREAM_DESTROYED') {
+ console.verbose.error(error, logName);
+ this.lrErrors++;
+ this.lrLastError = error.message;
+ }
+ });
+ }
+
+ listLogFiles() {
+ return getLogSizes(this.basePath, this.logNameRegex);
+ }
+
+ getLogFile(fileName: string) {
+ if (!this.logNameRegex.test(fileName)) throw new Error('fileName doesn\'t match regex');
+ return fs.createReadStream(path.join(this.basePath, fileName));
+ }
+};
diff --git a/core/modules/Logger/handlers/admin.js b/core/modules/Logger/handlers/admin.js
new file mode 100644
index 0000000..0c52975
--- /dev/null
+++ b/core/modules/Logger/handlers/admin.js
@@ -0,0 +1,77 @@
+const modulename = 'Logger:Admin';
+import fsp from 'node:fs/promises';
+import path from 'node:path';
+import { getBootDivider } from '../loggerUtils';
+import consoleFactory from '@lib/console';
+import { LoggerBase } from '../LoggerBase';
+import { chalkInversePad, getTimeHms } from '@lib/misc';
+const console = consoleFactory(modulename);
+
+
+export default class AdminLogger extends LoggerBase {
+ constructor(basePath, lrProfileConfig) {
+ const lrDefaultOptions = {
+ path: basePath,
+ intervalBoundary: true,
+ initialRotation: true,
+ history: 'admin.history',
+ interval: '7d',
+
+ };
+ super(basePath, 'admin', lrDefaultOptions, lrProfileConfig);
+ this.lrStream.write(getBootDivider());
+
+ this.writeCounter = 0;
+ }
+
+ /**
+ * Returns a string with short usage stats
+ */
+ getUsageStats() {
+ return `Writes: ${this.writeCounter}, lrErrors: ${this.lrErrors}`;
+ }
+
+ /**
+ * Returns an string with everything in admin.log (the active log rotate file)
+ */
+ async getRecentBuffer() {
+ try {
+ return await fsp.readFile(path.join(this.basePath, 'admin.log'), 'utf8');
+ } catch (error) {
+ return false;
+ }
+ }
+
+ /**
+ * Handles the input of log data
+ *
+ * @param {string} author
+ * @param {string} message
+ */
+ writeSystem(author, message) {
+ const timestamp = getTimeHms();
+ this.lrStream.write(`[${timestamp}][${author}] ${message}\n`);
+ this.writeCounter++;
+ }
+
+
+ /**
+ * Handles the input of log data
+ * TODO: add here discord log forwarding
+ *
+ * @param {string} author
+ * @param {string} action
+ * @param {'default'|'command'} type
+ */
+ write(author, action, type = 'default') {
+ let saveMsg;
+ if (type === 'command') {
+ saveMsg = `[${author}] executed "${action}"`;
+ console.log(`${author} executed ` + chalkInversePad(action));
+ } else {
+ saveMsg = action;
+ console.log(saveMsg);
+ }
+ this.writeSystem(author, saveMsg);
+ }
+};
diff --git a/core/modules/Logger/handlers/server.js b/core/modules/Logger/handlers/server.js
new file mode 100644
index 0000000..2d29b1c
--- /dev/null
+++ b/core/modules/Logger/handlers/server.js
@@ -0,0 +1,308 @@
+/* eslint-disable padded-blocks */
+const modulename = 'Logger:Server';
+import { QuantileArray, estimateArrayJsonSize } from '@modules/Metrics/statsUtils';
+import { LoggerBase } from '../LoggerBase';
+import { getBootDivider } from '../loggerUtils';
+import consoleFactory from '@lib/console';
+import bytes from 'bytes';
+import { summarizeIdsArray } from '@lib/player/idUtils';
+const console = consoleFactory(modulename);
+
+/*
+NOTE: Expected time cap based on log size cap to prevent memory leak
+Big server: 300 events/min (freeroam/dm with 100+ players)
+Medium servers: 30 events/min (rp with up to 64 players)
+
+64k cap: 3.5h big, 35.5h medium, 24mb, 620ms/1000 seek time
+32k cap: 1.7h big, 17.7h medium, 12mb, 307ms/1000 seek time
+16k cap: 0.9h big, 9h medium, 6mb, 150ms/1000 seek time
+
+> Seek time based on getting 500 items older than cap - 1000 (so near the end of the array) run 1k times
+> Memory calculated with process.memoryUsage().heapTotal considering every event about 300 bytes
+
+NOTE: Although we could comfortably do 64k cap, even if showing 500 lines per page, nobody would
+navigate through 128 pages, so let's do 16k cap since there is not even a way for the admin to skip
+pages since it's all relative (older/newer) just like github's tags/releases page.
+
+NOTE: Final code after 2.5h at 2400 events/min with websocket client the memory usage was 135mb
+
+
+TODO: maybe a way to let big servers filter what is logged or not? That would be an export in fxs,
+before sending it to fd3
+*/
+
+//DEBUG testing stuff
+// let cnt = 0;
+// setInterval(() => {
+// cnt++;
+// if (cnt > 84) cnt = 1;
+// const mtx = txCore.fxRunner.child?.mutex ?? 'UNKNW';
+// const payload = [
+// {
+// src: 'tx',
+// ts: Date.now(),
+// type: 'DebugMessage',
+// data: cnt + '='.repeat(cnt),
+// },
+// ];
+// txCore.logger.server.write(mtx, payload);
+// }, 750);
+
+
+export default class ServerLogger extends LoggerBase {
+ constructor(basePath, lrProfileConfig) {
+ const lrDefaultOptions = {
+ path: basePath,
+ intervalBoundary: true,
+ initialRotation: true,
+ history: 'server.history',
+ // compress: 'gzip',
+ interval: '1d',
+ maxFiles: 7,
+ maxSize: '10G',
+
+ };
+ super(basePath, 'server', lrDefaultOptions, lrProfileConfig);
+ this.lrStream.write(getBootDivider());
+
+ this.recentBuffer = [];
+ this.recentBufferMaxSize = 32e3;
+
+ //stats stuff
+ this.eventsPerMinute = new QuantileArray(24 * 60, 6 * 60); //max 1d, min 6h
+ this.eventsThisMinute = 0;
+ setInterval(() => {
+ this.eventsPerMinute.count(this.eventsThisMinute);
+ this.eventsThisMinute = 0;
+ }, 60_000);
+ }
+
+
+ /**
+ * Returns a string with short usage stats
+ */
+ getUsageStats() {
+ // Get events/min
+ const eventsPerMinRes = this.eventsPerMinute.resultSummary();
+ const eventsPerMinStr = eventsPerMinRes.enoughData
+ ? eventsPerMinRes.summary
+ : 'LowCount';
+
+ //Buffer JSON size (8k min buffer, 1k samples)
+ const bufferJsonSizeRes = estimateArrayJsonSize(this.recentBuffer, 4e3);
+ const bufferJsonSizeStr = bufferJsonSizeRes.enoughData
+ ? `${bytes(bufferJsonSizeRes.bytesPerElement)}/e`
+ : 'LowCount';
+
+ 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
+ * @returns the recent buffer, optionally only the last N elements
+ */
+ getRecentBuffer(lastN) {
+ return (lastN) ? this.recentBuffer.slice(-lastN) : this.recentBuffer;
+ }
+
+
+ /**
+ * Processes the FD3 log array
+ * @param {Object[]} data
+ * @param {string} [mutex]
+ */
+ write(data, mutex) {
+ if (!Array.isArray(data)) {
+ console.verbose.warn(`write() expected array, got ${typeof data}`);
+ return false;
+ }
+ mutex ??= txCore.fxRunner.child.mutex ?? 'UNKNW';
+
+ //Processing events
+ for (let i = 0; i < data.length; i++) {
+ try {
+ const { eventObject, eventString } = this.processEvent(data[i], mutex);
+ if (!eventObject || !eventString) {
+ console.verbose.warn('Failed to parse event:');
+ console.verbose.dir(data[i]);
+ continue;
+ }
+
+ //Add to recent buffer
+ this.eventsThisMinute++;
+ this.recentBuffer.push(eventObject);
+ if (this.recentBuffer.length > this.recentBufferMaxSize) this.recentBuffer.shift();
+
+ //Send to websocket
+ txCore.webServer.webSocket.buffer('serverlog', eventObject);
+
+ //Write to file
+ this.lrStream.write(`${eventString}\n`);
+ } catch (error) {
+ console.verbose.error('Error processing FD3 txAdminLogData:');
+ console.verbose.dir(error);
+ }
+ }
+ }
+
+
+ /**
+ * Processes an event and returns both the string for log file, and object for the web ui
+ * @param {Object} eventData
+ * @param {String} mutex
+ */
+ processEvent(eventData, mutex) {
+ //Get source
+ let srcObject; //to be sent to the UI
+ let srcString; //to ve saved to the log file
+ if (eventData.src === 'tx') {
+ srcObject = { id: false, name: 'txAdmin' };
+ srcString = 'txAdmin';
+
+ } else if (typeof eventData.src === 'number' && eventData.src > 0) {
+ const player = txCore.fxPlayerlist.getPlayerById(eventData.src);
+ if (player) {
+ //FIXME: playermutex must be a ServerPlayer prop, already considering mutex, netid and rollover
+ const playerID = `${mutex}#${eventData.src}`;
+ srcObject = { id: playerID, name: player.displayName };
+ srcString = `[${playerID}] ${player.displayName}`;
+ } else {
+ srcObject = { id: false, name: 'UNKNOWN PLAYER' };
+ srcString = 'UNKNOWN PLAYER';
+ console.verbose.warn('Unknown numeric event source from object:');
+ console.verbose.dir(eventData);
+ }
+ } else {
+ srcObject = { id: false, name: 'UNKNOWN' };
+ srcString = 'UNKNOWN';
+ }
+
+ //Process event types
+ //TODO: normalize/padronize actions
+ let eventMessage; //to be sent to the UI + saved to the log
+ if (eventData.type === 'playerJoining') {
+ const idsString = summarizeIdsArray(eventData?.data?.ids);
+ eventMessage = `joined with identifiers ${idsString}`;
+
+ } else if (eventData.type === 'playerDropped') {
+ const reason = eventData.data.reason || 'UNKNOWN REASON';
+ eventMessage = `disconnected (${reason})`;
+
+ } else if (eventData.type === 'playerJoinDenied') {
+ const reason = eventData.data.reason ?? 'UNKNOWN REASON';
+ eventMessage = `player join denied due to ${reason}`;
+
+ } else if (eventData.type === 'ChatMessage') {
+ const text = (typeof eventData.data.text === 'string') ? eventData.data.text.replace(/\^([0-9])/g, '') : 'unknown message';
+ eventMessage = (typeof eventData.data.author === 'string' && eventData.data.author !== srcObject.name)
+ ? `(${eventData.data.author}): said "${text}"`
+ : `said "${text}"`;
+
+ } else if (eventData.type === 'DeathNotice') {
+ const cause = eventData.data.cause || 'unknown';
+ if (typeof eventData.data.killer === 'number' && eventData.data.killer > 0) {
+ const killer = txCore.fxPlayerlist.getPlayerById(eventData.data.killer);
+ if (killer) {
+ eventMessage = `died from ${cause} by ${killer.displayName}`;
+ } else {
+ eventMessage = `died from ${cause} by unknown killer`;
+ }
+ } else {
+ eventMessage = `died from ${cause}`;
+ }
+
+ } else if (eventData.type === 'explosionEvent') {
+ const expType = eventData.data.explosionType || 'UNKNOWN';
+ eventMessage = `caused an explosion (${expType})`;
+
+ } else if (eventData.type === 'CommandExecuted') {
+ const command = eventData.data || 'unknown';
+ eventMessage = `executed: /${command}`;
+
+ } else if (eventData.type === 'LoggerStarted') {
+ eventMessage = 'Logger started';
+ txCore.metrics.playerDrop.handleServerBootData(eventData.data);
+ if (typeof eventData.data?.gameName === 'string' && eventData.data.gameName.length) {
+ if(eventData.data.gameName === 'gta5'){
+ txCore.cacheStore.set('fxsRuntime:gameName', 'fivem');
+ } else if (eventData.data.gameName === 'rdr3') {
+ txCore.cacheStore.set('fxsRuntime:gameName', 'redm');
+ } else {
+ txCore.cacheStore.delete('fxsRuntime:gameName');
+ }
+ }
+
+ } else if (eventData.type === 'DebugMessage') {
+ eventMessage = (typeof eventData.data === 'string')
+ ? `Debug Message: ${eventData.data}`
+ : 'Debug Message: unknown';
+
+ } else if (eventData.type === 'MenuEvent') {
+ txCore.metrics.txRuntime.menuCommands.count(eventData.data?.action ?? 'unknown');
+ eventMessage = (typeof eventData.data.message === 'string')
+ ? `${eventData.data.message}`
+ : 'did unknown action';
+
+ } else {
+ console.verbose.warn(`Unrecognized event: ${eventData.type}`);
+ console.verbose.dir(eventData);
+ eventMessage = eventData.type;
+ }
+
+
+ //Prepare output
+ const localeTime = new Date(eventData.ts).toLocaleTimeString();
+ eventMessage = eventMessage.replace(/\n/g, '\t'); //Just to make sure no event is injecting line breaks
+ return {
+ eventObject: {
+ ts: eventData.ts,
+ type: eventData.type,
+ src: srcObject,
+ msg: eventMessage,
+ },
+ eventString: `[${localeTime}] ${srcString}: ${eventMessage}`,
+ };
+ }
+
+
+ /**
+ * Returns a slice of the recent buffer OLDER than a reference timestamp.
+ * @param {Number} timestamp
+ * @param {Number} sliceLength
+ */
+ readPartialNewer(timestamp, sliceLength) {
+ //FIXME: use d3 bissect to optimize this
+ const limitIndex = this.recentBuffer.findIndex((x) => x.ts > timestamp);
+ return this.recentBuffer.slice(limitIndex, limitIndex + sliceLength);
+ }
+
+
+ /**
+ * Returns a slice of the recent buffer NEWER than a reference timestamp.
+ * @param {Number} timestamp
+ * @param {Number} sliceLength
+ */
+ readPartialOlder(timestamp, sliceLength) {
+ //FIXME: use d3 bissect to optimize this
+ const limitIndex = this.recentBuffer.findIndex((x) => x.ts >= timestamp);
+
+ if (limitIndex === -1) {
+ //everything is older, return last few
+ return this.recentBuffer.slice(-sliceLength);
+ } else {
+ //not everything is older
+ return this.recentBuffer.slice(Math.max(0, limitIndex - sliceLength), limitIndex);
+ }
+ }
+
+
+ /**
+ * TODO: filter function, so we can search for all log from a specific player
+ */
+ readFiltered() {
+ throw new Error('Not yet implemented.');
+ }
+};
diff --git a/core/modules/Logger/index.ts b/core/modules/Logger/index.ts
new file mode 100644
index 0000000..2412e71
--- /dev/null
+++ b/core/modules/Logger/index.ts
@@ -0,0 +1,47 @@
+const modulename = 'Logger';
+import type { Options as RfsOptions } from 'rotating-file-stream';
+import AdminLogger from './handlers/admin';
+import FXServerLogger from './FXServerLogger';
+import ServerLogger from './handlers/server';
+import { getLogSizes } from './loggerUtils.js';
+import consoleFactory from '@lib/console';
+import { txEnv } from '@core/globalData';
+const console = consoleFactory(modulename);
+
+
+/**
+ * Logger module that holds the scope-specific loggers and provides some utility functions.
+ */
+export default class Logger {
+ private readonly basePath = `${txEnv.profilePath}/logs/`;
+ public readonly admin: AdminLogger;
+ public readonly fxserver: FXServerLogger;
+ public readonly server: ServerLogger;
+
+ constructor() {
+ this.admin = new AdminLogger(this.basePath, txConfig.logger.admin);
+ this.fxserver = new FXServerLogger(this.basePath, txConfig.logger.fxserver);
+ this.server = new ServerLogger(this.basePath, txConfig.logger.server);
+ }
+
+
+ /**
+ * Returns the total size of the log files used.
+ */
+ getUsageStats() {
+ //{loggerName: statsString}
+ throw new Error('Not yet implemented.');
+ }
+
+
+ /**
+ * Return the total size of the log files used.
+ * FIXME: this regex is kinda redundant with the one from loggerUtils.js
+ */
+ async getStorageSize() {
+ return await getLogSizes(
+ this.basePath,
+ /^(admin|fxserver|server)(_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(_\d+)?)?.log$/,
+ );
+ }
+};
diff --git a/core/modules/Logger/loggerUtils.ts b/core/modules/Logger/loggerUtils.ts
new file mode 100644
index 0000000..a0c399a
--- /dev/null
+++ b/core/modules/Logger/loggerUtils.ts
@@ -0,0 +1,59 @@
+import fsp from 'node:fs/promises';
+import path, { sep } from 'node:path';
+import bytes from 'bytes';
+import { txEnv } from '@core/globalData';
+
+
+/**
+ * Returns an associative array of files in the log folder and it's sizes (human readable)
+ */
+export const getLogSizes = async (basePath: string, filterRegex: RegExp) => {
+ //Reading path
+ const files = await fsp.readdir(basePath, { withFileTypes: true });
+ const statOps = [];
+ const statNames: string[] = [];
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ const filePath = path.join(basePath, file.name);
+ if (!filterRegex.test(file.name) || !file.isFile()) continue;
+ statNames.push(file.name);
+ statOps.push(fsp.stat(filePath));
+ }
+
+ //Processing files
+ let totalBytes = 0;
+ const fileStatsSizes = await Promise.allSettled(statOps);
+ const fileStatsArray = fileStatsSizes.map((op, index) => {
+ if (op.status === 'fulfilled') {
+ totalBytes += op.value.size;
+ return [statNames[index], bytes(op.value.size)];
+ } else {
+ return [statNames[index], false];
+ }
+ });
+ return {
+ total: bytes(totalBytes),
+ files: Object.fromEntries(fileStatsArray),
+ };
+};
+
+
+/**
+ * Generates a multiline separator string with 1 line padding
+ */
+export const getLogDivider = (msg: string) => {
+ const sepLine = '='.repeat(64);
+ const timestamp = new Date().toLocaleString();
+ let out = sepLine + '\n';
+ out += `======== ${msg} - ${timestamp}`.padEnd(64, ' ') + '\n';
+ out += sepLine + '\n';
+ return out;
+};
+
+
+/**
+ * Returns the boot divider, with the txAdmin and FXServer versions
+ */
+export const getBootDivider = () => {
+ return getLogDivider(`txAdmin v${txEnv.txaVersion} atop fxserver ${txEnv.fxsVersion} Starting`);
+}
diff --git a/core/modules/Metrics/index.ts b/core/modules/Metrics/index.ts
new file mode 100644
index 0000000..d586051
--- /dev/null
+++ b/core/modules/Metrics/index.ts
@@ -0,0 +1,55 @@
+const modulename = 'Metrics';
+import consoleFactory from '@lib/console';
+import SvRuntimeMetrics from './svRuntime';
+import TxRuntimeMetrics from './txRuntime';
+import PlayerDropMetrics from './playerDrop';
+import type { UpdateConfigKeySet } from '@modules/ConfigStore/utils';
+const console = consoleFactory(modulename);
+
+
+/**
+ * Module responsible to collect statistics and data.
+ * It is broken down into sub-modules for each specific area.
+ */
+export default class Metrics {
+ static readonly configKeysWatched = [
+ 'server.dataPath',
+ 'server.cfgPath',
+ 'whitelist.mode',
+ ];
+
+ public readonly svRuntime: SvRuntimeMetrics;
+ public readonly txRuntime: TxRuntimeMetrics;
+ public readonly playerDrop: PlayerDropMetrics;
+
+ constructor() {
+ this.svRuntime = new SvRuntimeMetrics();
+ this.txRuntime = new TxRuntimeMetrics();
+ this.playerDrop = new PlayerDropMetrics();
+ }
+
+ /**
+ * Handle updates to the config by resetting the required metrics
+ */
+ public handleConfigUpdate(updatedConfigs: UpdateConfigKeySet) {
+ //TxRuntime
+ if(updatedConfigs.hasMatch('whitelist.mode')){
+ txCore.metrics.txRuntime.whitelistCheckTime.clear();
+ }
+
+ //PlayerDrop
+ const hasServerDataChanged = updatedConfigs.hasMatch('server.dataPath');
+ const hasServerCfgChanged = updatedConfigs.hasMatch('server.cfgPath');
+ let pdlResetMsgPart: string | undefined;
+ if(hasServerDataChanged && hasServerCfgChanged){
+ pdlResetMsgPart = 'Data Path and CFG Path';
+ } else if (hasServerDataChanged){
+ pdlResetMsgPart = 'Data Path';
+ } else if (hasServerCfgChanged){
+ pdlResetMsgPart = 'CFG Path';
+ }
+ if (pdlResetMsgPart) {
+ this.playerDrop.resetLog(`Server ${pdlResetMsgPart} changed.`);
+ }
+ }
+};
diff --git a/core/modules/Metrics/playerDrop/classifyDropReason.test.ts b/core/modules/Metrics/playerDrop/classifyDropReason.test.ts
new file mode 100644
index 0000000..bb43cba
--- /dev/null
+++ b/core/modules/Metrics/playerDrop/classifyDropReason.test.ts
@@ -0,0 +1,181 @@
+//@ts-nocheck
+import { expect, it, suite } from 'vitest';
+import { classifyDrop } from './classifyDropReason';
+import { PDL_CRASH_REASON_CHAR_LIMIT, PDL_UNKNOWN_REASON_CHAR_LIMIT } from './config';
+
+
+const playerInitiatedExamples = [
+ `Exiting`,
+ `Quit: safasdfsadfasfd`,
+ `Entering Rockstar Editor`,
+ `Could not find requested level (%s) - loaded the default level instead.`,
+ `Reloading game.`,
+ `Reconnecting`,
+ `Connecting to another server.`,
+ `Disconnected.`,
+];
+const serverInitiatedExamples = [
+ `Disconnected by server: %s`,
+ `Server shutting down: %s`,
+ `[txAdmin] Server restarting (scheduled restart at 03:00).`, //not so sure about this
+];
+const timeoutExamples = [
+ `Server->client connection timed out. Pending commands: %d.\nCommand list:\n%s`,
+ `Server->client connection timed out. Last seen %d msec ago.`,
+ `Connection timed out.`,
+ `Timed out after 60 seconds (1, %d)`,
+ `Timed out after 60 seconds (2)`,
+];
+const securityExamples = [
+ `Unreliable network event overflow.`,
+ `Reliable server command overflow.`,
+ `Reliable network event overflow.`,
+ `Reliable network event size overflow: %s`,
+ `Reliable state bag packet overflow.`,
+ `Connection to CNL timed out.`,
+ `Server Command Overflow`,
+ `Invalid Client configuration. Restart your Game and reconnect.`,
+]
+const crashExamples = [
+ `Game crashed: Recursive error: An exception occurred (c0000005 at 0x7ff6bb17f1c9) during loading of resources:/cars/data/[limiteds]/xmas 4/carvariations.meta in data file mounter 0x141a22350. The game will be terminated.`,
+ `O jogo crashou: %s`,
+];
+const crashExceptionExamples = [
+ `Game crashed: Unhandled exception: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`,
+ `Game crashed: Exceção não tratada: %s`,
+];
+
+
+suite('classifyDrop legacy mode', () => {
+ const fnc = (reason: string) => classifyDrop({
+ type: 'txAdminPlayerlistEvent',
+ event: 'playerDropped',
+ id: 0,
+ reason,
+ });
+ it('should handle invalid reasons', () => {
+ expect(fnc(undefined as any)).toEqual({
+ category: 'unknown',
+ cleanReason: '[tx:invalid-reason]',
+ });
+ expect(fnc('')).toEqual({
+ category: 'unknown',
+ cleanReason: '[tx:empty-reason]',
+ });
+ expect(fnc(' ')).toEqual({
+ category: 'unknown',
+ cleanReason: '[tx:empty-reason]',
+ });
+ });
+ it('should classify player-initiated reasons', () => {
+ for (const reason of playerInitiatedExamples) {
+ expect(fnc(reason).category).toBe('player');
+ }
+ });
+ it('should classify server-initiated reasons', () => {
+ for (const reason of serverInitiatedExamples) {
+ expect(fnc(reason).category).toBe(false);
+ }
+ });
+ it('should classify timeout reasons', () => {
+ for (const reason of timeoutExamples) {
+ expect(fnc(reason).category).toBe('timeout');
+ }
+ });
+ it('should classify security reasons', () => {
+ for (const reason of securityExamples) {
+ expect(fnc(reason).category).toBe('security');
+ }
+ });
+ it('should classify crash reasons', () => {
+ for (const reason of [...crashExamples, ...crashExceptionExamples]) {
+ expect(fnc(reason).category).toBe('crash');
+ }
+ });
+ it('should translate crash exceptions', () => {
+ for (const reason of [...crashExceptionExamples]) {
+ const resp = fnc(reason);
+ expect(resp.cleanReason).toBeTypeOf('string');
+ expect(resp.cleanReason).toSatisfy((x: string) => {
+ return x.startsWith('Unhandled exception: ')
+ });
+ }
+ });
+ it('should handle long crash reasons', () => {
+ const resp = fnc(crashExamples[0] + 'a'.repeat(1000));
+ expect(resp.cleanReason).toBeTypeOf('string');
+ expect(resp.cleanReason!.length).toBe(PDL_CRASH_REASON_CHAR_LIMIT);
+ });
+ it('should handle unknown reasons', () => {
+ const resp = fnc('a'.repeat(1000));
+ expect(resp.cleanReason).toBeTypeOf('string');
+ expect(resp.cleanReason!.length).toBe(PDL_UNKNOWN_REASON_CHAR_LIMIT);
+ expect(resp.category).toBe('unknown');
+ });
+});
+
+
+suite('classifyDrop new mode', () => {
+ const fnc = (reason: string, resource: string, category: number) => classifyDrop({
+ type: 'txAdminPlayerlistEvent',
+ event: 'playerDropped',
+ id: 0,
+ reason,
+ category,
+ resource,
+ });
+ it('should handle invalid categories', () => {
+ expect(fnc('rsn', 'res', null)).toEqual({
+ category: 'unknown',
+ cleanReason: '[tx:invalid-category] rsn',
+ });
+ expect(fnc('rsn', 'res', -1)).toEqual({
+ category: 'unknown',
+ cleanReason: '[tx:invalid-category] rsn',
+ });
+ expect(fnc('rsn', 'res', 999)).toEqual({
+ category: 'unknown',
+ cleanReason: '[tx:unknown-category] rsn',
+ });
+ });
+
+ it('should handle the resource category', () => {
+ expect(fnc('rsn', 'res', 1)).toEqual({
+ category: 'resource',
+ resource: 'res',
+ });
+ expect(fnc('rsn', '', 1)).toEqual({
+ category: 'resource',
+ resource: 'unknown',
+ });
+ expect(fnc('rsn', 'monitor', 1)).toEqual({
+ category: 'resource',
+ resource: 'txAdmin',
+ });
+ expect(fnc('server_shutting_down', 'monitor', 1)).toEqual({
+ category: false,
+ });
+ });
+ it('should handle the client category', () => {
+ expect(fnc('rsn', 'res', 2)).toEqual({
+ category: 'player',
+ });
+ expect(fnc(crashExamples[0], 'res', 2).category).toEqual('crash');
+ });
+ it('should handle the timeout category', () => {
+ expect(fnc('rsn', 'res', 5)).toEqual({category: 'timeout'});
+ expect(fnc('rsn', 'res', 6)).toEqual({category: 'timeout'});
+ expect(fnc('rsn', 'res', 12)).toEqual({category: 'timeout'});
+ });
+ it('should handle the security category', () => {
+ expect(fnc('rsn', 'res', 3)).toEqual({category: 'security'});
+ expect(fnc('rsn', 'res', 4)).toEqual({category: 'security'});
+ expect(fnc('rsn', 'res', 8)).toEqual({category: 'security'});
+ expect(fnc('rsn', 'res', 9)).toEqual({category: 'security'});
+ expect(fnc('rsn', 'res', 10)).toEqual({category: 'security'});
+ expect(fnc('rsn', 'res', 11)).toEqual({category: 'security'});
+ });
+ it('should handle the shutdown category', () => {
+ expect(fnc('rsn', 'res', 7)).toEqual({category: false});
+ });
+});
diff --git a/core/modules/Metrics/playerDrop/classifyDropReason.ts b/core/modules/Metrics/playerDrop/classifyDropReason.ts
new file mode 100644
index 0000000..5c2a6da
--- /dev/null
+++ b/core/modules/Metrics/playerDrop/classifyDropReason.ts
@@ -0,0 +1,296 @@
+import { PlayerDropEvent } from "@modules/FxPlayerlist";
+import { PDL_CRASH_REASON_CHAR_LIMIT, PDL_UNKNOWN_REASON_CHAR_LIMIT } from "./config";
+
+const playerInitiatedRules = [
+ `exiting`,//basically only see this one
+ `disconnected.`, //need to keep the dot
+ `connecting to another server`,
+ `could not find requested level`,
+ `entering rockstar editor`,
+ `quit:`,
+ `reconnecting`,
+ `reloading game`,
+];
+
+//FIXME: remover essa categoria, checar com XXXX se os security podem vir como security ao invés de server-initiated
+const serverInitiatedRules = [
+ //NOTE: this is not a real drop reason prefix, but something that the client sees only
+ `disconnected by server:`,
+
+ //NOTE: Happens only when doing "quit xxxxxx" in live console
+ `server shutting down:`,
+
+ //NOTE: Happens when txAdmin players - but soon specific player kicks (instead of kick all)
+ // will not fall under this category anymore
+ `[txadmin]`,
+];
+const timeoutRules = [
+ `server->client connection timed out`, //basically only see this one - FIXME: falta espaço?
+ `connection timed out`,
+ `timed out after 60 seconds`, //onesync timeout
+];
+const securityRules = [
+ `reliable network event overflow`,
+ `reliable network event size overflow:`,
+ `reliable server command overflow`,
+ `reliable state bag packet overflow`,
+ `unreliable network event overflow`,
+ `connection to cnl timed out`,
+ `server command overflow`,
+ `invalid client configuration. restart your game and reconnect`,
+];
+
+//The crash message prefix is translated, so we need to lookup the translations.
+//This list covers the top 20 languages used in FiveM, with exceptions languages with no translations.
+const crashRulesIntl = [
+ // (en) English - 18.46%
+ `game crashed: `,
+ // (pt) Portuguese - 15.67%
+ `o jogo crashou: `,
+ // (fr) French - 9.58%
+ `le jeu a cessé de fonctionner : `,
+ // (de) German - 9.15%
+ `spielabsturz: `,
+ // (es) Spanish - 6.08%
+ `el juego crasheó: `,
+ // (ar) Arabic - 6.04%
+ `تعطلت العبة: `,
+ // (nl) Dutch - 2.46%
+ `spel werkt niet meer: `,
+ // (tr) Turkish - 1.37%
+ `oyun çöktü: `,
+ // (hu) Hungarian - 1.31%
+ `a játék összeomlott: `,
+ // (it) Italian - 1.20%
+ `il gioco ha smesso di funzionare: `,
+ // (zh) Chinese - 1.02%
+ `游戏发生崩溃:`,
+ `遊戲已崩潰: `,
+ // (cs) Czech - 0.92%
+ `pád hry: `,
+ // (sv) Swedish - 0.69%
+ `spelet kraschade: `,
+];
+const exceptionPrefixesIntl = [
+ // (en) English - 18.46%
+ `unhandled exception: `,
+ // (pt) Portuguese - 15.67%
+ `exceção não tratada: `,
+ // (fr) French - 9.58%
+ `exception non-gérée : `,
+ // (de) German - 9.15%
+ `unbehandelte ausnahme: `,
+ // (es) Spanish - 6.08%
+ `excepción no manejada: `,
+ // (ar) Arabic - 6.04%
+ `استثناء غير معالج: `,
+ // (nl) Dutch - 2.46%
+ // NOTE: Dutch doesn't have a translation for "unhandled exception"
+ // (tr) Turkish - 1.37%
+ `i̇şlenemeyen özel durum: `,
+ // (hu) Hungarian - 1.31%
+ `nem kezelt kivétel: `,
+ // (it) Italian - 1.20%
+ `eccezione non gestita: `,
+ // (zh) Chinese - 1.02%
+ `未处理的异常:`,
+ `未處理的異常: `,
+ // (cs) Czech - 0.92%
+ `neošetřená výjimka: `,
+ // (sv) Swedish - 0.69%
+ `okänt fel: `,
+];
+
+const truncateReason = (reason: string, maxLength: number, prefix?: string) => {
+ prefix = prefix ? `${prefix} ` : '';
+ if (prefix) {
+ maxLength -= prefix.length;
+ }
+ const truncationSuffix = '[truncated]';
+ if (!reason.length) {
+ return prefix + '[tx:empty-reason]';
+ }else if (reason.length > maxLength) {
+ return prefix + reason.slice(0, maxLength - truncationSuffix.length) + truncationSuffix;
+ } else {
+ return prefix + reason;
+ }
+}
+
+const cleanCrashReason = (reason: string) => {
+ const cutoffIdx = reason.indexOf(': ') + 2;
+ const msg = reason.slice(cutoffIdx);
+ const exceptionPrefix = exceptionPrefixesIntl.find((prefix) => msg.toLocaleLowerCase().startsWith(prefix));
+ const saveMsg = exceptionPrefix
+ ? 'Unhandled exception: ' + msg.slice(exceptionPrefix.length)
+ : msg;
+ return truncateReason(saveMsg, PDL_CRASH_REASON_CHAR_LIMIT);
+}
+
+/**
+ * Classifies a drop reason into a category, and returns a cleaned up version of it.
+ * The cleaned up version is truncated to a certain length.
+ * The cleaned up version of crash reasons have the prefix translated back into English.
+ */
+const guessDropReasonCategory = (reason: string): ClassifyDropReasonResponse => {
+ if (typeof reason !== 'string') {
+ return {
+ category: 'unknown' as const,
+ cleanReason: '[tx:invalid-reason]',
+ };
+ }
+ const reasonToMatch = reason.trim().toLocaleLowerCase();
+
+ if (!reasonToMatch.length) {
+ return {
+ category: 'unknown' as const,
+ cleanReason: '[tx:empty-reason]',
+ };
+ } else if (playerInitiatedRules.some((rule) => reasonToMatch.startsWith(rule))) {
+ return { category: 'player' };
+ } else if (serverInitiatedRules.some((rule) => reasonToMatch.startsWith(rule))) {
+ return { category: false };
+ } else if (timeoutRules.some((rule) => reasonToMatch.includes(rule))) {
+ return { category: 'timeout' };
+ } else if (securityRules.some((rule) => reasonToMatch.includes(rule))) {
+ return { category: 'security' };
+ } else if (crashRulesIntl.some((rule) => reasonToMatch.includes(rule))) {
+ return {
+ category: 'crash' as const,
+ cleanReason: cleanCrashReason(reason),
+ };
+ } else {
+ return {
+ category: 'unknown' as const,
+ cleanReason: truncateReason(reason, PDL_UNKNOWN_REASON_CHAR_LIMIT),
+ };
+ }
+}
+
+//NOTE: From fivem/code/components/citizen-server-impl/include/ClientDropReasons.h
+export enum FxsDropReasonGroups {
+ //1 resource dropped the client
+ RESOURCE = 1,
+ //2 client initiated a disconnect
+ CLIENT,
+ //3 server initiated a disconnect
+ SERVER,
+ //4 client with same guid connected and kicks old client
+ CLIENT_REPLACED,
+ //5 server -> client connection timed out
+ CLIENT_CONNECTION_TIMED_OUT,
+ //6 server -> client connection timed out with pending commands
+ CLIENT_CONNECTION_TIMED_OUT_WITH_PENDING_COMMANDS,
+ //7 server shutdown triggered the client drop
+ SERVER_SHUTDOWN,
+ //8 state bag rate limit exceeded
+ STATE_BAG_RATE_LIMIT,
+ //9 net event rate limit exceeded
+ NET_EVENT_RATE_LIMIT,
+ //10 latent net event rate limit exceeded
+ LATENT_NET_EVENT_RATE_LIMIT,
+ //11 command rate limit exceeded
+ COMMAND_RATE_LIMIT,
+ //12 too many missed frames in OneSync
+ ONE_SYNC_TOO_MANY_MISSED_FRAMES,
+};
+
+const timeoutCategory = [
+ FxsDropReasonGroups.CLIENT_CONNECTION_TIMED_OUT,
+ FxsDropReasonGroups.CLIENT_CONNECTION_TIMED_OUT_WITH_PENDING_COMMANDS,
+ FxsDropReasonGroups.ONE_SYNC_TOO_MANY_MISSED_FRAMES,
+];
+const securityCategory = [
+ FxsDropReasonGroups.SERVER,
+ FxsDropReasonGroups.CLIENT_REPLACED,
+ FxsDropReasonGroups.STATE_BAG_RATE_LIMIT,
+ FxsDropReasonGroups.NET_EVENT_RATE_LIMIT,
+ FxsDropReasonGroups.LATENT_NET_EVENT_RATE_LIMIT,
+ FxsDropReasonGroups.COMMAND_RATE_LIMIT,
+];
+
+
+/**
+ * Classifies a drop reason into a category, and returns a cleaned up version of it.
+ * The cleaned up version is truncated to a certain length.
+ * The cleaned up version of crash reasons have the prefix translated back into English.
+ */
+export const classifyDrop = (payload: PlayerDropEvent): ClassifyDropReasonResponse => {
+ if (typeof payload.reason !== 'string') {
+ return {
+ category: 'unknown',
+ cleanReason: '[tx:invalid-reason]',
+ };
+ } else if (payload.category === undefined || payload.resource === undefined) {
+ return guessDropReasonCategory(payload.reason);
+ }
+
+ if (typeof payload.category !== 'number' || payload.category <= 0) {
+ return {
+ category: 'unknown',
+ cleanReason: truncateReason(
+ payload.reason,
+ PDL_UNKNOWN_REASON_CHAR_LIMIT,
+ '[tx:invalid-category]'
+ ),
+ };
+ } else if (payload.category === FxsDropReasonGroups.RESOURCE) {
+ if (payload.resource === 'monitor') {
+ //if server shutting down, return ignore, otherwise return server-initiated
+ if (payload.reason === 'server_shutting_down') {
+ return { category: false };
+ } else {
+ return {
+ category: 'resource',
+ resource: 'txAdmin'
+ };
+ }
+ } else {
+ return {
+ category: 'resource',
+ resource: payload.resource ? payload.resource : 'unknown',
+ }
+ }
+ } else if (payload.category === FxsDropReasonGroups.CLIENT) {
+ //check if it's crash
+ const reasonToMatch = payload.reason.trim().toLocaleLowerCase();
+ if (crashRulesIntl.some((rule) => reasonToMatch.includes(rule))) {
+ return {
+ category: 'crash',
+ cleanReason: cleanCrashReason(payload.reason),
+ }
+ } else {
+ return { category: 'player' };
+ }
+ } else if (timeoutCategory.includes(payload.category)) {
+ return { category: 'timeout' };
+ } else if (securityCategory.includes(payload.category)) {
+ return { category: 'security' };
+ } else if (payload.category === FxsDropReasonGroups.SERVER_SHUTDOWN) {
+ return { category: false };
+ } else {
+ return {
+ category: 'unknown',
+ cleanReason: truncateReason(
+ payload.reason,
+ PDL_UNKNOWN_REASON_CHAR_LIMIT,
+ '[tx:unknown-category]'
+ ),
+ };
+ }
+}
+type SimpleDropCategory = 'player' | 'timeout' | 'security';
+// type DetailedDropCategory = 'resource' | 'crash' | 'unknown';
+// type DropCategories = SimpleDropCategory | DetailedDropCategory;
+
+
+type ClassifyDropReasonResponse = {
+ category: SimpleDropCategory;
+} | {
+ category: 'resource';
+ resource: string;
+} | {
+ category: 'crash' | 'unknown';
+ cleanReason: string;
+} | {
+ category: false; //server shutting down, ignore
+}
diff --git a/core/modules/Metrics/playerDrop/config.ts b/core/modules/Metrics/playerDrop/config.ts
new file mode 100644
index 0000000..54e14c5
--- /dev/null
+++ b/core/modules/Metrics/playerDrop/config.ts
@@ -0,0 +1,8 @@
+/**
+ * Configs
+ */
+const daysMs = 24 * 60 * 60 * 1000;
+export const PDL_CRASH_REASON_CHAR_LIMIT = 512;
+export const PDL_UNKNOWN_REASON_CHAR_LIMIT = 320;
+export const PDL_UNKNOWN_LIST_SIZE_LIMIT = 200; //at most 62.5kb (200*320/1024)
+export const PDL_RETENTION = 14 * daysMs;
diff --git a/core/modules/Metrics/playerDrop/index.ts b/core/modules/Metrics/playerDrop/index.ts
new file mode 100644
index 0000000..62b3025
--- /dev/null
+++ b/core/modules/Metrics/playerDrop/index.ts
@@ -0,0 +1,351 @@
+const modulename = 'PlayerDropMetrics';
+import fsp from 'node:fs/promises';
+import consoleFactory from '@lib/console';
+import { PDLChangeEventType, PDLFileSchema, PDLFileType, PDLHourlyRawType, PDLHourlyType, PDLServerBootDataSchema } from './playerDropSchemas';
+import { classifyDrop } from './classifyDropReason';
+import { PDL_RETENTION, PDL_UNKNOWN_LIST_SIZE_LIMIT } from './config';
+import { ZodError } from 'zod';
+import { getDateHourEnc, parseDateHourEnc } from './playerDropUtils';
+import { MultipleCounter } from '../statsUtils';
+import { throttle } from 'throttle-debounce';
+import { PlayerDropsDetailedWindow, PlayerDropsSummaryHour } from '@routes/playerDrops';
+import { migratePlayerDropsFile } from './playerDropMigrations';
+import { parseFxserverVersion } from '@lib/fxserver/fxsVersionParser';
+import { PlayerDropEvent } from '@modules/FxPlayerlist';
+import { txEnv } from '@core/globalData';
+const console = consoleFactory(modulename);
+
+
+//Consts
+export const LOG_DATA_FILE_VERSION = 2;
+const LOG_DATA_FILE_NAME = 'stats_playerDrop.json';
+
+
+/**
+ * Stores player drop logs, and also logs other information that might be relevant to player crashes,
+ * such as changes to the detected game/server version, resources, etc.
+ *
+ * NOTE: PDL = PlayerDropLog
+ */
+export default class PlayerDropMetrics {
+ private readonly logFilePath = `${txEnv.profilePath}/data/${LOG_DATA_FILE_NAME}`;
+ private eventLog: PDLHourlyType[] = [];
+ private lastGameVersion: string | undefined;
+ private lastServerVersion: string | undefined;
+ private lastResourceList: string[] | undefined;
+ private lastUnknownReasons: string[] = [];
+ private queueSaveEventLog = throttle(
+ 15_000,
+ this.saveEventLog.bind(this),
+ { noLeading: true }
+ );
+
+ constructor() {
+ setImmediate(() => {
+ this.loadEventLog();
+ });
+ }
+
+
+ /**
+ * Get the recent category count for player drops in the last X hours
+ */
+ public getRecentDropTally(windowHours: number) {
+ const logCutoff = (new Date).setUTCMinutes(0, 0, 0) - (windowHours * 60 * 60 * 1000) - 1;
+ const flatCounts = this.eventLog
+ .filter((entry) => entry.hour.dateHourTs >= logCutoff)
+ .map((entry) => entry.dropTypes.toSortedValuesArray())
+ .flat();
+ const cumulativeCounter = new MultipleCounter();
+ cumulativeCounter.merge(flatCounts);
+ return cumulativeCounter.toSortedValuesArray();
+ }
+
+
+ /**
+ * Get the recent log with drop/crash/changes for the last X hours
+ */
+ public getRecentSummary(windowHours: number): PlayerDropsSummaryHour[] {
+ const logCutoff = (new Date).setUTCMinutes(0, 0, 0) - (windowHours * 60 * 60 * 1000);
+ const windowSummary = this.eventLog
+ .filter((entry) => entry.hour.dateHourTs >= logCutoff)
+ .map((entry) => ({
+ hour: entry.hour.dateHourStr,
+ changes: entry.changes.length,
+ dropTypes: entry.dropTypes.toSortedValuesArray(),
+ }));
+ return windowSummary;
+ }
+
+
+ /**
+ * Get the data for the player drops drilldown card within a inclusive time window
+ */
+ public getWindowData(windowStart: number, windowEnd: number): PlayerDropsDetailedWindow {
+ const allChanges: PDLChangeEventType[] = [];
+ const crashTypes = new MultipleCounter();
+ const dropTypes = new MultipleCounter();
+ const resKicks = new MultipleCounter();
+ const filteredLogs = this.eventLog.filter((entry) => {
+ return entry.hour.dateHourTs >= windowStart && entry.hour.dateHourTs <= windowEnd;
+ });
+ for (const log of filteredLogs) {
+ allChanges.push(...log.changes);
+ crashTypes.merge(log.crashTypes);
+ dropTypes.merge(log.dropTypes);
+ resKicks.merge(log.resKicks);
+ }
+ return {
+ changes: allChanges,
+ crashTypes: crashTypes.toSortedValuesArray(true),
+ dropTypes: dropTypes.toSortedValuesArray(true),
+ resKicks: resKicks.toSortedValuesArray(true),
+ };
+ }
+
+
+ /**
+ * Returns the object of the current hour object in log.
+ * Creates one if doesn't exist one for the current hour.
+ */
+ private getCurrentLogHourRef() {
+ const { dateHourTs, dateHourStr } = getDateHourEnc();
+ const currentHourLog = this.eventLog.find((entry) => entry.hour.dateHourStr === dateHourStr);
+ if (currentHourLog) return currentHourLog;
+ const newHourLog: PDLHourlyType = {
+ hour: {
+ dateHourTs: dateHourTs,
+ dateHourStr: dateHourStr,
+ },
+ changes: [],
+ crashTypes: new MultipleCounter(),
+ dropTypes: new MultipleCounter(),
+ resKicks: new MultipleCounter(),
+ };
+ this.eventLog.push(newHourLog);
+ return newHourLog;
+ }
+
+
+ /**
+ * Handles receiving the data sent to the logger as soon as the server boots
+ */
+ public handleServerBootData(rawPayload: any) {
+ const logRef = this.getCurrentLogHourRef();
+
+ //Parsing data
+ const validation = PDLServerBootDataSchema.safeParse(rawPayload);
+ if (!validation.success) {
+ console.warn(`Invalid server boot data: ${validation.error.errors}`);
+ return;
+ }
+ const { gameName, gameBuild, fxsVersion, resources } = validation.data;
+ let shouldSave = false;
+
+ //Game version change
+ const gameString = `${gameName}:${gameBuild}`;
+ if (gameString) {
+ if (!this.lastGameVersion) {
+ shouldSave = true;
+ } else if (gameString !== this.lastGameVersion) {
+ shouldSave = true;
+ logRef.changes.push({
+ ts: Date.now(),
+ type: 'gameChanged',
+ oldVersion: this.lastGameVersion,
+ newVersion: gameString,
+ });
+ }
+ this.lastGameVersion = gameString;
+ }
+
+ //Server version change
+ let { build: serverBuild, platform: serverPlatform } = parseFxserverVersion(fxsVersion);
+ const fxsVersionString = `${serverPlatform}:${serverBuild}`;
+ if (fxsVersionString) {
+ if (!this.lastServerVersion) {
+ shouldSave = true;
+ } else if (fxsVersionString !== this.lastServerVersion) {
+ shouldSave = true;
+ logRef.changes.push({
+ ts: Date.now(),
+ type: 'fxsChanged',
+ oldVersion: this.lastServerVersion,
+ newVersion: fxsVersionString,
+ });
+ }
+ this.lastServerVersion = fxsVersionString;
+ }
+
+ //Resource list change - if no resources, ignore as that's impossible
+ if (resources.length) {
+ if (!this.lastResourceList || !this.lastResourceList.length) {
+ shouldSave = true;
+ } else {
+ const resAdded = resources.filter(r => !this.lastResourceList!.includes(r));
+ const resRemoved = this.lastResourceList.filter(r => !resources.includes(r));
+ if (resAdded.length || resRemoved.length) {
+ shouldSave = true;
+ logRef.changes.push({
+ ts: Date.now(),
+ type: 'resourcesChanged',
+ resAdded,
+ resRemoved,
+ });
+ }
+ }
+ this.lastResourceList = resources;
+ }
+
+ //Saving if needed
+ if (shouldSave) {
+ this.queueSaveEventLog();
+ }
+ }
+
+
+ /**
+ * Handles receiving the player drop event, and returns the category of the drop
+ */
+ public handlePlayerDrop(event: PlayerDropEvent) {
+ const drop = classifyDrop(event);
+
+ //Ignore server shutdown drops
+ if (drop.category === false) return false;
+
+ //Log the drop
+ const logRef = this.getCurrentLogHourRef();
+ logRef.dropTypes.count(drop.category);
+ if (drop.category === 'resource' && drop.resource) {
+ logRef.resKicks.count(drop.resource);
+ } else if (drop.category === 'crash' && drop.cleanReason) {
+ logRef.crashTypes.count(drop.cleanReason);
+ } else if (drop.category === 'unknown' && drop.cleanReason) {
+ if (!this.lastUnknownReasons.includes(drop.cleanReason)) {
+ this.lastUnknownReasons.push(drop.cleanReason);
+ }
+ }
+ this.queueSaveEventLog();
+ return drop.category;
+ }
+
+
+ /**
+ * Resets the player drop stats log
+ */
+ public resetLog(reason: string) {
+ if (typeof reason !== 'string' || !reason) throw new Error(`reason required`);
+ this.eventLog = [];
+ this.lastGameVersion = undefined;
+ this.lastServerVersion = undefined;
+ this.lastResourceList = undefined;
+ this.lastUnknownReasons = [];
+ this.queueSaveEventLog.cancel({ upcomingOnly: true });
+ this.saveEventLog(reason);
+ }
+
+
+ /**
+ * Loads the stats database/cache/history
+ */
+ private async loadEventLog() {
+ try {
+ const rawFileData = await fsp.readFile(this.logFilePath, 'utf8');
+ const fileData = JSON.parse(rawFileData);
+ let statsData: PDLFileType;
+ if (fileData.version === LOG_DATA_FILE_VERSION) {
+ statsData = PDLFileSchema.parse(fileData);
+ } else {
+ try {
+ statsData = await migratePlayerDropsFile(fileData);
+ } catch (error) {
+ throw new Error(`Failed to migrate ${LOG_DATA_FILE_NAME} from ${fileData?.version} to ${LOG_DATA_FILE_VERSION}: ${(error as Error).message}`);
+ }
+ }
+ this.lastGameVersion = statsData.lastGameVersion;
+ this.lastServerVersion = statsData.lastServerVersion;
+ this.lastResourceList = statsData.lastResourceList;
+ this.lastUnknownReasons = statsData.lastUnknownReasons;
+ this.eventLog = statsData.log.map((entry): PDLHourlyType => {
+ return {
+ hour: parseDateHourEnc(entry.hour),
+ changes: entry.changes,
+ crashTypes: new MultipleCounter(entry.crashTypes),
+ dropTypes: new MultipleCounter(entry.dropTypes),
+ resKicks: new MultipleCounter(entry.resKicks),
+ }
+ });
+ console.verbose.ok(`Loaded ${this.eventLog.length} log entries from cache`);
+ this.optimizeStatsLog();
+ } catch (error) {
+ if ((error as any)?.code === 'ENOENT') {
+ console.verbose.debug(`${LOG_DATA_FILE_NAME} not found, starting with empty stats.`);
+ this.resetLog('File was just created, no data yet');
+ return;
+ }
+ if (error instanceof ZodError) {
+ console.warn(`Failed to load ${LOG_DATA_FILE_NAME} due to invalid data.`);
+ this.resetLog('Failed to load log file due to invalid data');
+ } else {
+ console.warn(`Failed to load ${LOG_DATA_FILE_NAME} with message: ${(error as Error).message}`);
+ this.resetLog('Failed to load log file due to unknown error');
+ }
+ console.warn('Since this is not a critical file, it will be reset.');
+ }
+ }
+
+
+ /**
+ * Optimizes the event log by removing old entries
+ */
+ private optimizeStatsLog() {
+ if (this.lastUnknownReasons.length > PDL_UNKNOWN_LIST_SIZE_LIMIT) {
+ this.lastUnknownReasons = this.lastUnknownReasons.slice(-PDL_UNKNOWN_LIST_SIZE_LIMIT);
+ }
+
+ const maxAge = Date.now() - PDL_RETENTION;
+ const cutoffIdx = this.eventLog.findIndex((entry) => entry.hour.dateHourTs > maxAge);
+ if (cutoffIdx > 0) {
+ this.eventLog = this.eventLog.slice(cutoffIdx);
+ }
+ }
+
+
+ /**
+ * Saves the stats database/cache/history
+ */
+ private async saveEventLog(emptyReason?: string) {
+ try {
+ const sizeBefore = this.eventLog.length;
+ this.optimizeStatsLog();
+ if (!this.eventLog.length) {
+ if (sizeBefore) {
+ emptyReason ??= 'Cleared due to retention policy';
+ }
+ } else {
+ emptyReason = undefined;
+ }
+
+ const savePerfData: PDLFileType = {
+ version: LOG_DATA_FILE_VERSION,
+ emptyReason,
+ lastGameVersion: this.lastGameVersion ?? 'unknown',
+ lastServerVersion: this.lastServerVersion ?? 'unknown',
+ lastResourceList: this.lastResourceList ?? [],
+ lastUnknownReasons: this.lastUnknownReasons,
+ log: this.eventLog.map((entry): PDLHourlyRawType => {
+ return {
+ hour: entry.hour.dateHourStr,
+ changes: entry.changes,
+ crashTypes: entry.crashTypes.toArray(),
+ dropTypes: entry.dropTypes.toArray(),
+ resKicks: entry.resKicks.toArray(),
+ }
+ }),
+ };
+ await fsp.writeFile(this.logFilePath, JSON.stringify(savePerfData));
+ } catch (error) {
+ console.warn(`Failed to save ${LOG_DATA_FILE_NAME} with message: ${(error as Error).message}`);
+ }
+ }
+};
diff --git a/core/modules/Metrics/playerDrop/playerDropMigrations.ts b/core/modules/Metrics/playerDrop/playerDropMigrations.ts
new file mode 100644
index 0000000..4c70e9d
--- /dev/null
+++ b/core/modules/Metrics/playerDrop/playerDropMigrations.ts
@@ -0,0 +1,59 @@
+import { LOG_DATA_FILE_VERSION } from './index'; //FIXME: circular_dependency
+import { PDLChangeEventType, PDLFileSchema, PDLFileSchema_v1, type PDLFileType } from './playerDropSchemas';
+
+export const migratePlayerDropsFile = async (fileData: any): Promise => {
+ //Migrate from v1 to v2
+ //- adding oldVersion to fxsChanged and gameChanged events
+ //- remove the "Game crashed: " prefix from crash reasons
+ //- renamed "user-initiated" to "player-initiated"
+ //- add the "resources" counter to hourly log
+ if (fileData.version === 1) {
+ console.warn('Migrating your player drops stats v1 to v2.');
+ const data = PDLFileSchema_v1.parse(fileData);
+ const crashPrefix = 'Game crashed: ';
+ let lastFxsVersion = 'unknown';
+ let lastGameVersion = 'unknown';
+ for (const log of data.log) {
+ for (const event of log.changes as PDLChangeEventType[]) {
+ if (event.type === 'fxsChanged') {
+ event.oldVersion = lastFxsVersion;
+ lastFxsVersion = event.newVersion;
+ } else if (event.type === 'gameChanged') {
+ event.oldVersion = lastGameVersion;
+ lastGameVersion = event.newVersion;
+ }
+ }
+ log.crashTypes = log.crashTypes.map(([reason, count]) => {
+ const newReason = reason.startsWith(crashPrefix)
+ ? reason.slice(crashPrefix.length)
+ : reason;
+ return [newReason, count];
+ });
+ //@ts-ignore
+ log.dropTypes = log.dropTypes.map(([type, count]): [string, number] | false => {
+ if (type === 'user-initiated') {
+ return ['player', count]
+ } else if (type === 'server-initiated') {
+ //Mostly server shutdowns
+ return false;
+ } else {
+ return [type, count];
+ }
+ }).filter(Array.isArray);
+ //@ts-ignore
+ log.resKicks = [];
+ }
+
+ fileData = {
+ ...data,
+ version: LOG_DATA_FILE_VERSION
+ }
+ }
+
+ //Final check
+ if (fileData.version === LOG_DATA_FILE_VERSION) {
+ return PDLFileSchema.parse(fileData);
+ } else {
+ throw new Error(`Unknown file version: ${fileData.version}`);
+ }
+}
diff --git a/core/modules/Metrics/playerDrop/playerDropSchemas.ts b/core/modules/Metrics/playerDrop/playerDropSchemas.ts
new file mode 100644
index 0000000..3534024
--- /dev/null
+++ b/core/modules/Metrics/playerDrop/playerDropSchemas.ts
@@ -0,0 +1,114 @@
+import * as z from 'zod';
+import type { MultipleCounter } from '../statsUtils';
+import { parseDateHourEnc } from './playerDropUtils';
+import { DeepReadonly } from 'utility-types';
+
+
+//Generic schemas
+const zIntNonNegative = z.number().int().nonnegative();
+
+//handleServerBootData that comes from txAdmin.loggers.server when the server boots
+export const PDLServerBootDataSchema = z.object({
+ gameName: z.string().min(1).default('unknown'),
+ gameBuild: z.string().min(1).default('unknown'),
+ fxsVersion: z.string().min(1).default('unknown'),
+ resources: z.array(z.string().min(1)),
+ projectName: z.string().optional(),
+});
+
+
+//Log stuff
+export const PDLFxsChangedEventSchema = z.object({
+ ts: zIntNonNegative,
+ type: z.literal('fxsChanged'),
+ oldVersion: z.string(),
+ newVersion: z.string(),
+});
+export const PDLGameChangedEventSchema = z.object({
+ ts: zIntNonNegative,
+ type: z.literal('gameChanged'),
+ oldVersion: z.string(),
+ newVersion: z.string(),
+});
+export const PDLResourcesChangedEventSchema = z.object({
+ ts: zIntNonNegative,
+ type: z.literal('resourcesChanged'),
+ resAdded: z.array(z.string().min(1)),
+ resRemoved: z.array(z.string().min(1)),
+});
+
+export const PDLHourlyRawSchema = z.object({
+ hour: z.string(),
+ changes: z.array(z.union([
+ PDLFxsChangedEventSchema,
+ PDLGameChangedEventSchema,
+ PDLResourcesChangedEventSchema,
+ ])),
+ crashTypes: z.array(z.tuple([z.string(), z.number()])),
+ dropTypes: z.array(z.tuple([z.string(), z.number()])),
+ resKicks: z.array(z.tuple([z.string(), z.number()])),
+});
+
+export const PDLFileSchema = z.object({
+ version: z.literal(2),
+ emptyReason: z.string().optional(), //If the log is empty, this will be the reason
+ lastGameVersion: z.string(),
+ lastServerVersion: z.string(),
+ lastResourceList: z.array(z.string()),
+ lastUnknownReasons: z.array(z.string()), //store the last few for potential analysis
+ log: z.array(PDLHourlyRawSchema),
+});
+
+
+//Exporting types
+export type PDLFileType = z.infer;
+export type PDLHourlyRawType = z.infer;
+export type PDLFxsChangedEventType = z.infer;
+export type PDLGameChangedEventType = z.infer;
+export type PDLResourcesChangedEventType = z.infer;
+// export type PDLClientChangedEventType = z.infer;
+export type PDLChangeEventType = (PDLFxsChangedEventType | PDLGameChangedEventType | PDLResourcesChangedEventType);
+export type PDLHourlyChanges = PDLHourlyRawType['changes'];
+
+//Used after parsing (getCurrentLogHourRef)
+export type PDLHourlyType = {
+ hour: DeepReadonly>;
+ changes: PDLHourlyChanges;
+ crashTypes: MultipleCounter;
+ dropTypes: MultipleCounter;
+ resKicks: MultipleCounter;
+};
+
+
+/**
+ * Migration schemas from v1 to v2 with changes:
+ * - added "oldVersion" to the fxsChanged and gameChanged events
+ * - removed the "Game crashed: " prefix from crash reasons
+ */
+export const PDLFxsChangedEventSchema_v1 = PDLFxsChangedEventSchema.omit({
+ oldVersion: true,
+});
+export const PDLGameChangedEventSchema_v1 = PDLGameChangedEventSchema.omit({
+ oldVersion: true,
+});
+export const PDLHourlyRawSchema_v1 = PDLHourlyRawSchema.extend({
+ changes: z.array(z.union([
+ PDLFxsChangedEventSchema_v1,
+ PDLGameChangedEventSchema_v1,
+ PDLResourcesChangedEventSchema,
+ ])),
+}).omit({
+ resKicks: true,
+});
+export const PDLFileSchema_v1 = PDLFileSchema.extend({
+ version: z.literal(1),
+ log: z.array(PDLHourlyRawSchema_v1),
+});
+export type PDLFileType_v1 = z.infer;
+
+//Used only in scripts/dev/makeOldStatsFile.ts
+export type PDLChangeEventType_V1 = (
+ z.infer
+ | z.infer
+ | PDLResourcesChangedEventType
+);
diff --git a/core/modules/Metrics/playerDrop/playerDropUtils.ts b/core/modules/Metrics/playerDrop/playerDropUtils.ts
new file mode 100644
index 0000000..97dce15
--- /dev/null
+++ b/core/modules/Metrics/playerDrop/playerDropUtils.ts
@@ -0,0 +1,17 @@
+export const getDateHourEnc = () => {
+ const now = new Date();
+ const dateHourTs = now.setUTCMinutes(0, 0, 0);
+ return {
+ dateHourTs,
+ dateHourStr: now.toISOString(),
+ }
+}
+
+export const parseDateHourEnc = (dateHourStr: string) => {
+ const date = new Date(dateHourStr);
+ const dateHourTs = date.setUTCMinutes(0, 0, 0);
+ return {
+ dateHourTs,
+ dateHourStr,
+ }
+}
diff --git a/core/modules/Metrics/statsUtils.test.ts b/core/modules/Metrics/statsUtils.test.ts
new file mode 100644
index 0000000..6fe57a1
--- /dev/null
+++ b/core/modules/Metrics/statsUtils.test.ts
@@ -0,0 +1,198 @@
+//@ts-nocheck
+import { test, expect, suite, it } from 'vitest';
+import {
+ MultipleCounter,
+ QuantileArray,
+ TimeCounter,
+ estimateArrayJsonSize,
+ isWithinMargin,
+} from './statsUtils';
+
+
+suite('MultipleCounter', () => {
+ it('should instantiate empty correctly', () => {
+ const counter = new MultipleCounter();
+ expect(counter).toBeInstanceOf(MultipleCounter);
+ expect(counter.toArray()).toEqual([]);
+ expect(counter.toJSON()).toEqual({});
+ });
+
+ it('should instantiate locked if specified', () => {
+ const lockedCounter = new MultipleCounter(undefined, true);
+ expect(() => lockedCounter.count('a')).toThrowError('is locked');
+ expect(() => lockedCounter.clear()).toThrowError('is locked');
+ });
+
+ it('should handle instantiation data error', () => {
+ expect(() => new MultipleCounter({ a: 'b' as any })).toThrowError('only integer');
+ });
+
+ const counterWithData = new MultipleCounter({ a: 1, b: 2 });
+ it('should instantiate with object correctly', () => {
+ expect(counterWithData.toArray()).toEqual([['a', 1], ['b', 2]]);
+ expect(counterWithData.toJSON()).toEqual({ a: 1, b: 2 });
+ });
+ it('should count and clear', () => {
+ counterWithData.count('a');
+ expect(counterWithData.toJSON()).toEqual({ a: 2, b: 2 });
+ counterWithData.count('b');
+ counterWithData.count('c', 5);
+ expect(counterWithData.toJSON()).toEqual({ a: 2, b: 3, c: 5 });
+ counterWithData.clear();
+ expect(counterWithData.toJSON()).toEqual({});
+ });
+
+ it('should sort the data', () => {
+ const counter = new MultipleCounter({ a: 3, z: 1, c: 2 });
+ expect(counter.toSortedKeyObject()).toEqual({ a: 3, c: 2, z: 1 });
+ expect(counter.toSortedKeyObject(true)).toEqual({ z: 1, c: 2, a: 3 });
+ expect(counter.toSortedValuesObject()).toEqual({ a: 3, c: 2, z: 1 });
+ expect(counter.toSortedValuesObject(true)).toEqual({ z: 1, c: 2, a: 3 });
+ expect(counter.toSortedKeysArray()).toEqual([['a', 3], ['c', 2], ['z', 1]]);
+ expect(counter.toSortedKeysArray(true)).toEqual([['z', 1], ['c', 2], ['a', 3]]);
+ expect(counter.toSortedValuesArray()).toEqual([['z', 1], ['c', 2], ['a', 3]]);
+ expect(counter.toSortedValuesArray(true)).toEqual([['a', 3], ['c', 2], ['z', 1]]);
+ });
+
+ suite('should handle merging counters', () => {
+ it('with another counter', () => {
+ const ogCounter = new MultipleCounter({ a: 1, b: 2 });
+ const newCounter = new MultipleCounter({ b: 3, c: 4 });
+ ogCounter.merge(newCounter);
+ expect(ogCounter.toJSON()).toEqual({ a: 1, b: 5, c: 4 });
+ });
+ it('with an array', () => {
+ const ogCounter = new MultipleCounter({ a: 1, b: 2 });
+ ogCounter.merge([['b', 3], ['c', 4]]);
+ expect(ogCounter.toJSON()).toEqual({ a: 1, b: 5, c: 4 });
+ });
+ it('with an object', () => {
+ const ogCounter = new MultipleCounter({ a: 1, b: 2 });
+ ogCounter.merge({ b: 3, c: 4 });
+ expect(ogCounter.toJSON()).toEqual({ a: 1, b: 5, c: 4 });
+ });
+ it('with invalid data', () => {
+ const ogCounter = new MultipleCounter();
+ expect(() => ogCounter.merge('a' as any)).toThrowError('Invalid data type for merge');
+ });
+ });
+});
+
+
+suite('QuantileArray', () => {
+ const array = new QuantileArray(4, 2);
+ test('min data', () => {
+ array.count(0);
+ expect(array.result()).toEqual({ enoughData: false });
+ });
+ test('zeros only', () => {
+ array.count(0);
+ array.count(0);
+ array.count(0);
+ expect(array.result()).toEqual({
+ enoughData: true,
+ count: 4,
+ p5: 0,
+ p25: 0,
+ p50: 0,
+ p75: 0,
+ p95: 0,
+ });
+ });
+ const repeatedExpectedResult = {
+ enoughData: true,
+ count: 4,
+ p5: 0,
+ p25: 0,
+ p50: 0.5,
+ p75: 1,
+ p95: 1,
+ }
+ test('calc quantile', () => {
+ array.count(1);
+ array.count(1);
+ expect(array.result()).toEqual(repeatedExpectedResult);
+ });
+ test('summary', () => {
+ expect(array.resultSummary('ms')).toEqual({
+ ...repeatedExpectedResult,
+ summary: 'p5:0ms/p25:0ms/p50:1ms/p75:1ms/p95:1ms (x4)',
+ });
+ expect(array.resultSummary()).toEqual({
+ ...repeatedExpectedResult,
+ summary: 'p5:0/p25:0/p50:1/p75:1/p95:1 (x4)',
+ });
+ });
+ test('clear', () => {
+ array.clear();
+ expect(array.result()).toEqual({ enoughData: false });
+ expect(array.resultSummary()).toEqual({
+ enoughData: false,
+ summary: 'not enough data available',
+ });
+ });
+});
+
+
+suite('TimeCounter', async () => {
+ const counter = new TimeCounter();
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ const duration = counter.stop();
+
+ // Check if the duration is a valid object
+ test('duration is valid', () => {
+ expect(duration.seconds).toBeTypeOf('number');
+ expect(duration.milliseconds).toBeTypeOf('number');
+ expect(duration.nanoseconds).toBeTypeOf('number');
+ expect(counter.toJSON()).toEqual(duration);
+ });
+
+ // Check if the duration is within the expected range
+ test('duration within range', () => {
+ const isCloseTo50ms = (x: number) => (x > 150 && x < 175);
+ expect(duration.seconds * 1000).toSatisfy(isCloseTo50ms);
+ expect(duration.milliseconds).toSatisfy(isCloseTo50ms);
+ expect(duration.nanoseconds / 1_000_000).toSatisfy(isCloseTo50ms);
+ });
+});
+
+
+suite('estimateArrayJsonSize', () => {
+ test('obeys minimas', () => {
+ const result = estimateArrayJsonSize([], 1);
+ expect(result).toEqual({ enoughData: false });
+
+ const result2 = estimateArrayJsonSize([1], 2);
+ expect(result2).toEqual({ enoughData: false });
+ });
+
+ test('calculates size correctly', () => {
+ const array = Array.from({ length: 1000 }, (_, i) => ({ id: i, value: `value${i}` }));
+ const realFullSize = JSON.stringify(array).length;
+ const realElementSize = realFullSize / array.length;
+ const result = estimateArrayJsonSize(array, 100);
+ expect(result.enoughData).toBe(true);
+ expect(result.bytesTotal).toSatisfy((x: number) => isWithinMargin(x, realFullSize, 0.1));
+ expect(result.bytesPerElement).toSatisfy((x: number) => isWithinMargin(x, realElementSize, 0.1));
+ });
+
+ test('handles small arrays', () => {
+ const array = [{ id: 1, value: 'value1' }];
+ const result = estimateArrayJsonSize(array, 0);
+ expect(result.enoughData).toBe(true);
+ expect(result.bytesTotal).toBeGreaterThan(0);
+ expect(result.bytesTotal).toBeLessThan(100);
+ expect(result.bytesPerElement).toBeGreaterThan(0);
+ expect(result.bytesTotal).toBeLessThan(100);
+ });
+
+ test('handles large arrays', () => {
+ const array = Array.from({ length: 20000 }, (_, i) => ({ id: i, value: `value${i}` }));
+ const realFullSize = JSON.stringify(array).length;
+ const realElementSize = realFullSize / array.length;
+ const result = estimateArrayJsonSize(array, 100);
+ expect(result.enoughData).toBe(true);
+ expect(result.bytesTotal).toSatisfy((x: number) => isWithinMargin(x, realFullSize, 0.1));
+ expect(result.bytesPerElement).toSatisfy((x: number) => isWithinMargin(x, realElementSize, 0.1));
+ });
+});
diff --git a/core/modules/Metrics/statsUtils.ts b/core/modules/Metrics/statsUtils.ts
new file mode 100644
index 0000000..9cc6cfd
--- /dev/null
+++ b/core/modules/Metrics/statsUtils.ts
@@ -0,0 +1,303 @@
+import { inspect } from 'node:util';
+import CircularBuffer from 'mnemonist/circular-buffer';
+import * as d3array from 'd3-array';
+
+
+/**
+ * Helper class to count different options
+ */
+export class MultipleCounter extends Map {
+ public locked: boolean;
+ private _clear: () => void;
+
+ constructor(initialData?: [string, number][] | null | Record, locked = false) {
+ let initialDataIterable: any;
+ if (initialData !== undefined && initialData !== null || typeof initialData === 'object') {
+ if (Array.isArray(initialData)) {
+ initialDataIterable = initialData;
+ } else {
+ initialDataIterable = Object.entries(initialData!);
+ if (initialDataIterable.some(([k, v]: [string, number]) => typeof k !== 'string' || typeof v !== 'number')) {
+ throw new Error(`Initial data must be an object with only integer values.`)
+ }
+ }
+ }
+ super(initialDataIterable ?? initialData);
+ this.locked = locked;
+ this._clear = super.clear;
+ }
+
+ //Clears the counter
+ clear() {
+ if (this.locked) throw new Error(`This MultipleCounter is locked to modifications.`);
+ this._clear();
+ };
+
+ //Returns the sum of all values
+ sum() {
+ return [...this.values()].reduce((a, b) => a + b, 0);
+ }
+
+ //Increments the count of a key by a value
+ count(key: string, val = 1) {
+ if (this.locked) throw new Error(`This MultipleCounter is locked to modifications.`);
+
+ const currentValue = this.get(key);
+ if (currentValue !== undefined) {
+ const newVal = currentValue + val;
+ this.set(key, newVal);
+ return newVal;
+ } else {
+ this.set(key, val);
+ return val;
+ }
+ };
+
+ //Merges another counter into this one
+ merge(newData: MultipleCounter | [string, number][] | Record) {
+ if (this.locked) throw new Error(`This MultipleCounter is locked to modifications.`);
+ let iterable;
+ if (newData instanceof MultipleCounter || Array.isArray(newData)) {
+ iterable = newData;
+ } else if (typeof newData === 'object' && newData !== null) {
+ iterable = Object.entries(newData);
+ } else {
+ throw new Error(`Invalid data type for merge`);
+ }
+ for (const [key, value] of iterable) {
+ this.count(key, value);
+ }
+ }
+
+ //Returns an array with sorted keys in asc or desc order
+ toSortedKeysArray(desc?: boolean) {
+ return [...this.entries()]
+ .sort((a, b) => desc
+ ? b[0].localeCompare(a[0])
+ : a[0].localeCompare(b[0])
+ );
+ }
+
+ // Returns an array with sorted values in asc or desc order
+ toSortedValuesArray(desc?: boolean) {
+ return [...this.entries()]
+ .sort((a, b) => desc ? b[1] - a[1] : a[1] - b[1]);
+ }
+
+ //Returns an object with sorted keys in asc or desc order
+ toSortedKeyObject(desc?: boolean) {
+ return Object.fromEntries(this.toSortedKeysArray(desc));
+ }
+
+ //Returns an object with sorted values in asc or desc order
+ toSortedValuesObject(desc?: boolean) {
+ return Object.fromEntries(this.toSortedValuesArray(desc));
+ }
+
+ toArray(): [string, number][] {
+ return [...this];
+ }
+
+ toJSON(): MultipleCounterOutput {
+ return Object.fromEntries(this);
+ }
+
+ [inspect.custom]() {
+ return this.toSortedKeyObject();
+ }
+}
+export type MultipleCounterOutput = Record;
+
+
+/**
+ * Helper calculate quantiles out of a circular buffer of numbers
+ */
+export class QuantileArray {
+ readonly #cache: CircularBuffer;
+ readonly #minSize: number;
+
+ constructor(sizeLimit: number, minSize = 1) {
+ if (typeof sizeLimit !== 'number' || !Number.isInteger(sizeLimit) || sizeLimit < 1) {
+ throw new Error(`sizeLimit must be a positive integer over 1.`);
+ }
+ if (typeof minSize !== 'number' || !Number.isInteger(minSize) || minSize < 1) {
+ throw new Error(`minSize must be a positive integer or one.`);
+ }
+ this.#cache = new CircularBuffer(Array, sizeLimit);
+ this.#minSize = minSize;
+ }
+
+ /**
+ * Clears the cached data (wipe counts).
+ */
+ clear() {
+ this.#cache.clear();
+ };
+
+ /**
+ * Adds a value to the cache.
+ */
+ count(value: number) {
+ if (typeof value !== 'number') throw new Error(`value must be a number`);
+ this.#cache.push(value);
+ };
+
+ /**
+ * Processes the cache and returns the count and quantiles, if enough data.
+ */
+ result(): QuantileArrayOutput {
+ if (this.#cache.size < this.#minSize) {
+ return {
+ enoughData: false,
+ }
+ } else {
+ 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)!,
+ };
+ }
+ }
+
+ /**
+ * Returns a human readable summary of the data.
+ */
+ resultSummary(unit = ''): QuantileArraySummary {
+ const result = this.result();
+ if (!result.enoughData) {
+ return {
+ ...result,
+ summary: 'not enough data available',
+ };
+ }
+ // const a = Object.entries(result)
+ const percentiles = (Object.entries(result) as [string, number][])
+ .filter((el): el is [string, number] => el[0].startsWith('p'))
+ .map(([key, val]) => `${key}:${Math.ceil(val)}${unit}`);
+ return {
+ ...result,
+ summary: percentiles.join('/') + ` (x${this.#cache.size})`,
+ };
+ }
+
+ toJSON() {
+ return this.result();
+ }
+
+ [inspect.custom]() {
+ return this.result();
+ }
+}
+type QuantileArrayOutput = {
+ enoughData: true;
+ count: number;
+ p5: number;
+ p25: number;
+ p50: number;
+ p75: number;
+ p95: number;
+} | {
+ enoughData: false;
+}; //if less than min size
+
+type QuantileArraySummary = QuantileArrayOutput & {
+ summary: string,
+};
+
+
+/**
+ * Helper class to count time durations and convert them to human readable values
+ */
+export class TimeCounter {
+ readonly #timeStart: bigint;
+ #timeEnd: bigint | undefined;
+ public duration: {
+ nanoseconds: number
+ milliseconds: number,
+ seconds: number,
+ } | undefined;
+
+ constructor() {
+ this.#timeStart = process.hrtime.bigint();
+ }
+
+ stop() {
+ this.#timeEnd = process.hrtime.bigint();
+ const nanoseconds = this.#timeEnd - this.#timeStart;
+ const asNumber = Number(nanoseconds);
+ this.duration = {
+ nanoseconds: asNumber,
+ seconds: asNumber / 1_000_000_000,
+ milliseconds: asNumber / 1_000_000,
+ };
+ return this.duration;
+ };
+
+ toJSON() {
+ return this.duration;
+ }
+
+ [inspect.custom]() {
+ return this.toJSON();
+ }
+}
+
+
+/**
+ * Estimates the JSON size in bytes of an array based on a simple heuristic
+ */
+export const estimateArrayJsonSize = (srcArray: any[], minLength: number): JsonEstimateResult => {
+ // Check if the buffer has enough data
+ if (srcArray.length <= minLength) {
+ return { enoughData: false };
+ }
+
+ // Determine a reasonable sample size:
+ // - At least 100 elements
+ // - Up to 10% of the buffer length
+ // - Capped at 1000 elements to limit CPU usage
+ const sourceArrayLength = srcArray.length;
+ const sampleSize = Math.min(1000, Math.max(100, Math.floor(sourceArrayLength * 0.1)));
+ const sampleArray: any[] = [];
+
+ // Randomly sample elements from the buffer
+ for (let i = 0; i < sampleSize; i++) {
+ const randomIndex = Math.floor(Math.random() * sourceArrayLength);
+ sampleArray.push(srcArray[randomIndex]);
+ }
+
+ // Serialize the sample to JSON
+ const jsonString = JSON.stringify(sampleArray);
+ const sampleSizeBytes = Buffer.byteLength(jsonString, 'utf-8'); // More accurate byte count
+
+ // Estimate the total size based on the sample
+ const estimatedTotalBytes = (sampleSizeBytes / sampleSize) * sourceArrayLength;
+ const bytesPerElement = estimatedTotalBytes / sourceArrayLength;
+
+ return {
+ enoughData: true,
+ bytesTotal: Math.round(estimatedTotalBytes),
+ bytesPerElement: Math.ceil(bytesPerElement),
+ };
+};
+
+type JsonEstimateResult = {
+ enoughData: false;
+} | {
+ enoughData: true;
+ bytesTotal: number;
+ bytesPerElement: number;
+};
+
+
+/**
+ * Checks if a value is within a fraction margin of an expected value.
+ */
+export const isWithinMargin = (value: number, expectedValue: number, marginFraction: number) => {
+ const margin = expectedValue * marginFraction;
+ return Math.abs(value - expectedValue) <= margin;
+}
diff --git a/core/modules/Metrics/svRuntime/config.ts b/core/modules/Metrics/svRuntime/config.ts
new file mode 100644
index 0000000..27ae608
--- /dev/null
+++ b/core/modules/Metrics/svRuntime/config.ts
@@ -0,0 +1,62 @@
+import { ValuesType } from "utility-types";
+
+
+/**
+ * Configs
+ */
+const minutesMs = 60 * 1000;
+const hoursMs = 60 * minutesMs;
+export const PERF_DATA_BUCKET_COUNT = 15;
+export const PERF_DATA_MIN_TICKS = 600; //less than that and the data is not reliable - 30s for svMain
+export const PERF_DATA_INITIAL_RESOLUTION = 5 * minutesMs;
+export const STATS_RESOLUTION_TABLE = [
+ //00~12h = 5min = 12/h = 144 snaps
+ //12~24h = 15min = 4/h = 48 snaps
+ //24~96h = 30min = 2/h = 144 snaps
+ { maxAge: 12 * hoursMs, resolution: PERF_DATA_INITIAL_RESOLUTION },
+ { maxAge: 24 * hoursMs, resolution: 15 * minutesMs },
+ { maxAge: 96 * hoursMs, resolution: 30 * minutesMs },
+];
+export const STATS_LOG_SIZE_LIMIT = 720; //144+48+144 (max data snaps) + 384 (1 reboot every 30 mins)
+export const PERF_DATA_THREAD_NAMES = ['svNetwork', 'svSync', 'svMain'] as const;
+export type SvRtPerfThreadNamesType = ValuesType;
+
+
+// // @ts-ignore Typescript Pseudocode:
+
+// type SnapType = {
+// dateStart: Date;
+// dateEnd: Date;
+// value: number;
+// }
+
+// const snapshots: SnapType[] = [/*data*/];
+// const fixedDesiredResolution = 15 * 60 * 1000; // 15 minutes in milliseconds
+// const processedSnapshots: SnapType[] = [];
+// let pendingSnapshots: SnapType[] = [];
+// for (const snap of snapshots) {
+// if (pendingSnapshots.length === 0) {
+// pendingSnapshots.push(snap);
+// continue;
+// }
+
+// const pendingStart = pendingSnapshots[0].dateStart;
+// const currSnapEnd = snap.dateEnd;
+// const totalDuration = currSnapEnd.getTime() - pendingStart.getTime();
+// if (totalDuration <= fixedDesiredResolution) {
+// pendingSnapshots.push(snap);
+// } else {
+// const sumValue = pendingSnapshots.reduce((acc, curr) => {
+// const snapDuration = curr.dateEnd.getTime() - curr.dateStart.getTime();
+// return acc + curr.value * snapDuration;
+// }, 0);
+// processedSnapshots.push({
+// dateStart: pendingStart,
+// dateEnd: currSnapEnd,
+// value: sumValue / totalDuration,
+// });
+// pendingSnapshots = [];
+// }
+// }
+
+// //processedSnapshots contains the snapshots with the fixed resolution
diff --git a/core/modules/Metrics/svRuntime/index.ts b/core/modules/Metrics/svRuntime/index.ts
new file mode 100644
index 0000000..952957f
--- /dev/null
+++ b/core/modules/Metrics/svRuntime/index.ts
@@ -0,0 +1,390 @@
+const modulename = 'SvRuntimeMetrics';
+import fsp from 'node:fs/promises';
+import * as d3array from 'd3-array';
+import consoleFactory from '@lib/console';
+import { SvRtFileSchema, isSvRtLogDataType, isValidPerfThreadName, SvRtNodeMemorySchema } from './perfSchemas';
+import type { SvRtFileType, SvRtLogDataType, SvRtLogType, SvRtNodeMemoryType, SvRtPerfBoundariesType, SvRtPerfCountsType } from './perfSchemas';
+import { didPerfReset, diffPerfs, fetchFxsMemory, fetchRawPerfData } from './perfUtils';
+import { optimizeSvRuntimeLog } from './logOptimizer';
+import { txDevEnv, txEnv } from '@core/globalData';
+import { ZodError } from 'zod';
+import { PERF_DATA_BUCKET_COUNT, PERF_DATA_INITIAL_RESOLUTION, PERF_DATA_MIN_TICKS } from './config';
+import { PerfChartApiResp } from '@routes/perfChart';
+import got from '@lib/got';
+import { throttle } from 'throttle-debounce';
+import { TimeCounter } from '../statsUtils';
+import { FxMonitorHealth } from '@shared/enums';
+const console = consoleFactory(modulename);
+
+
+//Consts
+const LOG_DATA_FILE_VERSION = 1;
+const LOG_DATA_FILE_NAME = 'stats_svRuntime.json';
+
+
+/**
+ * This module is reponsiple to collect many statistics from the server runtime
+ * Most of those will be displayed on the Dashboard.
+ */
+export default class SvRuntimeMetrics {
+ private readonly logFilePath = `${txEnv.profilePath}/data/${LOG_DATA_FILE_NAME}`;
+ private statsLog: SvRtLogType = [];
+ private lastFxsMemory: number | undefined;
+ private lastNodeMemory: SvRtNodeMemoryType | undefined;
+ private lastPerfBoundaries: SvRtPerfBoundariesType | undefined;
+ private lastRawPerfData: SvRtPerfCountsType | undefined;
+ private lastDiffPerfData: SvRtPerfCountsType | undefined;
+ private lastRawPerfSaved: {
+ ts: number,
+ data: SvRtPerfCountsType,
+ } | undefined;
+ private queueSaveStatsHistory = throttle(
+ 15_000,
+ this.saveStatsHistory.bind(this),
+ { noLeading: true }
+ );
+
+ constructor() {
+ setImmediate(() => {
+ this.loadStatsHistory();
+ });
+
+ //Cron functions
+ setInterval(() => {
+ this.collectStats().catch((error) => {
+ console.verbose.warn('Error while collecting server stats.');
+ console.verbose.dir(error);
+ });
+ }, 60 * 1000);
+ }
+
+
+ /**
+ * Reset the last perf data except boundaries
+ */
+ private resetPerfState() {
+ this.lastRawPerfData = undefined;
+ this.lastDiffPerfData = undefined;
+ this.lastRawPerfSaved = undefined;
+ }
+
+
+ /**
+ * Reset the last perf data except boundaries
+ */
+ private resetMemoryState() {
+ this.lastNodeMemory = undefined;
+ this.lastFxsMemory = undefined;
+ }
+
+
+ /**
+ * Registers that fxserver has BOOTED (FxMonitor is ONLINE)
+ */
+ public logServerBoot(duration: number) {
+ this.resetPerfState();
+ this.resetMemoryState();
+ txCore.webServer.webSocket.pushRefresh('dashboard');
+
+ //If last log is a boot, remove it as the server didn't really start
+ // otherwise it would have lived long enough to have stats logged
+ if (this.statsLog.length && this.statsLog.at(-1)!.type === 'svBoot') {
+ this.statsLog.pop();
+ }
+ this.statsLog.push({
+ ts: Date.now(),
+ type: 'svBoot',
+ duration,
+ });
+ this.queueSaveStatsHistory();
+ }
+
+
+ /**
+ * Registers that fxserver has CLOSED (fxRunner killing the process)
+ */
+ public logServerClose(reason: string) {
+ this.resetPerfState();
+ this.resetMemoryState();
+ txCore.webServer.webSocket.pushRefresh('dashboard');
+
+ if (this.statsLog.length) {
+ if (this.statsLog.at(-1)!.type === 'svClose') {
+ //If last log is a close, skip saving a new one
+ return;
+ } else if (this.statsLog.at(-1)!.type === 'svBoot') {
+ //If last log is a boot, remove it as the server didn't really start
+ this.statsLog.pop();
+ return;
+ }
+ }
+ this.statsLog.push({
+ ts: Date.now(),
+ type: 'svClose',
+ reason,
+ });
+ this.queueSaveStatsHistory();
+ }
+
+
+ /**
+ * Stores the last server Node.JS memory usage for later use in the data log
+ */
+ public logServerNodeMemory(payload: SvRtNodeMemoryType) {
+ const validation = SvRtNodeMemorySchema.safeParse(payload);
+ if (!validation.success) {
+ console.verbose.warn('Invalid LogNodeHeapEvent payload:');
+ console.verbose.dir(validation.error.errors);
+ return;
+ }
+ this.lastNodeMemory = {
+ used: payload.used,
+ limit: payload.limit,
+ };
+ txCore.webServer.webSocket.pushRefresh('dashboard');
+ }
+
+
+ /**
+ * Get recent stats
+ */
+ public getRecentStats() {
+ return {
+ fxsMemory: this.lastFxsMemory,
+ nodeMemory: this.lastNodeMemory,
+ perfBoundaries: this.lastPerfBoundaries,
+ perfBucketCounts: this.lastDiffPerfData ? {
+ svMain: this.lastDiffPerfData.svMain.buckets,
+ svNetwork: this.lastDiffPerfData.svNetwork.buckets,
+ svSync: this.lastDiffPerfData.svSync.buckets,
+ } : undefined,
+ }
+ }
+
+
+ /**
+ * Cron function to collect all the stats and save it to the cache file
+ */
+ private async collectStats() {
+ //Precondition checks
+ const monitorStatus = txCore.fxMonitor.status;
+ if (monitorStatus.health === FxMonitorHealth.OFFLINE) return; //collect even if partial
+ if (monitorStatus.uptime < 30_000) return; //server barely booted
+ if (!txCore.fxRunner.child?.isAlive) return;
+
+ //Get performance data
+ const netEndpoint = txDevEnv.EXT_STATS_HOST ?? txCore.fxRunner.child.netEndpoint;
+ if (!netEndpoint) throw new Error(`Invalid netEndpoint: ${netEndpoint}`);
+
+ const stopwatch = new TimeCounter();
+ const [fetchRawPerfDataRes, fetchFxsMemoryRes] = await Promise.allSettled([
+ fetchRawPerfData(netEndpoint),
+ fetchFxsMemory(txCore.fxRunner.child.pid),
+ ]);
+ const collectionTime = stopwatch.stop();
+
+ if (fetchFxsMemoryRes.status === 'fulfilled') {
+ this.lastFxsMemory = fetchFxsMemoryRes.value;
+ } else {
+ this.lastFxsMemory = undefined;
+ }
+ if (fetchRawPerfDataRes.status === 'rejected') throw fetchRawPerfDataRes.reason;
+
+ const { perfBoundaries, perfMetrics } = fetchRawPerfDataRes.value;
+ txCore.metrics.txRuntime.perfCollectionTime.count(collectionTime.milliseconds);
+
+ //Check for min tick count
+ if (
+ perfMetrics.svMain.count < PERF_DATA_MIN_TICKS ||
+ perfMetrics.svNetwork.count < PERF_DATA_MIN_TICKS ||
+ perfMetrics.svSync.count < PERF_DATA_MIN_TICKS
+ ) {
+ console.verbose.warn('Not enough ticks to log. Skipping this collection.');
+ return;
+ }
+
+ //Check if first collection, boundaries changed
+ if (!this.lastPerfBoundaries) {
+ console.verbose.debug('First perf collection.');
+ this.lastPerfBoundaries = perfBoundaries;
+ this.resetPerfState();
+ } else if (JSON.stringify(perfBoundaries) !== JSON.stringify(this.lastPerfBoundaries)) {
+ console.warn('Performance boundaries changed. Resetting history.');
+ this.statsLog = [];
+ this.lastPerfBoundaries = perfBoundaries;
+ this.resetPerfState();
+ }
+
+ //Checking if the counter (somehow) reset
+ if (this.lastRawPerfData && didPerfReset(perfMetrics, this.lastRawPerfData)) {
+ console.warn('Performance counter reset. Resetting lastPerfCounts/lastPerfSaved.');
+ this.resetPerfState();
+ } else if (this.lastRawPerfSaved && didPerfReset(perfMetrics, this.lastRawPerfSaved.data)) {
+ console.warn('Performance counter reset. Resetting lastPerfSaved.');
+ this.lastRawPerfSaved = undefined;
+ }
+
+ //Calculate the tick/time counts since last collection (1m ago)
+ this.lastDiffPerfData = diffPerfs(perfMetrics, this.lastRawPerfData);
+ this.lastRawPerfData = perfMetrics;
+
+ //Push the updated data to the dashboard ws room
+ txCore.webServer.webSocket.pushRefresh('dashboard');
+
+ //Check if enough time passed since last collection
+ const now = Date.now();
+ let perfToSave;
+ if (!this.lastRawPerfSaved) {
+ perfToSave = this.lastDiffPerfData;
+ } else if (now - this.lastRawPerfSaved.ts >= PERF_DATA_INITIAL_RESOLUTION) {
+ perfToSave = diffPerfs(perfMetrics, this.lastRawPerfSaved.data);
+ }
+ if (!perfToSave) return;
+
+ //Get player count locally or from external source
+ let playerCount = txCore.fxPlayerlist.onlineCount;
+ if (txDevEnv.EXT_STATS_HOST) {
+ try {
+ const playerCountResp = await got(`http://${netEndpoint}/players.json`).json();
+ playerCount = playerCountResp.length;
+ } catch (error) { }
+ }
+
+ //Update cache
+ this.lastRawPerfSaved = {
+ ts: now,
+ data: perfMetrics,
+ };
+ const currSnapshot: SvRtLogDataType = {
+ ts: now,
+ type: 'data',
+ players: playerCount,
+ fxsMemory: this.lastFxsMemory ?? null,
+ nodeMemory: this.lastNodeMemory?.used ?? null,
+ perf: perfToSave,
+ };
+ this.statsLog.push(currSnapshot);
+ // console.verbose.ok(`Collected performance snapshot #${this.statsLog.length}`);
+
+ //Save perf series do file - not queued because it's priority
+ this.queueSaveStatsHistory.cancel({ upcomingOnly: true });
+ this.saveStatsHistory();
+ }
+
+
+ /**
+ * Loads the stats database/cache/history
+ */
+ private async loadStatsHistory() {
+ try {
+ const rawFileData = await fsp.readFile(this.logFilePath, 'utf8');
+ const fileData = JSON.parse(rawFileData);
+ if (fileData?.version !== LOG_DATA_FILE_VERSION) throw new Error('invalid version');
+ const statsData = SvRtFileSchema.parse(fileData);
+ this.lastPerfBoundaries = statsData.lastPerfBoundaries;
+ this.statsLog = statsData.log;
+ this.resetPerfState();
+ console.verbose.ok(`Loaded ${this.statsLog.length} performance snapshots from cache`);
+ await optimizeSvRuntimeLog(this.statsLog);
+ } catch (error) {
+ if ((error as any)?.code === 'ENOENT') {
+ console.verbose.debug(`${LOG_DATA_FILE_NAME} not found, starting with empty stats.`);
+ return;
+ }
+ if (error instanceof ZodError) {
+ console.warn(`Failed to load ${LOG_DATA_FILE_NAME} due to invalid data.`);
+ } else {
+ console.warn(`Failed to load ${LOG_DATA_FILE_NAME} with message: ${(error as Error).message}`);
+ }
+ console.warn('Since this is not a critical file, it will be reset.');
+ }
+ }
+
+
+ /**
+ * Saves the stats database/cache/history
+ */
+ private async saveStatsHistory() {
+ try {
+ await optimizeSvRuntimeLog(this.statsLog);
+ const savePerfData: SvRtFileType = {
+ version: LOG_DATA_FILE_VERSION,
+ lastPerfBoundaries: this.lastPerfBoundaries,
+ log: this.statsLog,
+ };
+ await fsp.writeFile(this.logFilePath, JSON.stringify(savePerfData));
+ } catch (error) {
+ console.warn(`Failed to save ${LOG_DATA_FILE_NAME} with message: ${(error as Error).message}`);
+ }
+ }
+
+
+ /**
+ * Returns the data for charting the performance of a specific thread
+ */
+ public getChartData(threadName: string): PerfChartApiResp {
+ if (!isValidPerfThreadName(threadName)) return { fail_reason: 'invalid_thread_name' };
+ if (!this.statsLog.length || !this.lastPerfBoundaries?.length) return { fail_reason: 'data_unavailable' };
+
+ //Processing data
+ return {
+ boundaries: this.lastPerfBoundaries,
+ threadPerfLog: this.statsLog.map((log) => {
+ if (!isSvRtLogDataType(log)) return log;
+ return {
+ ...log,
+ perf: log.perf[threadName],
+ };
+ })
+ }
+ }
+
+
+ /**
+ * Returns a summary of the collected data and returns.
+ * NOTE: kinda expensive
+ */
+ public getServerPerfSummary() {
+ //Configs
+ const minSnapshots = 36; //3h of data
+ const tsScanWindowStart = Date.now() - 6 * 60 * 60 * 1000; //6h ago
+
+ //that's short for cumulative buckets, if you thought otherwise, i'm judging you
+ const cumBuckets = Array(PERF_DATA_BUCKET_COUNT).fill(0);
+ let cumTicks = 0;
+
+ //Processing each snapshot - then each bucket
+ let totalSnapshots = 0;
+ const players = [];
+ const fxsMemory = [];
+ const nodeMemory = []
+ for (const log of this.statsLog) {
+ if (log.ts < tsScanWindowStart) continue;
+ if (!isSvRtLogDataType(log)) continue;
+ if (log.perf.svMain.count < PERF_DATA_MIN_TICKS) continue;
+ totalSnapshots++
+ players.push(log.players);
+ fxsMemory.push(log.fxsMemory);
+ nodeMemory.push(log.nodeMemory);
+ for (let bIndex = 0; bIndex < PERF_DATA_BUCKET_COUNT; bIndex++) {
+ const tickCount = log.perf.svMain.buckets[bIndex];
+ cumTicks += tickCount;
+ cumBuckets[bIndex] += tickCount;
+ }
+ }
+
+ //Checking if at least 12h of data
+ if (totalSnapshots < minSnapshots) {
+ return null; //not enough data for meaningful analysis
+ }
+
+ //Formatting Output
+ return {
+ snaps: totalSnapshots,
+ freqs: cumBuckets.map(cumAvg => cumAvg / cumTicks),
+ players: d3array.median(players),
+ fxsMemory: d3array.median(fxsMemory),
+ nodeMemory: d3array.median(nodeMemory),
+ };
+ }
+};
diff --git a/core/modules/Metrics/svRuntime/logOptimizer.ts b/core/modules/Metrics/svRuntime/logOptimizer.ts
new file mode 100644
index 0000000..4ccbdc6
--- /dev/null
+++ b/core/modules/Metrics/svRuntime/logOptimizer.ts
@@ -0,0 +1,22 @@
+import { STATS_LOG_SIZE_LIMIT, STATS_RESOLUTION_TABLE } from "./config";
+import type { SvRtLogType } from "./perfSchemas";
+
+//Consts
+const YIELD_INTERVAL = 100;
+
+
+/**
+ * Optimizes (in place) the stats log by removing old data and combining snaps to match the resolution
+ */
+export const optimizeSvRuntimeLog = async (statsLog: SvRtLogType) => {
+ statsLog.splice(0, statsLog.length - STATS_LOG_SIZE_LIMIT);
+ for (let i = 0; i < statsLog.length; i++) {
+ //FIXME: write code
+ //FIXME: somehow prevent recombining the 0~12h snaps
+
+ // Yield every 100 iterations
+ if (i % YIELD_INTERVAL === 0) {
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+ }
+}
diff --git a/core/modules/Metrics/svRuntime/perfParser.test.ts b/core/modules/Metrics/svRuntime/perfParser.test.ts
new file mode 100644
index 0000000..7cb124d
--- /dev/null
+++ b/core/modules/Metrics/svRuntime/perfParser.test.ts
@@ -0,0 +1,127 @@
+import { test, expect, it, suite } from 'vitest';
+import { arePerfBoundariesValid, parseRawPerf, revertCumulativeBuckets } from './perfParser';
+
+
+test('arePerfBoundariesValid', () => {
+ const fnc = arePerfBoundariesValid;
+ expect(fnc([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, '+Inf'])).toBe(true);
+ expect(fnc([])).toBe(false); //length
+ expect(fnc([1, 2, 3])).toBe(false); //length
+ expect(fnc([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])).toBe(false); //last item
+ expect(fnc([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 'xx', 12, 13, 14, '+Inf'])).toBe(false); //always number, except last
+ expect(fnc([1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 11, 12, 13, 14, '+Inf'])).toBe(false); //always increasing
+ expect(fnc([0.1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 11, 12, 999, 14, '+Inf'])).toBe(false); //always increasing
+});
+
+
+const perfValidExample = `# HELP tickTime Time spent on server ticks
+# TYPE tickTime histogram
+tickTime_count{name="svNetwork"} 1840805
+tickTime_sum{name="svNetwork"} 76.39499999999963
+tickTime_bucket{name="svNetwork",le="0.005"} 1840798
+tickTime_bucket{name="svNetwork",le="0.01"} 1840804
+tickTime_bucket{name="svNetwork",le="0.025"} 1840805
+tickTime_bucket{name="svNetwork",le="0.05"} 1840805
+tickTime_bucket{name="svNetwork",le="0.075"} 1840805
+tickTime_bucket{name="svNetwork",le="0.1"} 1840805
+tickTime_bucket{name="svNetwork",le="0.25"} 1840805
+tickTime_bucket{name="svNetwork",le="0.5"} 1840805
+tickTime_bucket{name="svNetwork",le="0.75"} 1840805
+tickTime_bucket{name="svNetwork",le="1"} 1840805
+tickTime_bucket{name="svNetwork",le="2.5"} 1840805
+tickTime_bucket{name="svNetwork",le="5"} 1840805
+tickTime_bucket{name="svNetwork",le="7.5"} 1840805
+tickTime_bucket{name="svNetwork",le="10"} 1840805
+tickTime_bucket{name="svNetwork",le="+Inf"} 1840805
+tickTime_count{name="svSync"} 2268704
+tickTime_sum{name="svSync"} 1091.617999988212
+tickTime_bucket{name="svSync",le="0.005"} 2267516
+tickTime_bucket{name="svSync",le="0.01"} 2268532
+tickTime_bucket{name="svSync",le="0.025"} 2268664
+tickTime_bucket{name="svSync",le="0.05"} 2268685
+tickTime_bucket{name="svSync",le="0.075"} 2268686
+tickTime_bucket{name="svSync",le="0.1"} 2268688
+tickTime_bucket{name="svSync",le="0.25"} 2268703
+tickTime_bucket{name="svSync",le="0.5"} 2268704
+tickTime_bucket{name="svSync",le="0.75"} 2268704
+tickTime_bucket{name="svSync",le="1"} 2268704
+tickTime_bucket{name="svSync",le="2.5"} 2268704
+tickTime_bucket{name="svSync",le="5"} 2268704
+tickTime_bucket{name="svSync",le="7.5"} 2268704
+tickTime_bucket{name="svSync",le="10"} 2268704
+tickTime_bucket{name="svSync",le="+Inf"} 2268704
+tickTime_count{name="svMain"} 355594
+tickTime_sum{name="svMain"} 1330.458999996208
+tickTime_bucket{name="svMain",le="0.005"} 299261
+tickTime_bucket{name="svMain",le="0.01"} 327819
+tickTime_bucket{name="svMain",le="0.025"} 352052
+tickTime_bucket{name="svMain",le="0.05"} 354360
+tickTime_bucket{name="svMain",le="0.075"} 354808
+tickTime_bucket{name="svMain",le="0.1"} 355262
+tickTime_bucket{name="svMain",le="0.25"} 355577
+tickTime_bucket{name="svMain",le="0.5"} 355591
+tickTime_bucket{name="svMain",le="0.75"} 355591
+tickTime_bucket{name="svMain",le="1"} 355592
+tickTime_bucket{name="svMain",le="2.5"} 355593
+tickTime_bucket{name="svMain",le="5"} 355593
+tickTime_bucket{name="svMain",le="7.5"} 355593
+tickTime_bucket{name="svMain",le="10"} 355593
+tickTime_bucket{name="svMain",le="+Inf"} 355594`;
+
+suite('parseRawPerf', () => {
+ it('should parse the perf data correctly', () => {
+ const result = parseRawPerf(perfValidExample);
+ expect(result.perfBoundaries).toEqual([0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, '+Inf']);
+ expect(result.perfMetrics.svNetwork.count).toBe(1840805);
+ expect(result.perfMetrics.svSync.count).toBe(2268704);
+ expect(result.perfMetrics.svMain.count).toBe(355594);
+ expect(result.perfMetrics.svSync.sum).toBe(1091.617999988212);
+ expect(result.perfMetrics.svMain.buckets).toEqual([299261, 28558, 24233, 2308, 448, 454, 315, 14, 0, 1, 1, 0, 0, 0, 1]);
+ });
+
+ it('should detect bad perf output', () => {
+ expect(() => parseRawPerf(null as any)).toThrow('string expected');
+ expect(() => parseRawPerf('bad data')).toThrow('missing tickTime_');
+ });
+
+ it('should detect server still booting', () => {
+ const perfNoMain = perfValidExample.replaceAll('svMain', 'idk');
+ expect(() => parseRawPerf(perfNoMain)).toThrow('missing threads');
+ });
+
+ it('should handle bad data', () => {
+ expect(() => parseRawPerf(123 as any)).toThrow('string expected');
+
+ let targetLine = 'tickTime_bucket{name="svMain",le="10"} 355593';
+ let perfModifiedExample = perfValidExample.replace(targetLine, '');
+ expect(() => parseRawPerf(perfModifiedExample)).toThrow('invalid bucket boundaries');
+
+ targetLine = 'tickTime_bucket{name="svNetwork",le="+Inf"} 1840805';
+ perfModifiedExample = perfValidExample.replace(targetLine, '');
+ expect(() => parseRawPerf(perfModifiedExample)).toThrow('invalid threads');
+
+ targetLine = 'tickTime_count{name="svNetwork"} 1840805';
+ perfModifiedExample = perfValidExample.replace(targetLine, 'tickTime_count{name="svNetwork"} ????');
+ expect(() => parseRawPerf(perfModifiedExample)).toThrow('invalid threads');
+ });
+});
+
+
+suite('revertCumulativeBuckets', () => {
+ it('should convert the simplest case', () => {
+ const result = revertCumulativeBuckets([10, 20, 30]);
+ expect(result).toEqual([10, 10, 10]);
+ });
+
+ it('should convert a real case correctly', () => {
+ const result = revertCumulativeBuckets([299261, 327819, 352052, 354360, 354808, 355262, 355577, 355591, 355591, 355592, 355593, 355593, 355593, 355593, 355594]);
+ expect(result).toEqual([299261, 28558, 24233, 2308, 448, 454, 315, 14, 0, 1, 1, 0, 0, 0, 1]);
+
+ });
+
+ it('should return same length', () => {
+ expect(revertCumulativeBuckets([]).length).toBe(0);
+ expect(revertCumulativeBuckets([1, 2, 3, 4, 5]).length).toBe(5);
+ expect(revertCumulativeBuckets(Array(9999).fill(0)).length).toBe(9999);
+ });
+});
diff --git a/core/modules/Metrics/svRuntime/perfParser.ts b/core/modules/Metrics/svRuntime/perfParser.ts
new file mode 100644
index 0000000..6db983c
--- /dev/null
+++ b/core/modules/Metrics/svRuntime/perfParser.ts
@@ -0,0 +1,166 @@
+import { PERF_DATA_BUCKET_COUNT } from "./config";
+import { isValidPerfThreadName, type SvRtPerfBoundariesType, type SvRtPerfCountsType } from "./perfSchemas";
+
+
+//Consts
+const REGEX_BUCKET_BOUNDARIE = /le="(\d+(\.\d+)?|\+Inf)"/;
+const REGEX_PERF_LINE = /tickTime_(count|sum|bucket)\{name="(svSync|svNetwork|svMain)"(,le="(\d+(\.\d+)?|\+Inf)")?\}\s(\S+)/;
+
+
+/**
+ * Returns if the given thread name is a valid SvRtPerfThreadNamesType
+ */
+export const arePerfBoundariesValid = (boundaries: (number | string)[]): boundaries is SvRtPerfBoundariesType => {
+ // Check if the length is correct
+ if (boundaries.length !== PERF_DATA_BUCKET_COUNT) {
+ return false;
+ }
+
+ // Check if the last item is +Inf
+ if (boundaries[boundaries.length - 1] !== '+Inf') {
+ return false;
+ }
+
+ //Check any value is non-numeric except the last one
+ if (boundaries.slice(0, -1).some((val) => typeof val === 'string')) {
+ return false;
+ }
+
+ // Check if the values only increase
+ for (let i = 1; i < boundaries.length - 1; i++) {
+ if (boundaries[i] <= boundaries[i - 1]) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+
+/**
+ * Returns a buckets array with individual counts instead of cumulative counts
+ */
+export const revertCumulativeBuckets = (cumulativeCounts: number[]): number[] => {
+ const individualCounts = [];
+ for (let i = 0; i < cumulativeCounts.length; i++) {
+ const currCount = cumulativeCounts[i];
+ if (typeof currCount !== 'number') throw new Error('number expected');
+ if (!Number.isInteger(currCount)) throw new Error('integer expected');
+ if (!Number.isFinite(currCount)) throw new Error('finite number expected');
+ if (i === 0) {
+ individualCounts.push(currCount);
+ } else {
+ const lastCount = cumulativeCounts[i - 1] as number;
+ if (lastCount > currCount) throw new Error('retrograde cumulative count');
+ individualCounts.push(currCount - lastCount);
+ }
+ }
+ return individualCounts;
+};
+
+
+/**
+ * Parses the output of FXServer /perf/ in the proteus format
+ */
+export const parseRawPerf = (rawData: string) => {
+ if (typeof rawData !== 'string') throw new Error('string expected');
+ const lines = rawData.trim().split('\n');
+ const perfMetrics: SvRtPerfCountsType = {
+ svSync: {
+ count: 0,
+ sum: 0,
+ buckets: [],
+ },
+ svNetwork: {
+ count: 0,
+ sum: 0,
+ buckets: [],
+ },
+ svMain: {
+ count: 0,
+ sum: 0,
+ buckets: [],
+ },
+ };
+
+ //Checking basic integrity
+ if(!rawData.includes('tickTime_')){
+ throw new Error('missing tickTime_ in /perf/');
+ }
+ if (!rawData.includes('svMain') || !rawData.includes('svNetwork') || !rawData.includes('svSync')) {
+ throw new Error('missing threads in /perf/');
+ }
+
+ //Extract bucket boundaries
+ const perfBoundaries = lines
+ .filter((line) => line.startsWith('tickTime_bucket{name="svMain"'))
+ .map((line) => {
+ const parsed = line.match(REGEX_BUCKET_BOUNDARIE);
+ if (parsed === null) {
+ return undefined;
+ } else if (parsed[1] === '+Inf') {
+ return '+Inf';
+ } else {
+ return parseFloat(parsed[1]);
+ };
+ })
+ .filter((val): val is number | '+Inf' => {
+ return val !== undefined && (val === '+Inf' || isFinite(val))
+ }) as SvRtPerfBoundariesType; //it's alright, will check later
+ if (!arePerfBoundariesValid(perfBoundaries)) {
+ throw new Error('invalid bucket boundaries');
+ }
+
+ //Parse lines
+ for (const line of lines) {
+ const parsed = line.match(REGEX_PERF_LINE);
+ if (parsed === null) continue;
+ const regType = parsed[1];
+ const thread = parsed[2];
+ const bucket = parsed[4];
+ const value = parsed[6];
+ if (!isValidPerfThreadName(thread)) continue;
+
+ if (regType == 'count') {
+ const count = parseInt(value);
+ if (!isNaN(count)) perfMetrics[thread].count = count;
+ } else if (regType == 'sum') {
+ const sum = parseFloat(value);
+ if (!isNaN(sum)) perfMetrics[thread].sum = sum;
+ } else if (regType == 'bucket') {
+ //Check if the bucket is correct
+ const currBucketIndex = perfMetrics[thread].buckets.length;
+ const lastBucketIndex = PERF_DATA_BUCKET_COUNT - 1;
+ if (currBucketIndex === lastBucketIndex) {
+ if (bucket !== '+Inf') {
+ throw new Error(`unexpected last bucket to be +Inf and got ${bucket}`);
+ }
+ } else if (parseFloat(bucket) !== perfBoundaries[currBucketIndex]) {
+ throw new Error(`unexpected bucket ${bucket} at position ${currBucketIndex}`);
+ }
+ //Add the bucket
+ perfMetrics[thread].buckets.push(parseInt(value));
+ }
+ }
+
+ //Check perf validity
+ const invalid = Object.values(perfMetrics).filter((thread) => {
+ return (
+ !Number.isInteger(thread.count)
+ || thread.count === 0
+ || !Number.isFinite(thread.sum)
+ || thread.sum === 0
+ || thread.buckets.length !== PERF_DATA_BUCKET_COUNT
+ );
+ });
+ if (invalid.length) {
+ throw new Error(`${invalid.length} invalid threads in /perf/`);
+ }
+
+ //Reverse the cumulative buckets
+ perfMetrics.svSync.buckets = revertCumulativeBuckets(perfMetrics.svSync.buckets);
+ perfMetrics.svNetwork.buckets = revertCumulativeBuckets(perfMetrics.svNetwork.buckets);
+ perfMetrics.svMain.buckets = revertCumulativeBuckets(perfMetrics.svMain.buckets);
+
+ return { perfBoundaries, perfMetrics };
+};
diff --git a/core/modules/Metrics/svRuntime/perfSchemas.ts b/core/modules/Metrics/svRuntime/perfSchemas.ts
new file mode 100644
index 0000000..75dfece
--- /dev/null
+++ b/core/modules/Metrics/svRuntime/perfSchemas.ts
@@ -0,0 +1,94 @@
+import * as z from 'zod';
+import { PERF_DATA_BUCKET_COUNT, PERF_DATA_THREAD_NAMES, SvRtPerfThreadNamesType } from './config';
+import { ValuesType } from 'utility-types';
+
+
+/**
+ * Type guards
+ */
+export const isValidPerfThreadName = (threadName: string): threadName is SvRtPerfThreadNamesType => {
+ return PERF_DATA_THREAD_NAMES.includes(threadName as SvRtPerfThreadNamesType);
+}
+export const isSvRtLogDataType = (log: ValuesType): log is SvRtLogDataType => {
+ return log.type === 'data';
+}
+
+
+/**
+ * Schemas
+ */
+//Generic schemas
+const zIntNonNegative = z.number().int().nonnegative();
+const zNumberNonNegative = z.number().nonnegative();
+
+//Last perf stuff
+export const SvRtPerfBoundariesSchema = z.array(z.union([
+ zNumberNonNegative,
+ z.literal('+Inf'),
+]));
+
+export const SvRtPerfCountsThreadSchema = z.object({
+ count: zIntNonNegative,
+ buckets: z.array(zIntNonNegative).length(PERF_DATA_BUCKET_COUNT),
+
+ //NOTE: the sum is literally used for nothing,
+ //could be used to calculate the tick time average though
+ sum: zNumberNonNegative,
+});
+
+export const SvRtPerfCountsSchema = z.object({
+ svSync: SvRtPerfCountsThreadSchema,
+ svNetwork: SvRtPerfCountsThreadSchema,
+ svMain: SvRtPerfCountsThreadSchema,
+});
+
+//Log stuff
+export const SvRtLogDataSchema = z.object({
+ ts: zIntNonNegative,
+ type: z.literal('data'),
+ players: zIntNonNegative,
+ fxsMemory: zNumberNonNegative.nullable(),
+ nodeMemory: zNumberNonNegative.nullable(),
+ perf: SvRtPerfCountsSchema,
+});
+
+export const SvRtLogSvBootSchema = z.object({
+ ts: zIntNonNegative,
+ type: z.literal('svBoot'),
+ duration: zIntNonNegative,
+});
+
+export const SvRtLogSvCloseSchema = z.object({
+ ts: zIntNonNegative,
+ type: z.literal('svClose'),
+ reason: z.string(),
+});
+
+export const SvRtFileSchema = z.object({
+ version: z.literal(1),
+ lastPerfBoundaries: SvRtPerfBoundariesSchema.optional(),
+ log: z.array(z.union([SvRtLogDataSchema, SvRtLogSvBootSchema, SvRtLogSvCloseSchema])),
+});
+
+export const SvRtNodeMemorySchema = z.object({
+ //NOTE: technically it also has a type string, but we don't need to check it
+ used: zNumberNonNegative,
+ limit: zNumberNonNegative,
+});
+
+
+//Exporting types
+export type SvRtFileType = z.infer;
+export type SvRtLogSvCloseType = z.infer;
+export type SvRtLogSvBootType = z.infer;
+export type SvRtLogDataType = z.infer;
+export type SvRtLogType = (SvRtLogSvCloseType | SvRtLogSvBootType | SvRtLogDataType)[];
+export type SvRtPerfCountsType = z.infer;
+export type SvRtPerfBoundariesType = z.infer;
+export type SvRtNodeMemoryType = z.infer;
+
+export type SvRtPerfCountsThreadType = z.infer;
+export type SvRtLogDataFilteredType = Omit & {
+ perf: SvRtPerfCountsThreadType
+};
+export type SvRtLogFilteredType = (SvRtLogSvCloseType | SvRtLogSvBootType | SvRtLogDataFilteredType)[];
diff --git a/core/modules/Metrics/svRuntime/perfUtils.test.ts b/core/modules/Metrics/svRuntime/perfUtils.test.ts
new file mode 100644
index 0000000..d9969ae
--- /dev/null
+++ b/core/modules/Metrics/svRuntime/perfUtils.test.ts
@@ -0,0 +1,100 @@
+import { expect, it, suite } from 'vitest';
+import { diffPerfs, didPerfReset } from './perfUtils';
+
+
+suite('diffPerfs', () => {
+ it('should correctly calculate the difference between two performance snapshots', () => {
+ const oldPerf = {
+ svSync: { count: 10, sum: 20, buckets: [1, 2, 3] },
+ svNetwork: { count: 15, sum: 30, buckets: [4, 5, 6] },
+ svMain: { count: 20, sum: 40, buckets: [7, 8, 9] },
+ };
+ const newPerf = {
+ svSync: { count: 20, sum: 40, buckets: [2, 4, 6] },
+ svNetwork: { count: 30, sum: 60, buckets: [8, 10, 12] },
+ svMain: { count: 40, sum: 80, buckets: [14, 16, 18] },
+ };
+ const expectedDiff = {
+ svSync: { count: 10, sum: 20, buckets: [1, 2, 3] },
+ svNetwork: { count: 15, sum: 30, buckets: [4, 5, 6] },
+ svMain: { count: 20, sum: 40, buckets: [7, 8, 9] },
+ };
+ const result = diffPerfs(newPerf, oldPerf);
+ expect(result).toEqual(expectedDiff);
+ });
+
+ it('should correctly return the diff when there is no old data', () => {
+ const newPerf = {
+ shouldBeIgnored: { count: 20, sum: 40, buckets: [2, 4, 6] },
+ svSync: { count: 20, sum: 40, buckets: [2, 4, 6] },
+ svNetwork: { count: 30, sum: 60, buckets: [8, 10, 12] },
+ svMain: { count: 40, sum: 80, buckets: [14, 16, 18] },
+ };
+ const expectedDiff = {
+ svSync: { count: 20, sum: 40, buckets: [2, 4, 6] },
+ svNetwork: { count: 30, sum: 60, buckets: [8, 10, 12] },
+ svMain: { count: 40, sum: 80, buckets: [14, 16, 18] },
+ };
+ const result = diffPerfs(newPerf);
+ expect(result).toEqual(expectedDiff);
+ });
+});
+
+
+suite('didPerfReset', () => {
+ it('should detect change in count in any thread', () => {
+ const oldPerf = {
+ svNetwork: { count: 10, sum: 0, buckets: [] },
+ svSync: { count: 10, sum: 0, buckets: [] },
+ svMain: { count: 10, sum: 0, buckets: [] },
+ };
+ const newPerf = {
+ svNetwork: { count: 10, sum: 0, buckets: [] },
+ svSync: { count: 5, sum: 0, buckets: [] },
+ svMain: { count: 10, sum: 0, buckets: [] },
+ };
+ expect(didPerfReset(newPerf, oldPerf)).toBe(true);
+ });
+
+ it('should detect change in sum in any thread', () => {
+ const oldPerf = {
+ svNetwork: { count: 0, sum: 10, buckets: [] },
+ svSync: { count: 0, sum: 10, buckets: [] },
+ svMain: { count: 0, sum: 10, buckets: [] },
+ };
+ const newPerf = {
+ svNetwork: { count: 0, sum: 10, buckets: [] },
+ svSync: { count: 0, sum: 5, buckets: [] },
+ svMain: { count: 0, sum: 10, buckets: [] },
+ };
+ expect(didPerfReset(newPerf, oldPerf)).toBe(true);
+ });
+
+ it('should detect reset - real case', () => {
+ const oldPerf = {
+ svNetwork: { count: 5940, sum: 0.08900000000000005, buckets: [] },
+ svSync: { count: 7333, sum: 0.1400000000000001, buckets: [] },
+ svMain: { count: 1209, sum: 0.1960000000000001, buckets: [] },
+ };
+ const newPerf = {
+ svSync: { count: 1451, sum: 0.01600000000000001, buckets: [] },
+ svNetwork: { count: 1793, sum: 0.03900000000000003, buckets: [] },
+ svMain: { count: 278, sum: 0.05300000000000004, buckets: [] },
+ };
+ expect(didPerfReset(newPerf, oldPerf)).toBe(true);
+ });
+
+ it('should detect progression - real case', () => {
+ const oldPerf = {
+ svSync: { count: 1451, sum: 0.01600000000000001, buckets: [] },
+ svNetwork: { count: 1793, sum: 0.03900000000000003, buckets: [] },
+ svMain: { count: 278, sum: 0.05300000000000004, buckets: [] },
+ };
+ const newPerf = {
+ svNetwork: { count: 8764, sum: 0.1030000000000001, buckets: [] },
+ svSync: { count: 10815, sum: 0.1880000000000001, buckets: [] },
+ svMain: { count: 1792, sum: 0.3180000000000002, buckets: [] },
+ };
+ expect(didPerfReset(newPerf, oldPerf)).toBe(false);
+ });
+});
diff --git a/core/modules/Metrics/svRuntime/perfUtils.ts b/core/modules/Metrics/svRuntime/perfUtils.ts
new file mode 100644
index 0000000..29a4a11
--- /dev/null
+++ b/core/modules/Metrics/svRuntime/perfUtils.ts
@@ -0,0 +1,136 @@
+import pidusage from 'pidusage';
+import { cloneDeep } from 'lodash-es';
+import type { SvRtPerfCountsType } from "./perfSchemas";
+import got from '@lib/got';
+import { parseRawPerf } from './perfParser';
+import { PERF_DATA_BUCKET_COUNT } from './config';
+import { txEnv } from '@core/globalData';
+
+
+//Consts
+const perfDataRawThreadsTemplate: SvRtPerfCountsType = {
+ svSync: {
+ count: 0,
+ sum: 0,
+ buckets: Array(PERF_DATA_BUCKET_COUNT).fill(0),
+ },
+ svNetwork: {
+ count: 0,
+ sum: 0,
+ buckets: Array(PERF_DATA_BUCKET_COUNT).fill(0),
+ },
+ svMain: {
+ count: 0,
+ sum: 0,
+ buckets: Array(PERF_DATA_BUCKET_COUNT).fill(0),
+ }
+};
+
+
+/**
+ * Compares a perf snapshot with the one that came before
+ * NOTE: I could just clone the old perf data, but this way I guarantee the shape of the data
+ */
+export const diffPerfs = (newPerf: SvRtPerfCountsType, oldPerf?: SvRtPerfCountsType) => {
+ const basePerf = oldPerf ?? cloneDeep(perfDataRawThreadsTemplate);
+ return {
+ svSync: {
+ count: newPerf.svSync.count - basePerf.svSync.count,
+ sum: newPerf.svSync.sum - basePerf.svSync.sum,
+ buckets: newPerf.svSync.buckets.map((bucket, i) => bucket - basePerf.svSync.buckets[i]),
+ },
+ svNetwork: {
+ count: newPerf.svNetwork.count - basePerf.svNetwork.count,
+ sum: newPerf.svNetwork.sum - basePerf.svNetwork.sum,
+ buckets: newPerf.svNetwork.buckets.map((bucket, i) => bucket - basePerf.svNetwork.buckets[i]),
+ },
+ svMain: {
+ count: newPerf.svMain.count - basePerf.svMain.count,
+ sum: newPerf.svMain.sum - basePerf.svMain.sum,
+ buckets: newPerf.svMain.buckets.map((bucket, i) => bucket - basePerf.svMain.buckets[i]),
+ },
+ };
+};
+
+
+/**
+ * Checks if any perf count/sum from any thread reset (if old > new)
+ */
+export const didPerfReset = (newPerf: SvRtPerfCountsType, oldPerf: SvRtPerfCountsType) => {
+ return (
+ (oldPerf.svSync.count > newPerf.svSync.count) ||
+ (oldPerf.svSync.sum > newPerf.svSync.sum) ||
+ (oldPerf.svNetwork.count > newPerf.svNetwork.count) ||
+ (oldPerf.svNetwork.sum > newPerf.svNetwork.sum) ||
+ (oldPerf.svMain.count > newPerf.svMain.count) ||
+ (oldPerf.svMain.sum > newPerf.svMain.sum)
+ );
+}
+
+
+/**
+ * Transforms raw perf data into a frequency distribution (histogram)
+ * ForEach thread, individualize tick counts (instead of CumSum) and calculates frequency
+ */
+// export const perfCountsToHist = (threads: SvRtPerfCountsType) => {
+// const currPerfFreqs: SvRtPerfHistType = {
+// svSync: {
+// count: threads.svSync.count,
+// freqs: [],
+// },
+// svNetwork: {
+// count: threads.svNetwork.count,
+// freqs: [],
+// },
+// svMain: {
+// count: threads.svMain.count,
+// freqs: [],
+// },
+// };
+// for (const [tName, tData] of Object.entries(threads)) {
+// currPerfFreqs[tName as SvRtPerfThreadNamesType].freqs = tData.buckets.map((bucketValue, bucketIndex) => {
+// const prevBucketValue = (bucketIndex) ? tData.buckets[bucketIndex - 1] : 0;
+// return (bucketValue - prevBucketValue) / tData.count;
+// });
+// }
+// return currPerfFreqs;
+// }
+
+
+/**
+ * Requests /perf/, parses it and returns the raw perf data
+ */
+export const fetchRawPerfData = async (netEndpoint: string) => {
+ const currPerfRaw = await got(`http://${netEndpoint}/perf/`).text();
+ return parseRawPerf(currPerfRaw);
+}
+
+
+/**
+ * Get the fxserver memory usage
+ * FIXME: migrate to use gwmi on windows by default
+ */
+export const fetchFxsMemory = async (fxsPid?: number) => {
+ if (!fxsPid) return;
+ try {
+ const pidUsage = await pidusage(fxsPid);
+ const memoryMb = pidUsage.memory / 1024 / 1024;
+ return parseFloat((memoryMb).toFixed(2));
+ } catch (error) {
+ if ((error as any).code = 'ENOENT') {
+ console.error('Failed to get processes tree usage data.');
+ if (!txCore.fxRunner.child?.isAlive) {
+ console.error('The server process is not running.');
+ } if (txEnv.isWindows) {
+ console.error('This is probably because the `wmic` command is not available in your system.');
+ console.error('If you are on Windows 11 or Windows Server 2025, you can enable it in the "Windows Features" settings.');
+ } else {
+ console.error('This is probably because the `ps` command is not available in your system.');
+ console.error('This command is part of the `procps` package in most Linux distributions.');
+ }
+ return;
+ } else {
+ throw error;
+ }
+ }
+}
diff --git a/core/modules/Metrics/txRuntime/index.ts b/core/modules/Metrics/txRuntime/index.ts
new file mode 100644
index 0000000..8a69809
--- /dev/null
+++ b/core/modules/Metrics/txRuntime/index.ts
@@ -0,0 +1,212 @@
+const modulename = 'TxRuntimeMetrics';
+import * as jose from 'jose';
+import consoleFactory from '@lib/console';
+import { MultipleCounter, QuantileArray } from '../statsUtils';
+import { txEnv, txHostConfig } from '@core/globalData';
+import { getHostStaticData } from '@lib/diagnostics';
+import fatalError from '@lib/fatalError';
+const console = consoleFactory(modulename);
+
+
+//Consts
+const JWE_VERSION = 13;
+const statsPublicKeyPem = `-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2NCbB5DvpR7F8qHF9SyA
+xJKv9lpGO2PiU5wYUmEQaa0IUrUZmQ8ivsoOyCZOGKN9PESsVyqZPx37fhtAIqNo
+AXded6K6ortngEghqQloK3bi3hk8mclGXKmUhwimfrw77EIzd8dycSFQTwV+hiy6
+osF2150yfeGRnD1vGbc6iS7Ewer0Zh9rwghXnl/jTupVprQggrhVIg62ZxmrQ0Gd
+lj9pVXSu6QV/rjNbAVIiLFBGTjsHIKORQWV32oCguXu5krNvI+2lCBpOowY2dTO/
++TX0xXHgkGAQIdL0SdpD1SIe57hZsA2mOVitNwztE+KAhYsVBSqasGbly0lu7NDJ
+oQIDAQAB
+-----END PUBLIC KEY-----`;
+const jweHeader = {
+ alg: 'RSA-OAEP-256',
+ enc: 'A256GCM',
+ kid: '2023-05-21_stats'
+} satisfies jose.CompactJWEHeaderParameters;
+
+/**
+ * Responsible for collecting server runtime statistics
+ * NOTE: the register functions don't throw because we rather break stats than txAdmin itself
+ */
+export default class TxRuntimeMetrics {
+ #publicKey: jose.KeyLike | undefined;
+
+ #fxServerBootSeconds: number | false = false;
+ public readonly loginOrigins = new MultipleCounter();
+ public readonly loginMethods = new MultipleCounter();
+ public readonly botCommands = new MultipleCounter();
+ public readonly menuCommands = new MultipleCounter();
+ public readonly banCheckTime = new QuantileArray(5000, 50);
+ public readonly whitelistCheckTime = new QuantileArray(5000, 50);
+ public readonly playersTableSearchTime = new QuantileArray(5000, 50);
+ public readonly historyTableSearchTime = new QuantileArray(5000, 50);
+ public readonly databaseSaveTime = new QuantileArray(1440, 60);
+ public readonly perfCollectionTime = new QuantileArray(1440, 60);
+
+ public currHbData: string = '{"error": "not yet initialized in TxRuntimeMetrics"}';
+ public monitorStats = {
+ healthIssues: {
+ fd3: 0,
+ http: 0,
+ },
+ restartReasons: {
+ bootTimeout: 0,
+ close: 0,
+ heartBeat: 0,
+ healthCheck: 0,
+ both: 0,
+ },
+ };
+
+ constructor() {
+ setImmediate(() => {
+ this.loadStatsPublicKey();
+ });
+
+ //Delaying this because host static data takes 10+ seconds to be set
+ setTimeout(() => {
+ this.refreshHbData().catch((e) => { });
+ }, 15_000);
+
+ //Cron function
+ setInterval(() => {
+ this.refreshHbData().catch((e) => { });
+ }, 60_000);
+ }
+
+
+ /**
+ * Parses the stats public key
+ */
+ async loadStatsPublicKey() {
+ try {
+ this.#publicKey = await jose.importSPKI(statsPublicKeyPem, 'RS256');
+ } catch (error) {
+ fatalError.StatsTxRuntime(0, 'Failed to load stats public key.', error);
+ }
+ }
+
+
+ /**
+ * Called by FxMonitor to keep track of the last boot time
+ */
+ registerFxserverBoot(seconds: number) {
+ if (!Number.isInteger(seconds) || seconds < 0) {
+ this.#fxServerBootSeconds = false;
+ }
+ this.#fxServerBootSeconds = seconds;
+ console.verbose.debug(`FXServer booted in ${seconds} seconds.`);
+ }
+
+
+ /**
+ * Called by FxMonitor to keep track of the fxserver restart reasons
+ */
+ registerFxserverRestart(reason: keyof typeof this.monitorStats.restartReasons) {
+ if (!(reason in this.monitorStats.restartReasons)) return;
+ this.monitorStats.restartReasons[reason]++;
+ }
+
+
+ /**
+ * Called by FxMonitor to keep track of the fxserver HB/HC failures
+ */
+ registerFxserverHealthIssue(type: keyof typeof this.monitorStats.healthIssues) {
+ if (!(type in this.monitorStats.healthIssues)) return;
+ this.monitorStats.healthIssues[type]++;
+ }
+
+
+ /**
+ * Processes general txadmin stuff to generate the HB data.
+ *
+ * Stats Version Changelog:
+ * 6: added txStatsData.randIDFailures
+ * 7: changed web folder paths, which affect txStatsData.pageViews
+ * 8: removed discordBotStats and whitelistEnabled
+ * 9: totally new format
+ * 9: for tx v7, loginOrigin dropped the 'webpipe' and 'cfxre',
+ * and loginMethods dropped 'nui' and 'iframe'
+ * Did not change the version because its fully compatible.
+ * 10: deprecated pageViews because of the react migration
+ * 11: added playersTableSearchTime and historyTableSearchTime
+ * 12: changed perfSummary format
+ * 13: added providerName
+ *
+ * TODO:
+ * Use the average q5 and q95 to find out the buckets.
+ * Then start sending the buckets with counts instead of quantiles.
+ * Might be ok to optimize by joining both arrays, even if the buckets are not the same
+ * joinCheckTimes: [
+ * [ban, wl], //bucket 1
+ * [ban, wl], //bucket 2
+ * ...
+ * ]
+ */
+ async refreshHbData() {
+ //Make sure publicKey is loaded
+ if (!this.#publicKey) {
+ console.verbose.warn('Cannot refreshHbData because this.#publicKey is not set.');
+ return;
+ }
+
+ //Generate HB data
+ try {
+ const hostData = getHostStaticData();
+
+ //Prepare stats data
+ const statsData = {
+ //Static
+ providerName: txHostConfig.providerName,
+ isZapHosting: txEnv.isZapHosting,
+ isPterodactyl: txEnv.isPterodactyl,
+ osDistro: hostData.osDistro,
+ hostCpuModel: `${hostData.cpu.manufacturer} ${hostData.cpu.brand}`,
+
+ //Passive runtime data
+ fxServerBootSeconds: this.#fxServerBootSeconds,
+ loginOrigins: this.loginOrigins,
+ loginMethods: this.loginMethods,
+ botCommands: txConfig.discordBot.enabled
+ ? this.botCommands
+ : false,
+ menuCommands: txConfig.gameFeatures.menuEnabled
+ ? this.menuCommands
+ : false,
+ banCheckTime: txConfig.banlist.enabled
+ ? this.banCheckTime
+ : false,
+ whitelistCheckTime: txConfig.whitelist.mode !== 'disabled'
+ ? this.whitelistCheckTime
+ : false,
+ playersTableSearchTime: this.playersTableSearchTime,
+ historyTableSearchTime: this.historyTableSearchTime,
+
+ //Settings & stuff
+ adminCount: Array.isArray(txCore.adminStore.admins) ? txCore.adminStore.admins.length : 1,
+ banCheckingEnabled: txConfig.banlist.enabled,
+ whitelistMode: txConfig.whitelist.mode,
+ recipeName: txCore.cacheStore.get('deployer:recipe') ?? 'not_in_cache',
+ tmpConfigFlags: Object.entries(txConfig.gameFeatures)
+ .filter(([key, value]) => value)
+ .map(([key]) => key),
+
+ //Processed stuff
+ playerDb: txCore.database.stats.getDatabaseStats(),
+ perfSummary: txCore.metrics.svRuntime.getServerPerfSummary(),
+ };
+
+ //Prepare output
+ const encodedHbData = new TextEncoder().encode(JSON.stringify(statsData));
+ const jwe = await new jose.CompactEncrypt(encodedHbData)
+ .setProtectedHeader(jweHeader)
+ .encrypt(this.#publicKey);
+ this.currHbData = JSON.stringify({ '$statsVersion': JWE_VERSION, jwe });
+ } catch (error) {
+ console.verbose.error('Error while updating stats data.');
+ console.verbose.dir(error);
+ this.currHbData = JSON.stringify({ error: (error as Error).message });
+ }
+ }
+};
diff --git a/core/modules/Translator.ts b/core/modules/Translator.ts
new file mode 100644
index 0000000..0a15e17
--- /dev/null
+++ b/core/modules/Translator.ts
@@ -0,0 +1,136 @@
+const modulename = 'Translator';
+import fs from 'node:fs';
+import Polyglot from 'node-polyglot';
+import { txEnv, txHostConfig } from '@core/globalData';
+import localeMap from '@shared/localeMap';
+import consoleFactory from '@lib/console';
+import fatalError from '@lib/fatalError';
+import type { UpdateConfigKeySet } from './ConfigStore/utils';
+import humanizeDuration, { HumanizerOptions } from 'humanize-duration';
+import { msToDuration } from '@lib/misc';
+import { z } from 'zod';
+const console = consoleFactory(modulename);
+
+
+//Schema for the custom locale file
+export const localeFileSchema = z.object({
+ $meta: z.object({
+ label: z.string().min(1),
+ humanizer_language: z.string().min(1),
+ }),
+}).passthrough();
+
+
+/**
+ * Translation module built around Polyglot.js.
+ * The locale files are indexed by the localeMap in the shared folder.
+ */
+export default class Translator {
+ static readonly configKeysWatched = ['general.language'];
+ static readonly humanizerLanguages: string[] = humanizeDuration.getSupportedLanguages();
+
+ public readonly customLocalePath = txHostConfig.dataSubPath('locale.json');
+ public canonical: string = 'en-GB'; //Using GB instead of US due to date/time formats
+ #polyglot: Polyglot | null = null;
+
+ constructor() {
+ //Load language
+ this.setupTranslator(true);
+ }
+
+
+ /**
+ * Handle updates to the config by resetting the translator
+ */
+ public handleConfigUpdate(updatedConfigs: UpdateConfigKeySet) {
+ this.setupTranslator(false);
+ }
+
+
+ /**
+ * Setup polyglot instance
+ */
+ setupTranslator(isFirstTime = false) {
+ try {
+ this.canonical = Intl.getCanonicalLocales(txConfig.general.language.replace(/_/g, '-'))[0];
+ } catch (error) {
+ this.canonical = 'en-GB';
+ }
+
+ try {
+ const phrases = this.getLanguagePhrases(txConfig.general.language);
+ const polyglotOptions = {
+ allowMissing: false,
+ onMissingKey: (key: string) => {
+ console.error(`Missing key '${key}' from translation file.`, 'Translator');
+ return key;
+ },
+ phrases,
+ };
+ this.#polyglot = new Polyglot(polyglotOptions);
+ } catch (error) {
+ if (isFirstTime) {
+ fatalError.Translator(0, 'Failed to load initial language file', error);
+ } else {
+ console.dir(error);
+ }
+ }
+ }
+
+
+ /**
+ * Loads a language file or throws Error.
+ */
+ getLanguagePhrases(lang: string) {
+ if (localeMap[lang]?.$meta) {
+ //If its a known language
+ return localeMap[lang];
+
+ } else if (lang === 'custom') {
+ //If its a custom language
+ try {
+ return JSON.parse(fs.readFileSync(
+ this.customLocalePath,
+ 'utf8',
+ ));
+ } catch (error) {
+ throw new Error(`Failed to load '${this.customLocalePath}'. (${(error as Error).message})`);
+ }
+
+ } else {
+ //If its an invalid language
+ throw new Error(`Language '${lang}' not found.`);
+ }
+ }
+
+
+ /**
+ * Perform a translation (polyglot.t)
+ */
+ t(key: string, options = {}) {
+ if (!this.#polyglot) throw new Error(`polyglot not yet loaded`);
+
+ try {
+ return this.#polyglot.t(key, options);
+ } catch (error) {
+ console.error(`Error performing a translation with key '${key}'`);
+ return key;
+ }
+ }
+
+
+ /**
+ * Humanizes & translates a duration in ms
+ */
+ tDuration(ms: number, options: HumanizerOptions = {}) {
+ if (!this.#polyglot) throw new Error(`polyglot not yet loaded`);
+
+ try {
+ const lang = this.#polyglot.t('$meta.humanizer_language')
+ return msToDuration(ms, { ...options, language: lang });
+ } catch (error) {
+ console.error(`Error humanizing duration`, error);
+ return String(ms)+'ms';
+ }
+ }
+};
diff --git a/core/modules/UpdateChecker/index.ts b/core/modules/UpdateChecker/index.ts
new file mode 100644
index 0000000..1edfbd5
--- /dev/null
+++ b/core/modules/UpdateChecker/index.ts
@@ -0,0 +1,157 @@
+const modulename = 'UpdateChecker';
+import { txEnv } from '@core/globalData';
+import consoleFactory from '@lib/console';
+import { UpdateDataType } from '@shared/otherTypes';
+import { UpdateAvailableEventType } from '@shared/socketioTypes';
+import { queryChangelogApi } from './queryChangelogApi';
+import { getUpdateRolloutDelay } from './updateRollout';
+const console = consoleFactory(modulename);
+
+
+type CachedDelayType = {
+ ts: number,
+ diceRoll: number,
+}
+
+/**
+ * Creates a cache string.
+ */
+const createCacheString = (delayData: CachedDelayType) => {
+ return `${delayData.ts},${delayData.diceRoll}`;
+}
+
+
+/**
+ * Parses the cached string.
+ * Format: "ts,diceRoll"
+ */
+const parseCacheString = (raw: any) => {
+ if (typeof raw !== 'string' || !raw) return;
+ const [ts, diceRoll] = raw.split(',');
+ const obj = {
+ ts: parseInt(ts),
+ diceRoll: parseInt(diceRoll),
+ } satisfies CachedDelayType;
+ if (isNaN(obj.ts) || isNaN(obj.diceRoll)) return;
+ return obj;
+}
+
+
+/**
+ * Rolls dice, gets integer between 0 and 100
+ */
+const rollDice = () => {
+ return Math.floor(Math.random() * 101);
+}
+
+const DELAY_CACHE_KEY = 'updateDelay';
+
+
+/**
+ * Module to check for updates and notify the user according to a rollout strategy randomly picked.
+ */
+export default class UpdateChecker {
+ txaUpdateData?: UpdateDataType;
+ fxsUpdateData?: UpdateDataType;
+
+ constructor() {
+ //Check for updates ASAP
+ setImmediate(() => {
+ this.checkChangelog();
+ });
+
+ //Check again every 15 mins
+ setInterval(() => {
+ this.checkChangelog();
+ }, 15 * 60_000);
+ }
+
+
+ /**
+ * Check for txAdmin and FXServer updates
+ */
+ async checkChangelog() {
+ const updates = await queryChangelogApi();
+ if (!updates) return;
+
+ //If fxserver, don't print anything, just update the data
+ if (updates.fxs) {
+ this.fxsUpdateData = {
+ version: updates.fxs.version,
+ isImportant: updates.fxs.isImportant,
+ }
+ }
+
+ //If txAdmin update, check for delay before printing
+ if (updates.txa) {
+ //Setup delay data
+ const currTs = Date.now();
+ let delayData: CachedDelayType;
+ const rawCache = txCore.cacheStore.get(DELAY_CACHE_KEY);
+ const cachedData = parseCacheString(rawCache);
+ if (cachedData) {
+ delayData = cachedData;
+ } else {
+ delayData = {
+ diceRoll: rollDice(),
+ ts: currTs,
+ }
+ txCore.cacheStore.set(DELAY_CACHE_KEY, createCacheString(delayData));
+ }
+
+ //Get the delay
+ const notifDelayDays = getUpdateRolloutDelay(
+ updates.txa.semverDiff,
+ txEnv.txaVersion.includes('-'),
+ delayData.diceRoll
+ );
+ const notifDelayMs = notifDelayDays * 24 * 60 * 60 * 1000;
+ console.verbose.debug(`Update available, notification delayed by: ${notifDelayDays} day(s).`);
+ if (currTs - delayData.ts >= notifDelayMs) {
+ txCore.cacheStore.delete(DELAY_CACHE_KEY);
+ this.txaUpdateData = {
+ version: updates.txa.version,
+ isImportant: updates.txa.isImportant,
+ }
+ if (updates.txa.isImportant) {
+ console.error('This version of txAdmin is outdated.');
+ console.error('Please update as soon as possible.');
+ console.error('For more information: https://discord.gg/uAmsGa2');
+ } else {
+ console.warn('This version of txAdmin is outdated.');
+ console.warn('A patch (bug fix) update is available for txAdmin.');
+ console.warn('If you are experiencing any kind of issue, please update now.');
+ console.warn('For more information: https://discord.gg/uAmsGa2');
+ }
+ }
+ }
+
+ //Sending event to the UI
+ if (this.txaUpdateData || this.fxsUpdateData) {
+ txCore.webServer.webSocket.pushEvent('updateAvailable', {
+ fxserver: this.fxsUpdateData,
+ txadmin: this.txaUpdateData,
+ });
+ }
+ }
+};
+
+/*
+ TODO:
+ Create an page with the changelog, that queries for the following endpoint and caches it for 15 minutes:
+ https://changelogs-live.fivem.net/api/changelog/versions/2385/2375?tag=server
+ Maybe even grab the data from commits:
+ https://changelogs-live.fivem.net/api/changelog/versions/5562
+ Other relevant apis:
+ https://changelogs-live.fivem.net/api/changelog/versions/win32/server? (the one being used below)
+ https://changelogs-live.fivem.net/api/changelog/versions
+ https://api.github.com/repos/tabarra/txAdmin/releases (changelog in [].body)
+
+ NOTE: old logic
+ if == recommended, you're fine
+ if > recommended && < optional, pls update to optional
+ if == optional, you're fine
+ if > optional && < latest, pls update to latest
+ if == latest, duh
+ if < critical, BIG WARNING
+*/
diff --git a/core/modules/UpdateChecker/queryChangelogApi.ts b/core/modules/UpdateChecker/queryChangelogApi.ts
new file mode 100644
index 0000000..dde271e
--- /dev/null
+++ b/core/modules/UpdateChecker/queryChangelogApi.ts
@@ -0,0 +1,111 @@
+const modulename = 'UpdateChecker';
+import semver, { ReleaseType } from 'semver';
+import { z } from "zod";
+import got from '@lib/got';
+import { txEnv } from '@core/globalData';
+import consoleFactory from '@lib/console';
+import { UpdateDataType } from '@shared/otherTypes';
+import { fromError } from 'zod-validation-error';
+const console = consoleFactory(modulename);
+
+
+//Schemas
+const txVersion = z.string().refine(
+ (x) => x !== '0.0.0',
+ { message: 'must not be 0.0.0' }
+);
+const changelogRespSchema = z.object({
+ recommended: z.coerce.number().positive(),
+ recommended_download: z.string().url(),
+ recommended_txadmin: txVersion,
+ optional: z.coerce.number().positive(),
+ optional_download: z.string().url(),
+ optional_txadmin: txVersion,
+ latest: z.coerce.number().positive(),
+ latest_download: z.string().url(),
+ latest_txadmin: txVersion,
+ critical: z.coerce.number().positive(),
+ critical_download: z.string().url(),
+ critical_txadmin: txVersion,
+});
+
+//Types
+type DetailedUpdateDataType = {
+ semverDiff: ReleaseType;
+ version: string;
+ isImportant: boolean;
+};
+
+export const queryChangelogApi = async () => {
+ //GET changelog data
+ let apiResponse: z.infer;
+ try {
+ //perform request - cache busting every ~1.4h
+ const osTypeApiUrl = (txEnv.isWindows) ? 'win32' : 'linux';
+ const cacheBuster = Math.floor(Date.now() / 5_000_000);
+ const reqUrl = `https://changelogs-live.fivem.net/api/changelog/versions/${osTypeApiUrl}/server?${cacheBuster}`;
+ const resp = await got(reqUrl).json()
+ apiResponse = changelogRespSchema.parse(resp);
+ } catch (error) {
+ let msg = (error as Error).message;
+ if(error instanceof z.ZodError){
+ msg = fromError(error, { prefix: null }).message
+ }
+ console.verbose.warn(`Failed to retrieve FXServer/txAdmin update data with error: ${msg}`);
+ return;
+ }
+
+ //Checking txAdmin version
+ let txaUpdateData: DetailedUpdateDataType | undefined;
+ try {
+ const isOutdated = semver.lt(txEnv.txaVersion, apiResponse.latest_txadmin);
+ if (isOutdated) {
+ const semverDiff = semver.diff(txEnv.txaVersion, apiResponse.latest_txadmin) ?? 'patch';
+ const isImportant = (semverDiff === 'major' || semverDiff === 'minor');
+ txaUpdateData = {
+ semverDiff,
+ isImportant,
+ version: apiResponse.latest_txadmin,
+ };
+ }
+ } catch (error) {
+ console.verbose.warn('Error checking for txAdmin updates.');
+ console.verbose.dir(error);
+ }
+
+ //Checking FXServer version
+ let fxsUpdateData: UpdateDataType | undefined;
+ try {
+ if (txEnv.fxsVersion < apiResponse.critical) {
+ if (apiResponse.critical > apiResponse.recommended) {
+ fxsUpdateData = {
+ version: apiResponse.critical.toString(),
+ isImportant: true,
+ }
+ } else {
+ fxsUpdateData = {
+ version: apiResponse.recommended.toString(),
+ isImportant: true,
+ }
+ }
+ } else if (txEnv.fxsVersion < apiResponse.recommended) {
+ fxsUpdateData = {
+ version: apiResponse.recommended.toString(),
+ isImportant: true,
+ };
+ } else if (txEnv.fxsVersion < apiResponse.optional) {
+ fxsUpdateData = {
+ version: apiResponse.optional.toString(),
+ isImportant: false,
+ };
+ }
+ } catch (error) {
+ console.warn('Error checking for FXServer updates.');
+ console.verbose.dir(error);
+ }
+
+ return {
+ txa: txaUpdateData,
+ fxs: fxsUpdateData,
+ };
+};
diff --git a/core/modules/UpdateChecker/updateRollout.test.ts b/core/modules/UpdateChecker/updateRollout.test.ts
new file mode 100644
index 0000000..a76e5ff
--- /dev/null
+++ b/core/modules/UpdateChecker/updateRollout.test.ts
@@ -0,0 +1,65 @@
+import { it, expect, suite } from 'vitest';
+import { getUpdateRolloutDelay } from './updateRollout';
+
+suite('getReleaseRolloutDelay', () => {
+ const fnc = getUpdateRolloutDelay;
+
+ it('should handle invalid input', () => {
+ expect(fnc('minor', false, -1)).toBe(0);
+ expect(fnc('minor', false, 150)).toBe(0);
+ expect(fnc('aaaaaaa' as any, false, 5)).toBe(7);
+ });
+
+ it('should return 0 delay for 100% immediate pre-release update', () => {
+ expect(fnc('minor', true, 0)).toBe(0);
+ expect(fnc('minor', true, 50)).toBe(0);
+ expect(fnc('minor', true, 100)).toBe(0);
+ expect(fnc('major', true, 0)).toBe(0);
+ expect(fnc('major', true, 50)).toBe(0);
+ expect(fnc('major', true, 100)).toBe(0);
+ expect(fnc('patch', true, 0)).toBe(0);
+ expect(fnc('patch', true, 50)).toBe(0);
+ expect(fnc('patch', true, 100)).toBe(0);
+ expect(fnc('prepatch', true, 0)).toBe(0);
+ expect(fnc('prepatch', true, 50)).toBe(0);
+ expect(fnc('prepatch', true, 100)).toBe(0);
+ });
+
+ it('should return correct delay for major release based on dice roll', () => {
+ // First tier (5%)
+ let delay = fnc('major', false, 3);
+ expect(delay).toBe(0);
+
+ // Second tier (5% < x <= 20%)
+ delay = fnc('major', false, 10);
+ expect(delay).toBe(2);
+
+ // Third tier (remaining 80%)
+ delay = fnc('major', false, 50);
+ expect(delay).toBe(7);
+ });
+
+ it('should return correct delay for minor release based on dice roll', () => {
+ // First tier (10%)
+ let delay = fnc('minor', false, 5);
+ expect(delay).toBe(0);
+
+ // Second tier (10% < x <= 40%)
+ delay = fnc('minor', false, 20);
+ expect(delay).toBe(2);
+
+ // Third tier (remaining 60%)
+ delay = fnc('minor', false, 80);
+ expect(delay).toBe(4);
+ });
+
+ it('should return 0 delay for patch release for all dice rolls', () => {
+ const delay = fnc('patch', false, 50);
+ expect(delay).toBe(0);
+ });
+
+ it('should return 7-day delay for stable to pre-release', () => {
+ const delay = fnc('prerelease', false, 50);
+ expect(delay).toBe(7);
+ });
+});
diff --git a/core/modules/UpdateChecker/updateRollout.ts b/core/modules/UpdateChecker/updateRollout.ts
new file mode 100644
index 0000000..8a62b3c
--- /dev/null
+++ b/core/modules/UpdateChecker/updateRollout.ts
@@ -0,0 +1,69 @@
+import type { ReleaseType } from 'semver';
+
+type RolloutStrategyType = {
+ pct: number,
+ delay: number
+}[];
+
+
+/**
+ * Returns the delay in days for the update rollout based on the release type and the dice roll.
+ */
+export const getUpdateRolloutDelay = (
+ releaseDiff: ReleaseType,
+ isCurrentPreRelease: boolean,
+ diceRoll: number,
+): number => {
+ //Sanity check diceRoll
+ if (diceRoll < 0 || diceRoll > 100) {
+ return 0;
+ }
+
+ let rolloutStrategy: RolloutStrategyType;
+ if (isCurrentPreRelease) {
+ // If you are on beta, it's probably really important to update immediately
+ rolloutStrategy = [
+ { pct: 100, delay: 0 },
+ ];
+ } else if (releaseDiff === 'major') {
+ // 5% immediate rollout
+ // 20% after 2 days
+ // 100% after 7 days
+ rolloutStrategy = [
+ { pct: 5, delay: 0 },
+ { pct: 15, delay: 2 },
+ { pct: 80, delay: 7 },
+ ];
+ } else if (releaseDiff === 'minor') {
+ // 10% immediate rollout
+ // 40% after 2 day
+ // 100% after 4 days
+ rolloutStrategy = [
+ { pct: 10, delay: 0 },
+ { pct: 30, delay: 2 },
+ { pct: 60, delay: 4 },
+ ];
+ } else if (releaseDiff === 'patch') {
+ // Immediate rollout to everyone, probably correcting bugs
+ rolloutStrategy = [
+ { pct: 100, delay: 0 },
+ ];
+ } else {
+ // Update notification from stable to pre-release should not happen, delay 7 days
+ rolloutStrategy = [
+ { pct: 100, delay: 7 },
+ ];
+ }
+
+ // Implement strategy based on diceRoll
+ let cumulativePct = 0;
+ for (const tier of rolloutStrategy) {
+ cumulativePct += tier.pct;
+ if (diceRoll <= cumulativePct) {
+ return tier.delay;
+ }
+ }
+
+ // Default delay if somehow no tier is matched (which shouldn't happen)
+ return 0;
+};
diff --git a/core/modules/WebServer/authLogic.ts b/core/modules/WebServer/authLogic.ts
new file mode 100644
index 0000000..36c11ff
--- /dev/null
+++ b/core/modules/WebServer/authLogic.ts
@@ -0,0 +1,269 @@
+const modulename = 'WebServer:AuthLogic';
+import { z } from "zod";
+import { txEnv } from '@core/globalData';
+import consoleFactory from '@lib/console';
+import type { SessToolsType } from "./middlewares/sessionMws";
+import { ReactAuthDataType } from "@shared/authApiTypes";
+const console = consoleFactory(modulename);
+
+
+/**
+ * Admin class to be used as ctx.admin
+ */
+export class AuthedAdmin {
+ public readonly name: string;
+ public readonly isMaster: boolean;
+ public readonly permissions: string[];
+ public readonly isTempPassword: boolean;
+ public readonly profilePicture: string | undefined;
+ public readonly csrfToken?: string;
+
+ constructor(vaultAdmin: any, csrfToken?: string) {
+ this.name = vaultAdmin.name;
+ this.isMaster = vaultAdmin.master;
+ this.permissions = vaultAdmin.permissions;
+ this.isTempPassword = (typeof vaultAdmin.password_temporary !== 'undefined');
+ this.csrfToken = csrfToken;
+
+ const cachedPfp = txCore.cacheStore.get(`admin:picture:${vaultAdmin.name}`);
+ this.profilePicture = typeof cachedPfp === 'string' ? cachedPfp : undefined;
+ }
+
+ /**
+ * Logs an action to the console and the action logger
+ */
+ public logAction(action: string): void {
+ txCore.logger.admin.write(this.name, action);
+ };
+
+ /**
+ * Logs a command to the console and the action logger
+ */
+ public logCommand(data: string): void {
+ txCore.logger.admin.write(this.name, data, 'command');
+ };
+
+ /**
+ * Returns if admin has permission or not - no message is printed
+ */
+ hasPermission(perm: string): boolean {
+ try {
+ if (perm === 'master') {
+ return this.isMaster;
+ }
+ return (
+ this.isMaster
+ || this.permissions.includes('all_permissions')
+ || this.permissions.includes(perm)
+ );
+ } catch (error) {
+ console.verbose.warn(`Error validating permission '${perm}' denied.`);
+ return false;
+ }
+ }
+
+ /**
+ * Test for a permission and prints warn if test fails and verbose
+ */
+ testPermission(perm: string, fromCtx: string): boolean {
+ if (!this.hasPermission(perm)) {
+ console.verbose.warn(`[${this.name}] Permission '${perm}' denied.`, fromCtx);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Returns the data used for the frontend or sv_admins.lua
+ */
+ getAuthData(): ReactAuthDataType {
+ return {
+ name: this.name,
+ permissions: this.isMaster ? ['all_permissions'] : this.permissions,
+ isMaster: this.isMaster,
+ isTempPassword: this.isTempPassword,
+ profilePicture: this.profilePicture,
+ csrfToken: this.csrfToken ?? 'not_set',
+ }
+ }
+}
+
+export type AuthedAdminType = InstanceType;
+
+
+/**
+ * Return type helper - null reason indicates nothing to print
+ */
+type AuthLogicReturnType = {
+ success: true,
+ admin: AuthedAdmin;
+} | {
+ success: false;
+ rejectReason?: string;
+};
+const successResp = (vaultAdmin: any, csrfToken?: string) => ({
+ success: true,
+ admin: new AuthedAdmin(vaultAdmin, csrfToken),
+} as const)
+const failResp = (reason?: string) => ({
+ success: false,
+ rejectReason: reason,
+} as const)
+
+
+/**
+ * ZOD schemas for session auth
+ */
+const validPassSessAuthSchema = z.object({
+ type: z.literal('password'),
+ username: z.string(),
+ csrfToken: z.string(),
+ expiresAt: z.literal(false),
+ password_hash: z.string(),
+});
+export type PassSessAuthType = z.infer;
+
+const validCfxreSessAuthSchema = z.object({
+ type: z.literal('cfxre'),
+ username: z.string(),
+ csrfToken: z.string(),
+ expiresAt: z.number(),
+ identifier: z.string(),
+});
+export type CfxreSessAuthType = z.infer;
+
+const validSessAuthSchema = z.discriminatedUnion('type', [
+ validPassSessAuthSchema,
+ validCfxreSessAuthSchema
+]);
+
+
+/**
+ * Autentication logic used in both websocket and webserver, for both web and nui requests.
+ */
+export const checkRequestAuth = (
+ reqHeader: { [key: string]: unknown },
+ reqIp: string,
+ isLocalRequest: boolean,
+ sessTools: SessToolsType,
+) => {
+ return typeof reqHeader['x-txadmin-token'] === 'string'
+ ? nuiAuthLogic(reqIp, isLocalRequest, reqHeader)
+ : normalAuthLogic(sessTools);
+}
+
+
+/**
+ * Autentication logic used in both websocket and webserver
+ */
+export const normalAuthLogic = (
+ sessTools: SessToolsType
+): AuthLogicReturnType => {
+ try {
+ // Getting session
+ const sess = sessTools.get();
+ if (!sess) {
+ return failResp();
+ }
+
+ // Parsing session auth
+ const validationResult = validSessAuthSchema.safeParse(sess?.auth);
+ if (!validationResult.success) {
+ return failResp();
+ }
+ const sessAuth = validationResult.data;
+
+ // Checking for expiration
+ if (sessAuth.expiresAt !== false && Date.now() > sessAuth.expiresAt) {
+ return failResp(`Expired session from '${sess.auth?.username}'.`);
+ }
+
+ // Searching for admin in AdminStore
+ const vaultAdmin = txCore.adminStore.getAdminByName(sessAuth.username);
+ if (!vaultAdmin) {
+ return failResp(`Admin '${sessAuth.username}' not found.`);
+ }
+
+ // Checking for auth types
+ if (sessAuth.type === 'password') {
+ if (vaultAdmin.password_hash !== sessAuth.password_hash) {
+ return failResp(`Password hash doesn't match for '${sessAuth.username}'.`);
+ }
+ return successResp(vaultAdmin, sessAuth.csrfToken);
+ } else if (sessAuth.type === 'cfxre') {
+ if (
+ typeof vaultAdmin.providers.citizenfx !== 'object'
+ || vaultAdmin.providers.citizenfx.identifier !== sessAuth.identifier
+ ) {
+ return failResp(`Cfxre identifier doesn't match for '${sessAuth.username}'.`);
+ }
+ return successResp(vaultAdmin, sessAuth.csrfToken);
+ } else {
+ return failResp('Invalid auth type.');
+ }
+ } catch (error) {
+ console.debug(`Error validating session data: ${(error as Error).message}`);
+ return failResp('Error validating session data.');
+ }
+};
+
+
+/**
+ * Autentication & authorization logic used in for nui requests
+ */
+export const nuiAuthLogic = (
+ reqIp: string,
+ isLocalRequest: boolean,
+ reqHeader: { [key: string]: unknown }
+): AuthLogicReturnType => {
+ try {
+ // Check sus IPs
+ if (
+ !isLocalRequest
+ && !txEnv.isZapHosting
+ && !txConfig.webServer.disableNuiSourceCheck
+ ) {
+ console.verbose.warn(`NUI Auth Failed: reqIp "${reqIp}" not a local or allowed address.`);
+ return failResp('Invalid Request: source');
+ }
+
+ // Check missing headers
+ if (typeof reqHeader['x-txadmin-token'] !== 'string') {
+ return failResp('Invalid Request: token header');
+ }
+ if (typeof reqHeader['x-txadmin-identifiers'] !== 'string') {
+ return failResp('Invalid Request: identifiers header');
+ }
+
+ // Check token value
+ if (reqHeader['x-txadmin-token'] !== txCore.webServer.luaComToken) {
+ const expected = txCore.webServer.luaComToken;
+ const censoredExpected = expected.slice(0, 6) + '...' + expected.slice(-6);
+ console.verbose.warn(`NUI Auth Failed: token received '${reqHeader['x-txadmin-token']}' !== expected '${censoredExpected}'.`);
+ return failResp('Unauthorized: token value');
+ }
+
+ // Check identifier array
+ const identifiers = reqHeader['x-txadmin-identifiers']
+ .split(',')
+ .filter((i) => i.length);
+ if (!identifiers.length) {
+ return failResp('Unauthorized: empty identifier array');
+ }
+
+ // Searching for admin in AdminStore
+ const vaultAdmin = txCore.adminStore.getAdminByIdentifiers(identifiers);
+ if (!vaultAdmin) {
+ if(!reqHeader['x-txadmin-identifiers'].includes('license:')) {
+ return failResp('Unauthorized: you do not have a license identifier, which means the server probably has sv_lan enabled. Please disable sv_lan and restart the server to use the in-game menu.');
+ } else {
+ //this one is handled differently in resource/menu/client/cl_base.lua
+ return failResp('nui_admin_not_found');
+ }
+ }
+ return successResp(vaultAdmin, undefined);
+ } catch (error) {
+ console.debug(`Error validating session data: ${(error as Error).message}`);
+ return failResp('Error validating auth header');
+ }
+};
diff --git a/core/modules/WebServer/ctxTypes.ts b/core/modules/WebServer/ctxTypes.ts
new file mode 100644
index 0000000..df9dd3e
--- /dev/null
+++ b/core/modules/WebServer/ctxTypes.ts
@@ -0,0 +1,40 @@
+import type { ParameterizedContext } from "koa";
+import type { CtxTxVars } from "./middlewares/ctxVarsMw";
+import type { CtxTxUtils } from "./middlewares/ctxUtilsMw";
+import type { AuthedAdminType } from "./authLogic";
+import type { SessToolsType } from "./middlewares/sessionMws";
+import { Socket } from "socket.io";
+
+
+//Right as it comes from Koa
+export type RawKoaCtx = ParameterizedContext<
+ { [key: string]: unknown }, //state
+ { [key: string]: unknown }, //context
+ unknown //response
+>;
+
+//After passing through the libs (session, serve, body parse, etc)
+export type CtxWithSession = RawKoaCtx & {
+ sessTools: SessToolsType;
+ request: any;
+}
+
+//After setupVarsMw
+export type CtxWithVars = CtxWithSession & {
+ txVars: CtxTxVars;
+}
+
+//After setupUtilsMw
+export type InitializedCtx = CtxWithVars & CtxTxUtils;
+
+//After some auth middleware
+export type AuthedCtx = InitializedCtx & {
+ admin: AuthedAdminType;
+ params: any;
+ request: any;
+}
+
+//The socket.io version of "context"
+export type SocketWithSession = Socket & {
+ sessTools: SessToolsType;
+};
diff --git a/core/modules/WebServer/getReactIndex.ts b/core/modules/WebServer/getReactIndex.ts
new file mode 100644
index 0000000..4962851
--- /dev/null
+++ b/core/modules/WebServer/getReactIndex.ts
@@ -0,0 +1,197 @@
+const modulename = 'WebCtxUtils';
+import fsp from "node:fs/promises";
+import path from "node:path";
+import type { InjectedTxConsts, ThemeType } from '@shared/otherTypes';
+import { txEnv, txDevEnv, txHostConfig } from "@core/globalData";
+import { AuthedCtx, CtxWithVars } from "./ctxTypes";
+import consts from "@shared/consts";
+import consoleFactory from '@lib/console';
+import { AuthedAdminType, checkRequestAuth } from "./authLogic";
+import { isString } from "@modules/CacheStore";
+const console = consoleFactory(modulename);
+
+// NOTE: it's not possible to remove the hardcoded import of the entry point in the index.html file
+// even if you set the entry point manually in the vite config.
+// Therefore, it was necessary to tag it with `data-prod-only` so it can be removed in dev mode.
+
+//Consts
+const serverTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+//Cache the index.html file unless in dev mode
+let htmlFile: string;
+
+// NOTE: https://vitejs.dev/guide/backend-integration.html
+const viteOrigin = txDevEnv.VITE_URL ?? 'doesnt-matter';
+const devModulesScript = `
+
+ `;
+
+
+//Custom themes placeholder
+export const tmpDefaultTheme = 'dark';
+export const tmpDefaultThemes = ['dark', 'light'];
+export const tmpCustomThemes: ThemeType[] = [
+ // {
+ // name: 'deep-purple',
+ // isDark: true,
+ // style: {
+ // "background": "274 93% 39%",
+ // "foreground": "269 9% 100%",
+ // "card": "274 79% 53%",
+ // "card-foreground": "270 48% 99%",
+ // "popover": "240 10% 3.9%",
+ // "popover-foreground": "270 48% 99%",
+ // "primary": "270 48% 99%",
+ // "primary-foreground": "240 5.9% 10%",
+ // "secondary": "240 3.7% 15.9%",
+ // "secondary-foreground": "270 48% 99%",
+ // "muted": "240 3.7% 15.9%",
+ // "muted-foreground": "240 5% 64.9%",
+ // "accent": "240 3.7% 15.9%",
+ // "accent-foreground": "270 48% 99%",
+ // "destructive": "0 62.8% 30.6%",
+ // "destructive-foreground": "270 48% 99%",
+ // "border": "273 79%, 53%",
+ // "input": "240 3.7% 15.9%",
+ // "ring": "240 4.9% 83.9%",
+ // }
+ // }
+];
+
+
+
+/**
+ * Returns the react index.html file with placeholders replaced
+ * FIXME: add favicon
+ */
+export default async function getReactIndex(ctx: CtxWithVars | AuthedCtx) {
+ //Read file if not cached
+ if (txDevEnv.ENABLED || !htmlFile) {
+ try {
+ const indexPath = txDevEnv.ENABLED
+ ? path.join(txDevEnv.SRC_PATH, '/panel/index.html')
+ : path.join(txEnv.txaPath, 'panel/index.html')
+ const rawHtmlFile = await fsp.readFile(indexPath, 'utf-8');
+
+ //Remove tagged lines (eg hardcoded entry point) depending on env
+ if (txDevEnv.ENABLED) {
+ htmlFile = rawHtmlFile.replaceAll(/.+data-prod-only.+\r?\n/gm, '');
+ } else {
+ htmlFile = rawHtmlFile.replaceAll(/.+data-dev-only.+\r?\n/gm, '');
+ }
+ } catch (error) {
+ if ((error as any).code == 'ENOENT') {
+ return `⚠ index.html not found: You probably deleted the 'citizen/system_resources/monitor/panel/index.html' file, or the folders above it. `;
+ } else {
+ return `⚠ index.html load error: ${(error as Error).message} `
+ }
+ }
+ }
+
+ //Checking if already logged in
+ const authResult = checkRequestAuth(
+ ctx.request.headers,
+ ctx.ip,
+ ctx.txVars.isLocalRequest,
+ ctx.sessTools
+ );
+ let authedAdmin: AuthedAdminType | false = false;
+ if (authResult.success) {
+ authedAdmin = authResult.admin;
+ }
+
+ //Preparing vars
+ const basePath = (ctx.txVars.isWebInterface) ? '/' : consts.nuiWebpipePath;
+ const injectedConsts: InjectedTxConsts = {
+ //env
+ fxsVersion: txEnv.fxsVersionTag,
+ fxsOutdated: txCore.updateChecker.fxsUpdateData,
+ txaVersion: txEnv.txaVersion,
+ txaOutdated: txCore.updateChecker.txaUpdateData,
+ serverTimezone,
+ isWindows: txEnv.isWindows,
+ isWebInterface: ctx.txVars.isWebInterface,
+ showAdvanced: (txDevEnv.ENABLED || console.isVerbose),
+ 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,
+
+ //Login page info
+ server: {
+ name: txCore.cacheStore.getTyped('fxsRuntime:projectName', isString) ?? txConfig.general.serverName,
+ game: txCore.cacheStore.getTyped('fxsRuntime:gameName', isString),
+ icon: txCore.cacheStore.getTyped('fxsRuntime:iconFilename', isString),
+ },
+
+ //auth
+ preAuth: authedAdmin && authedAdmin.getAuthData(),
+ };
+
+ //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.devModules = txDevEnv.ENABLED ? devModulesScript : '';
+
+ //Prepare custom themes style tag
+ if (tmpCustomThemes.length) {
+ const cssThemes = [];
+ for (const theme of tmpCustomThemes) {
+ const cssVars = [];
+ for (const [name, value] of Object.entries(theme.style)) {
+ cssVars.push(`--${name}: ${value};`);
+ }
+ cssThemes.push(`.theme-${theme.name} { ${cssVars.join(' ')} }`);
+ }
+ replacers.customThemesStyle = ``;
+ } else {
+ replacers.customThemesStyle = '';
+ }
+
+ //Setting the theme class from the cookie
+ const themeCookie = ctx.cookies.get('txAdmin-theme');
+ if (themeCookie) {
+ if (tmpDefaultThemes.includes(themeCookie)) {
+ replacers.htmlClasses = themeCookie;
+ } else {
+ const selectedCustomTheme = tmpCustomThemes.find((theme) => theme.name === themeCookie);
+ if (!selectedCustomTheme) {
+ replacers.htmlClasses = tmpDefaultTheme;
+ } else {
+ const lightDarkSelector = selectedCustomTheme.isDark ? 'dark' : 'light';
+ replacers.htmlClasses = `${lightDarkSelector} theme-${selectedCustomTheme.name}`;
+ }
+ }
+ } else {
+ replacers.htmlClasses = tmpDefaultTheme;
+ }
+
+ //Replace
+ let htmlOut = htmlFile;
+ for (const [placeholder, value] of Object.entries(replacers)) {
+ const replacerRegex = new RegExp(`()?`, 'g');
+ htmlOut = htmlOut.replaceAll(replacerRegex, value);
+ }
+
+ //If in prod mode and NUI, replace the entry point with the local one
+ //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"`);
+ }
+
+ return htmlOut;
+}
diff --git a/core/modules/WebServer/index.ts b/core/modules/WebServer/index.ts
new file mode 100644
index 0000000..df4d1f0
--- /dev/null
+++ b/core/modules/WebServer/index.ts
@@ -0,0 +1,270 @@
+const modulename = 'WebServer';
+import crypto from 'node:crypto';
+import path from 'node:path';
+import HttpClass from 'node:http';
+
+import Koa from 'koa';
+import KoaBodyParser from 'koa-bodyparser';
+import KoaCors from '@koa/cors';
+
+import { Server as SocketIO } from 'socket.io';
+import WebSocket from './webSocket';
+
+import { customAlphabet } from 'nanoid';
+import dict49 from 'nanoid-dictionary/nolookalikes';
+
+import { txDevEnv, txEnv, txHostConfig } from '@core/globalData';
+import router from './router';
+import consoleFactory from '@lib/console';
+import topLevelMw from './middlewares/topLevelMw';
+import ctxVarsMw from './middlewares/ctxVarsMw';
+import ctxUtilsMw from './middlewares/ctxUtilsMw';
+import { SessionMemoryStorage, koaSessMw, socketioSessMw } from './middlewares/sessionMws';
+import checkRateLimit from './middlewares/globalRateLimiter';
+import checkHttpLoad from './middlewares/httpLoadMonitor';
+import cacheControlMw from './middlewares/cacheControlMw';
+import fatalError from '@lib/fatalError';
+import { isProxy } from 'node:util/types';
+import serveStaticMw from './middlewares/serveStaticMw';
+import serveRuntimeMw from './middlewares/serveRuntimeMw';
+const console = consoleFactory(modulename);
+const nanoid = customAlphabet(dict49, 32);
+
+
+/**
+ * Module for the web server and socket.io.
+ * It defines behaviors through middlewares, and instantiates the Koa app and the SocketIO server.
+ */
+export default class WebServer {
+ public isListening = false;
+ public isServing = false;
+ private sessionCookieName: string;
+ public luaComToken: string;
+ //setupKoa
+ private app: Koa;
+ public sessionStore: SessionMemoryStorage;
+ private koaCallback: (req: any, res: any) => Promise;
+ //setupWebSocket
+ private io: SocketIO;
+ public webSocket: WebSocket;
+ //setupServerCallbacks
+ private httpServer?: HttpClass.Server;
+
+ constructor() {
+ //Generate cookie key & luaComToken
+ const pathHash = crypto.createHash('shake256', { outputLength: 6 })
+ .update(txEnv.profilePath)
+ .digest('hex');
+ this.sessionCookieName = `tx:${pathHash}`;
+ this.luaComToken = nanoid();
+
+
+ // ===================
+ // 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.
+ // this.app.proxy = true;
+
+ //Setting up app
+ //@ts-ignore: no clue what this error is, but i'd bet it's just bad koa types
+ this.app.on('error', (error, ctx) => {
+ if (!(
+ error.code?.startsWith('HPE_')
+ || error.code?.startsWith('ECONN')
+ || error.code === 'EPIPE'
+ || error.code === 'ECANCELED'
+ )) {
+ console.error(`Probably harmless error on ${ctx.path}`);
+ console.dir(error);
+ }
+ });
+
+ //Disable CORS on dev mode
+ if (txDevEnv.ENABLED) {
+ this.app.use(KoaCors());
+ }
+
+ //Setting up timeout/error/no-output/413
+ this.app.use(topLevelMw);
+
+ //Setting up additional middlewares:
+ this.app.use(serveRuntimeMw);
+ this.app.use(serveStaticMw({
+ noCaching: txDevEnv.ENABLED,
+ cacheMaxAge: 30 * 60, //30 minutes
+ //Scan Limits: (v8-dev prod build: 56 files, 11.25MB)
+ limits: {
+ MAX_BYTES: 75 * 1024 * 1024, //75MB
+ MAX_FILES: 300,
+ MAX_DEPTH: 10,
+ MAX_TIME: 2 * 60 * 1000, //2 minutes
+ },
+ roots: [
+ txDevEnv.ENABLED
+ ? path.join(txDevEnv.SRC_PATH, 'panel/public')
+ : path.join(txEnv.txaPath, 'panel'),
+ path.join(txEnv.txaPath, 'web/public'),
+ ],
+ onReady: () => {
+ this.isServing = true;
+ },
+ }));
+
+ this.app.use(KoaBodyParser({
+ // Heavy bodies can cause v8 mem exhaustion during a POST DDoS.
+ // The heaviest JSON is the /intercom/resources endpoint.
+ // Conservative estimate: 768kb/300b = 2621 resources
+ jsonLimit: '768kb',
+ }));
+
+ //Custom stuff
+ this.sessionStore = new SessionMemoryStorage();
+ this.app.use(cacheControlMw);
+ this.app.use(koaSessMw(this.sessionCookieName, this.sessionStore));
+ this.app.use(ctxVarsMw);
+ this.app.use(ctxUtilsMw);
+
+ //Setting up routes
+ const txRouter = router();
+ this.app.use(txRouter.routes());
+ this.app.use(txRouter.allowedMethods());
+ this.app.use(async (ctx) => {
+ if (typeof ctx._matchedRoute === 'undefined') {
+ if (ctx.path.startsWith('/legacy')) {
+ ctx.status = 404;
+ console.verbose.warn(`Request 404 error: ${ctx.path}`);
+ return ctx.send('Not found.');
+ } else if (ctx.path.endsWith('.map')) {
+ ctx.status = 404;
+ return ctx.send('Not found.');
+ } else {
+ return ctx.utils.serveReactIndex();
+ }
+ }
+ });
+ this.koaCallback = this.app.callback();
+
+
+ // ===================
+ // Setting up SocketIO
+ // ===================
+ this.io = new SocketIO(HttpClass.createServer(), { serveClient: false });
+ this.io.use(socketioSessMw(this.sessionCookieName, this.sessionStore));
+ this.webSocket = new WebSocket(this.io);
+ //@ts-ignore
+ this.io.on('connection', this.webSocket.handleConnection.bind(this.webSocket));
+
+
+ // ===================
+ // Setting up Callbacks
+ // ===================
+ this.setupServerCallbacks();
+ }
+
+
+ /**
+ * Handler for all HTTP requests
+ * Note: i gave up on typing these
+ */
+ httpCallbackHandler(req: any, res: any) {
+ //Calls the appropriate callback
+ try {
+ // console.debug(`HTTP ${req.method} ${req.url}`);
+ if (!checkHttpLoad()) return;
+ if (!checkRateLimit(req?.socket?.remoteAddress)) return;
+ if (req.url.startsWith('/socket.io')) {
+ (this.io.engine as any).handleRequest(req, res);
+ } else {
+ this.koaCallback(req, res);
+ }
+ } catch (error) { }
+ }
+
+
+ /**
+ * Setup the HTTP server callbacks
+ */
+ setupServerCallbacks() {
+ //Just in case i want to re-execute this function
+ this.isListening = false;
+
+ //HTTP Server
+ try {
+ const listenErrorHandler = (error: any) => {
+ if (error.code !== 'EADDRINUSE') return;
+ fatalError.WebServer(0, [
+ `Failed to start HTTP server, port ${error.port} is already in use.`,
+ 'Maybe you already have another txAdmin running in this port.',
+ 'If you want to run multiple txAdmin instances, check the documentation for the port convar.',
+ 'You can also try restarting the host machine.',
+ ]);
+ };
+ //@ts-ignore
+ this.httpServer = HttpClass.createServer(this.httpCallbackHandler.bind(this));
+ // this.httpServer = HttpClass.createServer((req, res) => {
+ // // const reqSize = parseInt(req.headers['content-length'] || '0');
+ // // if (req.method === 'POST' && reqSize > 0) {
+ // // console.debug(chalk.yellow(bytes(reqSize)), `HTTP ${req.method} ${req.url}`);
+ // // }
+
+ // this.httpCallbackHandler(req, res);
+ // // if(checkRateLimit(req?.socket?.remoteAddress)){
+ // // this.httpCallbackHandler(req, res);
+ // // }else {
+ // // req.destroy();
+ // // }
+ // });
+ this.httpServer.on('error', listenErrorHandler);
+
+ const netInterface = txHostConfig.netInterface ?? '0.0.0.0';
+ if (txHostConfig.netInterface) {
+ console.warn(`Starting with interface ${txHostConfig.netInterface}.`);
+ console.warn('If the HTTP server doesn\'t start, this is probably the reason.');
+ }
+
+ this.httpServer.listen(txHostConfig.txaPort, netInterface, async () => {
+ //Sanity check on globals, to _guarantee_ all routes will have access to them
+ if (!txCore || isProxy(txCore) || !txConfig || !txManager) {
+ console.dir({
+ txCore: Boolean(txCore),
+ txCoreType: isProxy(txCore) ? 'proxy' : 'not proxy',
+ txConfig: Boolean(txConfig),
+ txManager: Boolean(txManager),
+ });
+ fatalError.WebServer(2, [
+ 'The HTTP server started before the globals were ready.',
+ 'This error should NEVER happen.',
+ 'Please report it to the developers.',
+ ]);
+ }
+ if (txHostConfig.netInterface) {
+ console.ok(`Listening on ${netInterface}.`);
+ }
+ this.isListening = true;
+ });
+ } catch (error) {
+ fatalError.WebServer(1, 'Failed to start HTTP server.', error);
+ }
+ }
+
+
+ /**
+ * handler for the shutdown event
+ */
+ public handleShutdown() {
+ return this.webSocket.handleShutdown();
+ }
+
+
+ /**
+ * Resetting lua comms token - called by fxRunner on spawnServer()
+ */
+ resetToken() {
+ this.luaComToken = nanoid();
+ console.verbose.debug('Resetting luaComToken.');
+ }
+};
diff --git a/core/modules/WebServer/middlewares/authMws.ts b/core/modules/WebServer/middlewares/authMws.ts
new file mode 100644
index 0000000..7ebf6ab
--- /dev/null
+++ b/core/modules/WebServer/middlewares/authMws.ts
@@ -0,0 +1,185 @@
+const modulename = 'WebServer:AuthMws';
+import consoleFactory from '@lib/console';
+import { checkRequestAuth } from "../authLogic";
+import { ApiAuthErrorResp, ApiToastResp, GenericApiErrorResp } from "@shared/genericApiTypes";
+import { InitializedCtx } from '../ctxTypes';
+import { txHostConfig } from '@core/globalData';
+const console = consoleFactory(modulename);
+
+const webLogoutPage = `
+
+ User logged out.
+ Redirecting to login page ...
+
+`;
+
+
+/**
+ * For the hosting provider routes
+ */
+export const hostAuthMw = async (ctx: InitializedCtx, next: Function) => {
+ const docs = 'https://aka.cfx.re/txadmin-env-config';
+
+ //Token disabled
+ if (txHostConfig.hostApiToken === 'disabled') {
+ return await next();
+ }
+
+ //Token undefined
+ if (!txHostConfig.hostApiToken) {
+ return ctx.send({
+ error: 'token not configured',
+ desc: 'need to configure the TXHOST_API_TOKEN environment variable to be able to use the status endpoint',
+ docs,
+ });
+ }
+
+ //Token available
+ let tokenProvided: string | undefined;
+ const headerToken = ctx.headers['x-txadmin-envtoken'];
+ if (typeof headerToken === 'string' && headerToken) {
+ tokenProvided = headerToken;
+ }
+ const paramsToken = ctx.query.envtoken;
+ if (typeof paramsToken === 'string' && paramsToken) {
+ tokenProvided = paramsToken;
+ }
+ if (headerToken && paramsToken) {
+ return ctx.send({
+ error: 'token conflict',
+ desc: 'cannot use both header and query token',
+ docs,
+ });
+ }
+ if (!tokenProvided) {
+ return ctx.send({
+ error: 'token missing',
+ desc: 'a token needs to be provided in the header or query string',
+ docs,
+ });
+ }
+ if (tokenProvided !== txHostConfig.hostApiToken) {
+ return ctx.send({
+ error: 'invalid token',
+ desc: 'the token provided does not match the TXHOST_API_TOKEN environment variable',
+ docs,
+ });
+ }
+
+ return await next();
+};
+
+
+/**
+ * Intercom auth middleware
+ * This does not set ctx.admin and does not use session/cookies whatsoever.
+ * FIXME: add isLocalAddress check?
+ */
+export const intercomAuthMw = async (ctx: InitializedCtx, next: Function) => {
+ if (
+ typeof ctx.request.body?.txAdminToken !== 'string'
+ || ctx.request.body.txAdminToken !== txCore.webServer.luaComToken
+ ) {
+ return ctx.send({ error: 'invalid token' });
+ }
+
+ await next();
+};
+
+/**
+ * Used for the legacy web interface.
+ */
+export const webAuthMw = async (ctx: InitializedCtx, next: Function) => {
+ //Check auth
+ const authResult = checkRequestAuth(
+ ctx.request.headers,
+ ctx.ip,
+ ctx.txVars.isLocalRequest,
+ ctx.sessTools
+ );
+ if (!authResult.success) {
+ ctx.sessTools.destroy();
+ if (authResult.rejectReason) {
+ console.verbose.warn(`Invalid session auth: ${authResult.rejectReason}`);
+ }
+ return ctx.send(webLogoutPage);
+ }
+
+ //Adding the admin to the context
+ ctx.admin = authResult.admin;
+ await next();
+};
+
+/**
+ * API Authentication Middleware
+ */
+export const apiAuthMw = async (ctx: InitializedCtx, next: Function) => {
+ const sendTypedResp = (data: ApiAuthErrorResp | (ApiToastResp & GenericApiErrorResp)) => ctx.send(data);
+
+ //Check auth
+ const authResult = checkRequestAuth(
+ ctx.request.headers,
+ ctx.ip,
+ ctx.txVars.isLocalRequest,
+ ctx.sessTools
+ );
+ if (!authResult.success) {
+ ctx.sessTools.destroy();
+ if (authResult.rejectReason && (authResult.rejectReason !== 'nui_admin_not_found' || console.isVerbose)) {
+ console.verbose.warn(`Invalid session auth: ${authResult.rejectReason}`);
+ }
+ return sendTypedResp({
+ logout: true,
+ reason: authResult.rejectReason ?? 'no session'
+ });
+ }
+
+ //For web routes, we need to check the CSRF token
+ //For nui routes, we need to check the luaComToken, which is already done in nuiAuthLogic above
+ if (ctx.txVars.isWebInterface) {
+ const sessToken = authResult.admin?.csrfToken; //it should exist for nui because of authLogic
+ const headerToken = ctx.headers['x-txadmin-csrftoken'];
+ if (!sessToken || !headerToken || sessToken !== headerToken) {
+ console.verbose.warn(`Invalid CSRF token: ${ctx.path}`);
+ const msg = (headerToken)
+ ? 'Error: Invalid CSRF token, please refresh the page or try to login again.'
+ : 'Error: Missing HTTP header \'x-txadmin-csrftoken\'. This likely means your files are not updated or you are using some reverse proxy that is removing this header from the HTTP request.';
+
+ //Doing ApiAuthErrorResp & GenericApiErrorResp to maintain compatibility with all routes
+ //"error" is used by diagnostic, masterActions, playerlist, whitelist and possibly more
+ return sendTypedResp({
+ type: 'error',
+ msg: msg,
+ error: msg
+ });
+ }
+ }
+
+ //Adding the admin to the context
+ ctx.admin = authResult.admin;
+ await next();
+};
diff --git a/core/modules/WebServer/middlewares/cacheControlMw.ts b/core/modules/WebServer/middlewares/cacheControlMw.ts
new file mode 100644
index 0000000..6b26d5f
--- /dev/null
+++ b/core/modules/WebServer/middlewares/cacheControlMw.ts
@@ -0,0 +1,16 @@
+import type { CtxWithVars } from "../ctxTypes";
+import type { Next } from 'koa';
+
+/**
+ * Middleware responsible for setting the cache control headers (disabling it entirely)
+ * Since this comes after the koa-static middleware, it will only apply to the web routes
+ * This is important because even our react index.html is actually SSR with auth context
+ */
+export default async function cacheControlMw(ctx: CtxWithVars, next: Next) {
+ ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate');
+ ctx.set('Surrogate-Control', 'no-store');
+ ctx.set('Expires', '0');
+ ctx.set('Pragma', 'no-cache');
+
+ return next();
+};
diff --git a/core/modules/WebServer/middlewares/ctxUtilsMw.ts b/core/modules/WebServer/middlewares/ctxUtilsMw.ts
new file mode 100644
index 0000000..ab97cf1
--- /dev/null
+++ b/core/modules/WebServer/middlewares/ctxUtilsMw.ts
@@ -0,0 +1,229 @@
+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 { txDevEnv, txEnv, txHostConfig } from '@core/globalData';
+import consoleFactory from '@lib/console';
+import getReactIndex, { tmpCustomThemes } from '../getReactIndex';
+import { CtxTxVars } from './ctxVarsMw';
+import { Next } from 'koa';
+import { CtxWithVars } from '../ctxTypes';
+import consts from '@shared/consts';
+import { AuthedAdminType } from '../authLogic';
+const console = consoleFactory(modulename);
+
+//Types
+export type CtxTxUtils = {
+ send: (data: T) => void;
+ utils: {
+ render: (view: string, data?: { headerTitle?: string, [key: string]: any }) => Promise;
+ error: (httpStatus?: number, message?: string) => void;
+ serveReactIndex: () => Promise;
+ legacyNavigateToPage: (href: string) => void;
+ };
+}
+
+//Helper functions
+const xss = xssInstancer();
+const getRenderErrorText = (view: string, error: Error, data: any) => {
+ console.error(`Error rendering ${view}.`);
+ console.verbose.dir(error);
+ if (data?.discord?.token) data.discord.token = '[redacted]';
+ return [
+ '',
+ `Error rendering '${view}'.`,
+ `Message: ${xss(error.message)}`,
+ 'The data provided was:',
+ '================',
+ xss(JSON.stringify(data, null, 2)),
+ ' ',
+ ].join('\n');
+};
+const getWebViewPath = (view: string) => {
+ if (view.includes('..')) throw new Error('Path Traversal?');
+ return path.join(txEnv.txaPath, 'web', view + '.ejs');
+};
+const getJavascriptConsts = (allConsts: NonNullable = {}) => {
+ return Object.entries(allConsts)
+ .map(([name, val]) => `const ${name} = ${JSON.stringify(val)};`)
+ .join(' ');
+};
+function getEjsOptions(filePath: string) {
+ const webTemplateRoot = path.resolve(txEnv.txaPath, 'web');
+ const webCacheDir = path.resolve(txEnv.txaPath, 'web-cache', filePath);
+ return {
+ cache: true,
+ filename: webCacheDir,
+ root: webTemplateRoot,
+ views: [webTemplateRoot],
+ rmWhitespace: true,
+ async: true,
+ };
+}
+
+//Consts
+const templateCache = new Map();
+const RESOURCE_PATH = 'nui://monitor/web/public/';
+
+const legacyNavigateHtmlTemplate = `
+
+ Redirecting to {{href}} ...
+
+`
+
+
+/**
+ * Loads re-usable base templates
+ */
+async function loadWebTemplate(name: string) {
+ if (txDevEnv.ENABLED || !templateCache.has(name)) {
+ try {
+ const rawTemplate = await fsp.readFile(getWebViewPath(name), 'utf-8');
+ const compiled = ejs.compile(rawTemplate, getEjsOptions(name + '.ejs'));
+ templateCache.set(name, compiled);
+ } catch (error) {
+ if ((error as any).code == 'ENOENT') {
+ throw new Error([
+ `The '${name}' template was not found:`,
+ `You probably deleted the 'citizen/system_resources/monitor/web/${name}.ejs' file, or the folders above it.`
+ ].join('\n'));
+ } else {
+ throw error;
+ }
+ }
+ }
+
+ return templateCache.get(name);
+}
+
+
+/**
+ * Renders normal views.
+ * Footer and header are configured inside the view template itself.
+ */
+async function renderView(
+ view: string,
+ possiblyAuthedAdmin: AuthedAdminType | undefined,
+ data: any,
+ txVars: CtxTxVars,
+) {
+ data.adminUsername = possiblyAuthedAdmin?.name ?? 'unknown user';
+ data.adminIsMaster = possiblyAuthedAdmin && possiblyAuthedAdmin.isMaster;
+ data.profilePicture = possiblyAuthedAdmin?.profilePicture ?? 'img/default_avatar.png';
+ data.isTempPassword = possiblyAuthedAdmin && possiblyAuthedAdmin.isTempPassword;
+ data.isLinux = !txEnv.isWindows;
+ data.showAdvanced = (txDevEnv.ENABLED || console.isVerbose);
+
+ try {
+ return await loadWebTemplate(view).then(template => template(data));
+ } catch (error) {
+ return getRenderErrorText(view, error as Error, data);
+ }
+}
+
+
+/**
+ * Middleware that adds some helper functions and data to the koa ctx object
+ */
+export default async function ctxUtilsMw(ctx: CtxWithVars, next: Next) {
+ //Shortcuts
+ const isWebInterface = ctx.txVars.isWebInterface;
+
+ //Functions
+ const renderUtil = async (view: string, data?: { headerTitle?: string, [key: string]: any }) => {
+ //Typescript is very annoying
+ const possiblyAuthedAdmin = ctx.admin as AuthedAdminType | undefined;
+
+ //Setting up legacy theme
+ let legacyTheme = '';
+ const themeCookie = ctx.cookies.get('txAdmin-theme');
+ if (!themeCookie || themeCookie === 'dark' || !isWebInterface) {
+ legacyTheme = 'theme--dark';
+ } else {
+ const selectorTheme = tmpCustomThemes.find((theme) => theme.name === themeCookie);
+ if (selectorTheme?.isDark) {
+ legacyTheme = 'theme--dark';
+ }
+ }
+
+ // Setting up default render data:
+ const baseViewData = {
+ isWebInterface,
+ basePath: (isWebInterface) ? '/' : consts.nuiWebpipePath,
+ resourcePath: (isWebInterface) ? '' : RESOURCE_PATH,
+ serverName: txConfig.general.serverName,
+ uiTheme: legacyTheme,
+ fxServerVersion: txEnv.fxsVersionTag,
+ txAdminVersion: txEnv.txaVersion,
+ hostConfigSource: txHostConfig.sourceName,
+ jsInjection: getJavascriptConsts({
+ isWebInterface: isWebInterface,
+ csrfToken: possiblyAuthedAdmin?.csrfToken ?? 'not_set',
+ TX_BASE_PATH: (isWebInterface) ? '' : consts.nuiWebpipePath,
+ PAGE_TITLE: data?.headerTitle ?? 'txAdmin',
+ }),
+ };
+
+ const renderData = Object.assign(baseViewData, data);
+ ctx.body = await renderView(view, possiblyAuthedAdmin, renderData, ctx.txVars);
+ ctx.type = 'text/html';
+ };
+
+ const errorUtil = (httpStatus = 500, message = 'unknown error') => {
+ ctx.status = httpStatus;
+ ctx.body = {
+ status: 'error',
+ code: httpStatus,
+ message,
+ };
+ };
+
+ //Legacy page util to navigate parent (react) to some page
+ //NOTE: in use by deployer/stepper.js and setup/get.js
+ const legacyNavigateToPage = (href: string) => {
+ ctx.body = legacyNavigateHtmlTemplate.replace(/{{href}}/g, href);
+ ctx.type = 'text/html';
+ }
+
+ const serveReactIndex = async () => {
+ ctx.body = await getReactIndex(ctx);
+ ctx.type = 'text/html';
+ };
+
+ //Injecting utils and forwarding
+ ctx.utils = {
+ render: renderUtil,
+ error: errorUtil,
+ serveReactIndex,
+ legacyNavigateToPage,
+ };
+ ctx.send = (data: T) => {
+ ctx.body = data;
+ };
+ return next();
+};
diff --git a/core/modules/WebServer/middlewares/ctxVarsMw.ts b/core/modules/WebServer/middlewares/ctxVarsMw.ts
new file mode 100644
index 0000000..b104122
--- /dev/null
+++ b/core/modules/WebServer/middlewares/ctxVarsMw.ts
@@ -0,0 +1,62 @@
+const modulename = 'WebServer:SetupVarsMw';
+import consoleFactory from '@lib/console';
+import consts from '@shared/consts';
+const console = consoleFactory(modulename);
+import { Next } from "koa";
+import { CtxWithSession } from '../ctxTypes';
+import { isIpAddressLocal } from '@lib/host/isIpAddressLocal';
+
+//The custom tx-related vars set to the ctx
+export type CtxTxVars = {
+ isWebInterface: boolean;
+ realIP: string;
+ isLocalRequest: boolean;
+ hostType: 'localhost' | 'ip' | 'other';
+};
+
+
+/**
+ * Middleware responsible for setting up the ctx.txVars
+ */
+const ctxVarsMw = (ctx: CtxWithSession, next: Next) => {
+ //Prepare variables
+ const txVars: CtxTxVars = {
+ isWebInterface: typeof ctx.headers['x-txadmin-token'] !== 'string',
+ realIP: ctx.ip,
+ isLocalRequest: isIpAddressLocal(ctx.ip),
+ hostType: 'other',
+ };
+
+ //Setting up the user's host type
+ const host = ctx.request.host ?? 'none';
+ if (host.startsWith('localhost') || host.startsWith('127.')) {
+ txVars.hostType = 'localhost';
+ } else if (/^\d+\.\d+\.\d+\.\d+(?::\d+)?$/.test(host)) {
+ txVars.hostType = 'ip';
+ }
+
+ //Setting up the user's real ip from the webpipe
+ //NOTE: not used anywhere except rate limiter, and login logs.
+ if (
+ typeof ctx.headers['x-txadmin-identifiers'] === 'string'
+ && typeof ctx.headers['x-txadmin-token'] === 'string'
+ && ctx.headers['x-txadmin-token'] === txCore.webServer.luaComToken
+ && txVars.isLocalRequest
+ ) {
+ const ipIdentifier = ctx.headers['x-txadmin-identifiers']
+ .split(',')
+ .find((i) => i.substring(0, 3) === 'ip:');
+ if (ipIdentifier) {
+ const srcIP = ipIdentifier.substring(3);
+ if (consts.regexValidIP.test(srcIP)) {
+ txVars.realIP = srcIP;
+ }
+ }
+ }
+
+ //Injecting vars and continuing
+ ctx.txVars = txVars;
+ return next();
+}
+
+export default ctxVarsMw;
diff --git a/core/modules/WebServer/middlewares/globalRateLimiter.ts b/core/modules/WebServer/middlewares/globalRateLimiter.ts
new file mode 100644
index 0000000..05d8f95
--- /dev/null
+++ b/core/modules/WebServer/middlewares/globalRateLimiter.ts
@@ -0,0 +1,97 @@
+const modulename = 'WebServer:RateLimiter';
+import consoleFactory from '@lib/console';
+import { isIpAddressLocal } from '@lib/host/isIpAddressLocal';
+const console = consoleFactory(modulename);
+
+
+/*
+ Expected requests per user per minute:
+ 50 usual
+ 800 live console with ingame + 2 web pages
+ 2300 very very very heavy behavior
+*/
+
+
+//Config
+const DDOS_THRESHOLD = 20_000;
+const MAX_RPM_DEFAULT = 5000;
+const MAX_RPM_UNDER_ATTACK = 2500;
+const DDOS_COOLDOWN_MINUTES = 15;
+
+//Vars
+const bannedIps = new Set();
+const reqsPerIp = new Map();
+let httpRequestsCounter = 0;
+let bansPendingWarn: string[] = [];
+let minutesSinceLastAttack = Number.MAX_SAFE_INTEGER;
+
+
+/**
+ * Process the counts and declares a DDoS or not, as well as warns of new banned IPs.
+ * Note if the requests are under the DDOS_THRESHOLD, banned ips will be immediately unbanned, so
+ * in this case the rate limiter will only serve to limit instead of banning these IPs.
+ */
+setInterval(() => {
+ if (httpRequestsCounter > DDOS_THRESHOLD) {
+ minutesSinceLastAttack = 0;
+ const numberFormatter = new Intl.NumberFormat('en-US');
+ console.majorMultilineError([
+ 'You might be under a DDoS attack!',
+ `txAdmin got ${numberFormatter.format(httpRequestsCounter)} HTTP requests in the last minute.`,
+ `The attacker IP addresses have been blocked until ${DDOS_COOLDOWN_MINUTES} mins after the attack stops.`,
+ 'Make sure you have a proper firewall setup and/or a reverse proxy with rate limiting.',
+ 'You can join https://discord.gg/txAdmin for support.'
+ ]);
+ } else {
+ minutesSinceLastAttack++;
+ if (minutesSinceLastAttack > DDOS_COOLDOWN_MINUTES) {
+ bannedIps.clear();
+ }
+ }
+ httpRequestsCounter = 0;
+ reqsPerIp.clear();
+ if (bansPendingWarn.length) {
+ console.warn('IPs blocked:', bansPendingWarn.join(', '));
+ bansPendingWarn = [];
+ }
+}, 60_000);
+
+
+/**
+ * Checks if an IP is allowed to make a request based on the rate limit per IP.
+ * The rate limit ignores local IPs.
+ * The limits are calculated based on requests per minute, which varies if under attack or not.
+ * All bans are cleared 15 minutes after the attack stops.
+ */
+const checkRateLimit = (remoteAddress: string) => {
+ // Sanity check on the ip
+ if (typeof remoteAddress !== 'string' || !remoteAddress.length) return false;
+
+ // Counting requests per minute
+ httpRequestsCounter++;
+
+ // Whitelist all local addresses
+ if (isIpAddressLocal(remoteAddress)) return true;
+
+ // Checking if the IP is banned
+ if (bannedIps.has(remoteAddress)) return false;
+
+ // Check rate and count request
+ const reqsCount = reqsPerIp.get(remoteAddress);
+ if (reqsCount !== undefined) {
+ const limit = minutesSinceLastAttack < DDOS_COOLDOWN_MINUTES
+ ? MAX_RPM_UNDER_ATTACK
+ : MAX_RPM_DEFAULT;
+ if (reqsCount > limit) {
+ bannedIps.add(remoteAddress);
+ bansPendingWarn.push(remoteAddress);
+ return false;
+ }
+ reqsPerIp.set(remoteAddress, reqsCount + 1);
+ } else {
+ reqsPerIp.set(remoteAddress, 1);
+ }
+ return true;
+}
+
+export default checkRateLimit;
diff --git a/core/modules/WebServer/middlewares/httpLoadMonitor.ts b/core/modules/WebServer/middlewares/httpLoadMonitor.ts
new file mode 100644
index 0000000..4d1b2c0
--- /dev/null
+++ b/core/modules/WebServer/middlewares/httpLoadMonitor.ts
@@ -0,0 +1,80 @@
+const modulename = 'WebServer:PacketDropper';
+import v8 from 'node:v8';
+import bytes from 'bytes';
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+
+//Config
+const RPM_CHECK_INTERVAL = 800; //multiples of 100 only
+const HIGH_LOAD_RPM = 15_000;
+const HEAP_USED_LIMIT = 0.9;
+const REQ_BLOCKER_DURATION = 60; //seconds
+
+//Vars
+let reqCounter = 0;
+let lastCheckTime = Date.now();
+let isUnderHighLoad = false;
+let acceptRequests = true;
+
+//Helpers
+const getHeapUsage = () => {
+ const heapStats = v8.getHeapStatistics();
+ return {
+ heapSize: bytes(heapStats.used_heap_size),
+ heapLimit: bytes(heapStats.heap_size_limit),
+ heapUsed: (heapStats.used_heap_size / heapStats.heap_size_limit),
+ };
+}
+
+/**
+ * Protects txAdmin against a massive HTTP load, no matter the source of the requests.
+ * It will print warnings if the server is under high load and block requests if heap is almost full.
+ */
+const checkHttpLoad = () => {
+ if (!acceptRequests) return false;
+
+ //Check RPM
+ reqCounter++;
+ if (reqCounter >= RPM_CHECK_INTERVAL) {
+ reqCounter = 0;
+ const now = Date.now();
+ const elapsedMs = now - lastCheckTime;
+ lastCheckTime = now;
+ const requestsPerMinute = Math.ceil((RPM_CHECK_INTERVAL / elapsedMs) * 60_000);
+
+ if (requestsPerMinute > HIGH_LOAD_RPM) {
+ isUnderHighLoad = true;
+ console.warn(`txAdmin is under high HTTP load: ~${Math.round(requestsPerMinute / 1000)}k reqs/min.`);
+ } else {
+ isUnderHighLoad = false;
+ // console.debug(`${requestsPerMinute.toLocaleString()} rpm`);
+ }
+ }
+
+ //Every 100 requests if under high load
+ if (isUnderHighLoad && reqCounter % 100 === 0) {
+ const { heapSize, heapLimit, heapUsed } = getHeapUsage();
+ // console.debug((heapUsed * 100).toFixed(2) + '% heap');
+ if (heapUsed > HEAP_USED_LIMIT) {
+ console.majorMultilineError([
+ `Node.js's V8 engine heap is almost full: ${(heapUsed * 100).toFixed(2)}% (${heapSize}/${heapLimit})`,
+ `All HTTP requests will be blocked for the next ${REQ_BLOCKER_DURATION} seconds to prevent a crash.`,
+ 'Make sure you have a proper firewall setup and/or a reverse proxy with rate limiting.',
+ 'You can join https://discord.gg/txAdmin for support.'
+ ]);
+ //Resetting counter
+ reqCounter = 0;
+
+ //Blocking requests + setting a timeout to unblock
+ acceptRequests = false;
+ setTimeout(() => {
+ acceptRequests = true;
+ console.warn('HTTP requests are now allowed again.');
+ }, REQ_BLOCKER_DURATION * 1000);
+ }
+ }
+
+ return acceptRequests;
+};
+
+export default checkHttpLoad;
diff --git a/core/modules/WebServer/middlewares/serveRuntimeMw.ts b/core/modules/WebServer/middlewares/serveRuntimeMw.ts
new file mode 100644
index 0000000..c3190e2
--- /dev/null
+++ b/core/modules/WebServer/middlewares/serveRuntimeMw.ts
@@ -0,0 +1,116 @@
+const modulename = 'WebServer:ServeStaticMw';
+import path from 'path';
+import consoleFactory from '@lib/console';
+import type { Next } from "koa";
+import type { RawKoaCtx } from '../ctxTypes';
+import { txDevEnv, txEnv } from '@core/globalData';
+import { getCompressedFile, type CompressionResult } from './serveStaticMw';
+const console = consoleFactory(modulename);
+
+
+type RuntimeFile = CompressionResult & {
+ mime: string;
+};
+type RuntimeFileCached = RuntimeFile & {
+ name: string;
+ date: string;
+};
+
+class LimitedCacheArray extends Array {
+ constructor(public readonly limit: number) {
+ super();
+ }
+ add(name: string, file: RuntimeFile) {
+ if (this.length >= this.limit) this.shift();
+ const toCache: RuntimeFileCached = {
+ name,
+ date: new Date().toUTCString(),
+ ...file,
+ };
+ super.push(toCache);
+ return toCache;
+ }
+}
+const runtimeCache = new LimitedCacheArray(50);
+
+
+const getServerIcon = async (fileName: string): Promise => {
+ const fileRegex = /^icon-(?[a-f0-9]{16})\.png(?:\?.*|$)/;
+ const iconHash = fileName.match(fileRegex)?.groups?.hash;
+ if (!iconHash) return undefined;
+ const localPath = path.resolve(txEnv.txaPath, '.runtime', `icon-${iconHash}.png`);
+ const fileData = await getCompressedFile(localPath);
+ return {
+ ...fileData,
+ mime: 'image/png'
+ };
+}
+
+
+const serveFile = async (ctx: RawKoaCtx, file: RuntimeFileCached) => {
+ ctx.type = file.mime;
+ if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip') {
+ ctx.set('Content-Encoding', 'gzip');
+ ctx.body = file.gz;
+ } else {
+ ctx.body = file.raw;
+ }
+ if (txDevEnv.ENABLED) {
+ ctx.set('Cache-Control', `public, max-age=0`);
+ } else {
+ ctx.set('Cache-Control', `public, max-age=1800`); //30 minutes
+ ctx.set('Last-Modified', file.date);
+ }
+}
+
+
+/**
+ * Middleware responsible for serving all the /.runtime/ files
+ */
+export default async function serveRuntimeMw(ctx: RawKoaCtx, next: Next) {
+ //Middleware pre-condition
+ if (!ctx.path.startsWith('/.runtime/') || ctx.method !== 'GET') {
+ return await next();
+ }
+
+ const fileNameRegex = /^\/\.runtime\/(?[^\/#\?]{3,64})/;
+ const fileName = ctx.path.match(fileNameRegex)?.groups?.file;
+ if (!fileName) {
+ return await next();
+ }
+
+ //Try to serve from cache first
+ for (let i = 0; i < runtimeCache.length; i++) {
+ const currCachedFile = runtimeCache[i];
+ if (currCachedFile.name === fileName) {
+ serveFile(ctx, currCachedFile);
+ return;
+ }
+ }
+
+ const handleError = (error: any) => {
+ console.verbose.error(`Failed serve runtime file: ${fileName}`);
+ console.verbose.dir(error);
+ ctx.status = 500;
+ ctx.body = 'Internal Server Error';
+ }
+
+ //Server icon
+ let runtimeFile: RuntimeFile | undefined;
+ try {
+ runtimeFile = await getServerIcon(fileName);
+ } catch (error) {
+ if ((error as any).code !== 'ENOENT') {
+ return handleError(error);
+ }
+ }
+
+ //If the file was not found
+ if (runtimeFile) {
+ const cached = runtimeCache.add(fileName, runtimeFile);
+ serveFile(ctx, cached);
+ } else {
+ ctx.status = 404;
+ ctx.body = 'File not found';
+ }
+}
diff --git a/core/modules/WebServer/middlewares/serveStaticMw.ts b/core/modules/WebServer/middlewares/serveStaticMw.ts
new file mode 100644
index 0000000..326105f
--- /dev/null
+++ b/core/modules/WebServer/middlewares/serveStaticMw.ts
@@ -0,0 +1,348 @@
+const modulename = 'WebServer:ServeStaticMw';
+import path from 'path';
+import fs from 'node:fs';
+import fsp from 'node:fs/promises';
+import consoleFactory from '@lib/console';
+import type { Next } from "koa";
+import type { RawKoaCtx } from '../ctxTypes';
+import zlib from 'node:zlib';
+import bytes from 'bytes';
+import { promisify } from 'util';
+const console = consoleFactory(modulename);
+
+const gzip = promisify(zlib.gzip);
+
+class ScanLimitError extends Error {
+ constructor(
+ code: string,
+ upperLimit: number,
+ current: number,
+ ) {
+ super(`${code}: ${current} > ${upperLimit}`);
+ }
+}
+
+
+/**
+ * MARK: Types
+ */
+type ScanLimits = {
+ MAX_BYTES?: number;
+ MAX_FILES?: number;
+ MAX_DEPTH?: number;
+ MAX_TIME?: number;
+}
+
+type ServeStaticMwOpts = {
+ noCaching: boolean;
+ onReady: () => void;
+ cacheMaxAge: number;
+ roots: string[];
+ limits: ScanLimits;
+}
+
+type ScanFolderState = {
+ files: StaticFileCache[],
+ bytes: number,
+ tsStart: number,
+ elapsedMs: number,
+}
+
+type ScanFolderOpts = {
+ rootPath: string,
+ state: ScanFolderState,
+ limits: ScanLimits,
+}
+
+export type CompressionResult = {
+ raw: Buffer,
+ gz: Buffer,
+}
+type StaticFilePath = {
+ url: string,
+}
+type StaticFileCache = CompressionResult & StaticFilePath;
+
+
+/**
+ * MARK: Caching Methods
+ */
+//Compression helper
+const compressGzip = async (buffer: Buffer) => {
+ return gzip(buffer, { chunkSize: 64 * 1024, level: 4 });
+};
+
+
+//Reads and compresses a file
+export const getCompressedFile = async (fullPath: string) => {
+ const raw = await fsp.readFile(fullPath);
+ const gz = await compressGzip(raw);
+ return { raw, gz };
+};
+
+
+//FIXME: This is a temporary function
+const checkFileWhitelist = (rootPath: string, url: string) => {
+ if (!rootPath.endsWith('panel')) return true;
+ const nonHashedFiles = [
+ '/favicon_default.svg',
+ '/favicon_offline.svg',
+ '/favicon_online.svg',
+ '/favicon_partial.svg',
+ '/index.html',
+ '/img/discord.png',
+ '/img/zap_login.png',
+ '/img/zap_main.png'
+ ];
+ return nonHashedFiles.includes(url) || url.includes('.v800.');
+}
+
+
+//Scans a folder and returns all files processed with size and count limits
+export const scanStaticFolder = async ({ rootPath, state, limits }: ScanFolderOpts) => {
+ //100ms precision for elapsedMs
+ let timerId
+ if (limits.MAX_TIME) {
+ timerId = setInterval(() => {
+ state.elapsedMs = Date.now() - state.tsStart;
+ }, 100);
+ }
+
+ let addedFiles = 0;
+ let addedBytes = 0;
+ const foldersToScan: string[][] = [[]];
+ while (foldersToScan.length > 0) {
+ const currFolderPath = foldersToScan.pop()!;
+ const currentFolderUrl = path.posix.join(...currFolderPath);
+ const currentFolderAbs = path.join(rootPath, ...currFolderPath);
+
+ //Ensure we don't go over the limits
+ if (limits.MAX_DEPTH && currFolderPath.length > limits.MAX_DEPTH) {
+ throw new ScanLimitError('MAX_DEPTH', limits.MAX_DEPTH, currFolderPath.length);
+ }
+
+ const entries = await fsp.readdir(currentFolderAbs, { withFileTypes: true });
+ for (const entry of entries) {
+ if (limits.MAX_FILES && state.files.length > limits.MAX_FILES) {
+ console.error('MAX_FILES ERROR', 'This likely means you did not erase the previous artifact files before adding new ones.');
+ throw new ScanLimitError('MAX_FILES', limits.MAX_FILES, state.files.length);
+ } else if (limits.MAX_BYTES && state.bytes > limits.MAX_BYTES) {
+ console.error('MAX_BYTES ERROR', 'This likely means you did not erase the previous artifact files before adding new ones.');
+ throw new ScanLimitError('MAX_BYTES', limits.MAX_BYTES, state.bytes);
+ } else if (limits.MAX_TIME && state.elapsedMs > limits.MAX_TIME) {
+ throw new ScanLimitError('MAX_TIME', limits.MAX_TIME, state.elapsedMs);
+ }
+
+ if (entry.isDirectory()) {
+ //Queue the folder for scanning
+ if (entry.name !== 'node_modules' && !entry.name.startsWith('.')) {
+ foldersToScan.push([...currFolderPath, entry.name]);
+ }
+ } else if (entry.isFile()) {
+ //Process the file
+ const entryPathUrl = '/' + path.posix.join(currentFolderUrl, entry.name);
+ if (!checkFileWhitelist(rootPath, entryPathUrl)) {
+ console.verbose.debug(`Skipping unknown file ${entryPathUrl}`);
+ continue;
+ }
+
+ const entryPathAbs = path.join(currentFolderAbs, entry.name);
+ const fileData = await getCompressedFile(entryPathAbs);
+ state.bytes += fileData.raw.length;
+ state.files.push({
+ url: entryPathUrl,
+ ...fileData,
+ });
+ addedFiles++;
+ addedBytes += fileData.raw.length;
+ }
+ }
+ }
+
+ if (timerId) {
+ clearInterval(timerId);
+ }
+
+ return { addedFiles, addedBytes };
+};
+
+
+//Pre-processes all the static files
+const preProcessFiles = async (opts: ServeStaticMwOpts) => {
+ const scanState: ScanFolderState = {
+ files: [],
+ bytes: 0,
+ tsStart: Date.now(),
+ elapsedMs: 0,
+ }
+ for (const rootPath of opts.roots) {
+ const res = await scanStaticFolder({
+ rootPath,
+ state: scanState,
+ limits: opts.limits,
+ });
+ console.verbose.debug(`Cached ${res.addedFiles} (${bytes(res.addedBytes)}) files from '${rootPath}'.`);
+ }
+
+ const gzSize = scanState.files.reduce((acc, file) => acc + file.gz.length, 0);
+ // console.dir({
+ // rawSize: bytes(rawSize),
+ // gzSize: bytes(gzSize),
+ // gzPct: (gzSize / rawSize * 100).toFixed(2) + '%',
+ // });
+ return {
+ files: scanState.files,
+ memSize: scanState.bytes + gzSize,
+ };
+}
+
+
+/**
+ * MARK: Cache Bootstrap
+ */
+const cacheDate = new Date().toUTCString(); //probably fine :P
+let cachedFiles: StaticFileCache[] | undefined;
+let bootstrapPromise: Promise | null = null;
+let bootstrapLastRun: number | null = null;
+
+// Bootstraps the cache with state tracking
+const bootstrapCache = async (opts: ServeStaticMwOpts): Promise => {
+ // Skip if already bootstrapped or running
+ if (bootstrapPromise) return bootstrapPromise;
+ if (cachedFiles) return;
+
+ const now = Date.now();
+ if (bootstrapLastRun && now - bootstrapLastRun < 15 * 1000) {
+ return console.warn('bootstrapCache recently failed, skipping new attempt for cooldown.');
+ }
+
+ bootstrapLastRun = now;
+ bootstrapPromise = (async () => {
+ const tsStart = now;
+ const { files, memSize } = await preProcessFiles(opts);
+ cachedFiles = files;
+ const elapsed = Date.now() - tsStart;
+ console.verbose.debug(`Cached ${files.length} static files (${bytes(memSize)} in memory) in ${elapsed}ms`);
+ opts.onReady();
+ })();
+
+ try {
+ await bootstrapPromise;
+ } finally {
+ bootstrapPromise = null; // Clear the promise regardless of success or failure
+ }
+};
+
+
+/**
+ * MARK: Prod Middleware
+ */
+const serveStaticMwProd = (opts: ServeStaticMwOpts) => async (ctx: RawKoaCtx, next: Next) => {
+ if (ctx.method !== 'HEAD' && ctx.method !== 'GET') {
+ return await next();
+ }
+
+ // Skip bootstrap if cache is ready
+ if (!cachedFiles) {
+ try {
+ await bootstrapCache(opts);
+ } catch (error) {
+ console.error(`Failed to bootstrap static files cache:`);
+ console.dir(error);
+ }
+ }
+ if (!cachedFiles) {
+ return ctx.throw(503, 'Service Unavailable: Static files cache not ready');
+ }
+
+ //Check if the file is in the cache
+ let staticFile: StaticFileCache | undefined;
+ for (let i = 0; i < cachedFiles.length; i++) {
+ const currCachedFile = cachedFiles[i];
+ if (currCachedFile.url === ctx.path) {
+ staticFile = currCachedFile;
+ break;
+ }
+ }
+ if (!staticFile) return await next();
+
+ //Check if the client supports gzip
+ //NOTE: dropped brotli, it's not worth the hassle
+ if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip') {
+ ctx.set('Content-Encoding', 'gzip');
+ ctx.body = staticFile.gz;
+ } else {
+ ctx.body = staticFile.raw;
+ }
+
+ //Determine the MIME type based on the original file extension
+ ctx.type = path.extname(staticFile.url); // This sets the appropriate Content-Type header based on the extension
+
+ //Set the client caching behavior (kinda conflicts with cacheControlMw)
+ //NOTE: The legacy URLs already contain the `txVer` param to bust the cache, so 30 minutes should be fine
+ ctx.set('Cache-Control', `public, max-age=${opts.cacheMaxAge}`);
+ ctx.set('Last-Modified', cacheDate);
+};
+
+
+/**
+ * MARK: Dev Middleware
+ */
+const serveStaticMwDev = (opts: ServeStaticMwOpts) => async (ctx: RawKoaCtx, next: Next) => {
+ if (ctx.method !== 'HEAD' && ctx.method !== 'GET') {
+ return await next();
+ }
+
+ const isValidPath = (urlPath: string) => {
+ if (urlPath[0] !== '/') return false;
+ if (urlPath.indexOf('\0') !== -1) return false;
+ const traversalRegex = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
+ if (traversalRegex.test(path.normalize('./' + urlPath))) return false;
+ if (!path.extname(urlPath)) return false;
+ return true;
+ }
+ if (!isValidPath(ctx.path)) return await next();
+
+ const tryAcquireFileStream = async (filePath: string) => {
+ try {
+ const stat = await fsp.stat(filePath);
+ if (stat.isFile()) {
+ return fs.createReadStream(filePath);
+ }
+ } catch (error) {
+ if ((error as any).code === 'ENOENT') return;
+ console.error(`Failed to create file read stream: ${filePath}`);
+ console.dir(error);
+ }
+ }
+
+ //Look for it in the roots
+ let readStream: fs.ReadStream | undefined;
+ for (const rootPath of opts.roots) {
+ readStream = await tryAcquireFileStream(path.join(rootPath, ctx.path));
+ if (readStream) break;
+ }
+ if (!readStream) return await next();
+
+ ctx.body = readStream;
+ ctx.type = path.extname(ctx.path);
+ ctx.set('Cache-Control', `public, max-age=0`);
+}
+
+
+/**
+ * Middleware responsible for serving all the static files.
+ * For prod environments, it will cache all the files in memory, pre-compressed.
+ */
+export default function serveStaticMw(opts: ServeStaticMwOpts) {
+ if (opts.noCaching) {
+ opts.onReady();
+ return serveStaticMwDev(opts);
+ } else {
+ bootstrapCache(opts).catch((error) => {
+ console.error(`Failed to bootstrap static files cache:`);
+ console.dir(error);
+ });
+ return serveStaticMwProd(opts);
+ }
+}
diff --git a/core/modules/WebServer/middlewares/sessionMws.ts b/core/modules/WebServer/middlewares/sessionMws.ts
new file mode 100644
index 0000000..d9ce39c
--- /dev/null
+++ b/core/modules/WebServer/middlewares/sessionMws.ts
@@ -0,0 +1,189 @@
+import { UserInfoType } from "@modules/AdminStore/providers/CitizenFX";
+import type { CfxreSessAuthType, PassSessAuthType } from "../authLogic";
+import { LRUCacheWithDelete } from "mnemonist";
+import { RawKoaCtx } from "../ctxTypes";
+import { Next } from "koa";
+import { randomUUID } from 'node:crypto';
+import { Socket } from "socket.io";
+import { parse as cookieParse } from 'cookie';
+import { SetOption as KoaCookieSetOption } from "cookies";
+import type { DeepReadonly } from 'utility-types';
+
+//Types
+export type ValidSessionType = {
+ auth?: PassSessAuthType | CfxreSessAuthType;
+ tmpOauthLoginStateKern?: string; //uuid v4
+ tmpOauthLoginCallbackUri?: string; //the URI provided to the IDMS as a callback
+ tmpAddMasterUserInfo?: UserInfoType;
+}
+export type SessToolsType = {
+ get: () => DeepReadonly | undefined;
+ set: (sess: ValidSessionType) => void;
+ destroy: () => void;
+}
+type StoredSessionType = {
+ expires: number;
+ data: ValidSessionType;
+}
+
+/**
+ * Storage for the sessions
+ */
+export class SessionMemoryStorage {
+ private readonly sessions = new LRUCacheWithDelete(5000);
+ public readonly maxAgeMs = 24 * 60 * 60 * 1000;
+
+ constructor(maxAgeMs?: number) {
+ if (maxAgeMs) {
+ this.maxAgeMs = maxAgeMs;
+ }
+
+ //Cleanup every 5 mins
+ setInterval(() => {
+ const now = Date.now();
+ for (const [key, sess] of this.sessions) {
+ if (sess.expires < now) {
+ this.sessions.delete(key);
+ }
+ }
+ }, 5 * 60_000);
+ }
+
+ get(key: string) {
+ const stored = this.sessions.get(key);
+ if (!stored) return;
+ if (stored.expires < Date.now()) {
+ this.sessions.delete(key);
+ return;
+ }
+ return stored.data as DeepReadonly;
+ }
+
+ set(key: string, sess: ValidSessionType) {
+ this.sessions.set(key, {
+ expires: Date.now() + this.maxAgeMs,
+ data: sess,
+ });
+ }
+
+ refresh(key: string) {
+ const stored = this.sessions.get(key);
+ if (!stored) return;
+ this.sessions.set(key, {
+ expires: Date.now() + this.maxAgeMs,
+ data: stored.data,
+ });
+ }
+
+ destroy(key: string) {
+ return this.sessions.delete(key);
+ }
+
+ get size() {
+ return this.sessions.size;
+ }
+}
+
+
+/**
+ * Helper to check if the session id is valid
+ */
+const isValidSessId = (sessId: string) => {
+ if (typeof sessId !== 'string') return false;
+ if (sessId.length !== 36) return false;
+ return true;
+}
+
+
+/**
+ * Middleware factory to add sessTools to the koa context.
+ */
+export const koaSessMw = (cookieName: string, store: SessionMemoryStorage) => {
+ const cookieOptions = {
+ path: '/',
+ maxAge: store.maxAgeMs,
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: false,
+ overwrite: true,
+ signed: false,
+
+ } as KoaCookieSetOption;
+
+ //Middleware
+ return (ctx: RawKoaCtx, next: Next) => {
+ const sessGet = () => {
+ const sessId = ctx.cookies.get(cookieName);
+ if (!sessId || !isValidSessId(sessId)) return;
+ const stored = store.get(sessId);
+ if (!stored) return;
+ ctx._refreshSessionCookieId = sessId;
+ return stored;
+ }
+
+ const sessSet = (sess: ValidSessionType) => {
+ const sessId = ctx.cookies.get(cookieName);
+ if (!sessId || !isValidSessId(sessId)) {
+ const newSessId = randomUUID();
+ ctx.cookies.set(cookieName, newSessId, cookieOptions);
+ store.set(newSessId, sess);
+ } else {
+ store.set(sessId, sess);
+ }
+ }
+
+ const sessDestroy = () => {
+ const sessId = ctx.cookies.get(cookieName);
+ if (!sessId || !isValidSessId(sessId)) return;
+ store.destroy(sessId);
+ ctx.cookies.set(cookieName, 'unset', cookieOptions);
+ }
+
+ ctx.sessTools = {
+ get: sessGet,
+ set: sessSet,
+ destroy: sessDestroy,
+ } satisfies SessToolsType;
+
+ try {
+ return next();
+ } catch (error) {
+ throw error;
+ } finally {
+ if (typeof ctx._refreshSessionCookieId === 'string') {
+ ctx.cookies.set(cookieName, ctx._refreshSessionCookieId, cookieOptions);
+ store.refresh(ctx._refreshSessionCookieId);
+ }
+ }
+ }
+}
+
+
+/**
+ * Middleware factory to add sessTools to the socket context.
+ *
+ * NOTE: The set() and destroy() functions are NO-OPs because we cannot set cookies in socket.io,
+ * but that's fine since socket pages are always acompanied by a web page
+ * the authLogic only needs to get the cookie, and the webAuthMw only destroys it
+ * and webSocket.handleConnection() just drops if authLogic fails.
+ */
+export const socketioSessMw = (cookieName: string, store: SessionMemoryStorage) => {
+ return async (socket: Socket & { sessTools?: SessToolsType }, next: Function) => {
+ const sessGet = () => {
+ const cookiesString = socket?.handshake?.headers?.cookie;
+ if (typeof cookiesString !== 'string') return;
+ const cookies = cookieParse(cookiesString);
+ const sessId = cookies[cookieName];
+ if (!sessId || !isValidSessId(sessId)) return;
+ return store.get(sessId);
+ }
+
+ socket.sessTools = {
+ get: sessGet,
+ set: (sess: ValidSessionType) => { },
+ destroy: () => { },
+ } satisfies SessToolsType;
+
+ return next();
+ }
+}
diff --git a/core/modules/WebServer/middlewares/topLevelMw.ts b/core/modules/WebServer/middlewares/topLevelMw.ts
new file mode 100644
index 0000000..6efa48f
--- /dev/null
+++ b/core/modules/WebServer/middlewares/topLevelMw.ts
@@ -0,0 +1,108 @@
+const modulename = 'WebServer:TopLevelMw';
+import { txEnv } from '@core/globalData';
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+import { Next } from "koa";
+import { RawKoaCtx } from '../ctxTypes';
+
+//Token Bucket (Rate Limiter)
+const maxTokens = 20;
+const tokensPerInterval = 5;
+let availableTokens = maxTokens;
+let suppressedErrors = 0;
+setInterval(() => {
+ availableTokens = Math.min(availableTokens + tokensPerInterval, maxTokens);
+ if (suppressedErrors) {
+ console.warn(`Suppressed ${suppressedErrors} errors to prevent log spam.`);
+ suppressedErrors = 0;
+ }
+}, 5_000);
+const consumePrintToken = () => {
+ if (availableTokens > 0) {
+ availableTokens--;
+ return true;
+ }
+ suppressedErrors++;
+ return false;
+}
+
+
+//Consts
+const timeoutLimit = 47 * 1000; //REQ_TIMEOUT_REALLY_REALLY_LONG is 45s
+
+/**
+ * Middleware responsible for timeout/error/no-output/413
+ */
+const topLevelMw = async (ctx: RawKoaCtx, next: Next) => {
+ ctx.set('Server', `txAdmin v${txEnv.txaVersion}`);
+ let timerId;
+ const timeout = new Promise((_, reject) => {
+ timerId = setTimeout(() => {
+ reject(new Error('route_timed_out'));
+ }, timeoutLimit);
+ });
+ try {
+ await Promise.race([timeout, next()]);
+ if (typeof ctx.body == 'undefined' || (typeof ctx.body == 'string' && !ctx.body.length)) {
+ console.verbose.warn(`Route without output: ${ctx.path}`);
+ return ctx.body = '[no output from route]';
+ }
+ } catch (e) {
+ const error = e as any; //this has all been previously validated
+ const prefix = `[txAdmin v${txEnv.txaVersion}]`;
+ const reqPath = (ctx.path.length > 80) ? `${ctx.path.slice(0, 77)}...` : ctx.path;
+ const methodName = (error.stack && error.stack[0] && error.stack[0].name) ? error.stack[0].name : 'anonym';
+
+ //NOTE: I couldn't force xss on path message, but just in case I'm forcing it here
+ //but it is overwritten by koa when we set the body to an object, which is fine
+ ctx.type = 'text/plain';
+ ctx.set('X-Content-Type-Options', 'nosniff');
+
+ //NOTE: not using HTTP logger endpoint anymore, FD3 only
+ if (error.type === 'entity.too.large') {
+ const desc = `Entity too large for: ${reqPath}`;
+ ctx.status = 413;
+ ctx.body = { error: desc };
+ if (consumePrintToken()) console.verbose.error(desc, methodName);
+ } else if (error.type === 'stream.not.readable') {
+ const desc = `Stream Not Readable: ${reqPath}`;
+ ctx.status = 422; //"Unprocessable Entity" kinda matches
+ ctx.body = { error: desc };
+ if (consumePrintToken()) console.verbose.warn(desc, methodName);
+ } else if (error.message === 'route_timed_out') {
+ const desc = `${prefix} Route timed out: ${reqPath}`;
+ ctx.status = 408;
+ ctx.body = desc;
+ if (consumePrintToken()) console.error(desc, methodName);
+ } else if (error.message === 'Malicious Path' || error.message === 'failed to decode') {
+ const desc = `${prefix} Malicious Path: ${reqPath}`;
+ ctx.status = 406;
+ ctx.body = desc;
+ if (consumePrintToken()) console.verbose.error(desc, methodName);
+ } else if (error.message.startsWith('Unexpected token')) {
+ const desc = `${prefix} Invalid JSON for: ${reqPath}`;
+ ctx.status = 400;
+ ctx.body = { error: desc };
+ if (consumePrintToken()) console.verbose.error(desc, methodName);
+ } else {
+ const desc = [
+ `${prefix} Internal Error.`,
+ `Route: ${reqPath}`,
+ `Message: ${error.message}`,
+ 'Make sure your txAdmin is updated.',
+ ].join('\n');
+ ctx.status = 500;
+ ctx.body = desc;
+ if (consumePrintToken()) {
+ console.error(desc, methodName);
+ console.verbose.dir(error);
+ }
+ }
+ } finally {
+ //Cannot forget about this or the ctx will only be released from memory after the timeout,
+ //making it easier to crash the server in a DDoS attack
+ clearTimeout(timerId);
+ }
+}
+
+export default topLevelMw;
diff --git a/core/modules/WebServer/router.ts b/core/modules/WebServer/router.ts
new file mode 100644
index 0000000..b5a0d8d
--- /dev/null
+++ b/core/modules/WebServer/router.ts
@@ -0,0 +1,134 @@
+import { txDevEnv } from '@core/globalData';
+import Router from '@koa/router';
+import KoaRateLimit from 'koa-ratelimit';
+
+import * as routes from '@routes/index';
+import { apiAuthMw, hostAuthMw, intercomAuthMw, webAuthMw } from './middlewares/authMws';
+
+
+/**
+ * Router factory
+ */
+export default () => {
+ const router = new Router();
+ const authLimiter = KoaRateLimit({
+ driver: 'memory',
+ db: new Map(),
+ duration: txConfig.webServer.limiterMinutes * 60 * 1000, // 15 minutes
+ errorMessage: JSON.stringify({
+ //Duplicated to maintain compatibility with all auth api routes
+ error: `Too many attempts. Blocked for ${txConfig.webServer.limiterMinutes} minutes.`,
+ errorTitle: 'Too many attempts.',
+ errorMessage: `Blocked for ${txConfig.webServer.limiterMinutes} minutes.`,
+ }),
+ max: txConfig.webServer.limiterAttempts,
+ disableHeader: true,
+ id: (ctx: any) => ctx.txVars.realIP,
+ });
+
+ //Rendered Pages
+ router.get('/legacy/adminManager', webAuthMw, routes.adminManager_page);
+ router.get('/legacy/advanced', webAuthMw, routes.advanced_page);
+ router.get('/legacy/cfgEditor', webAuthMw, routes.cfgEditor_page);
+ router.get('/legacy/diagnostics', webAuthMw, routes.diagnostics_page);
+ router.get('/legacy/masterActions', webAuthMw, routes.masterActions_page);
+ router.get('/legacy/resources', webAuthMw, routes.resources);
+ router.get('/legacy/serverLog', webAuthMw, routes.serverLog);
+ router.get('/legacy/whitelist', webAuthMw, routes.whitelist_page);
+ router.get('/legacy/setup', webAuthMw, routes.setup_get);
+ router.get('/legacy/deployer', webAuthMw, routes.deployer_stepper);
+
+ //Authentication
+ router.get('/auth/self', apiAuthMw, routes.auth_self);
+ router.post('/auth/password', authLimiter, routes.auth_verifyPassword);
+ router.post('/auth/logout', authLimiter, routes.auth_logout);
+ router.post('/auth/addMaster/pin', authLimiter, routes.auth_addMasterPin);
+ router.post('/auth/addMaster/callback', authLimiter, routes.auth_addMasterCallback);
+ router.post('/auth/addMaster/save', authLimiter, routes.auth_addMasterSave);
+ router.get('/auth/cfxre/redirect', authLimiter, routes.auth_providerRedirect);
+ router.post('/auth/cfxre/callback', authLimiter, routes.auth_providerCallback);
+ router.post('/auth/changePassword', apiAuthMw, routes.auth_changePassword);
+ router.get('/auth/getIdentifiers', apiAuthMw, routes.auth_getIdentifiers);
+ router.post('/auth/changeIdentifiers', apiAuthMw, routes.auth_changeIdentifiers);
+
+ //Admin Manager
+ router.post('/adminManager/getModal/:modalType', webAuthMw, routes.adminManager_getModal);
+ router.post('/adminManager/:action', apiAuthMw, routes.adminManager_actions);
+
+ //Settings
+ router.post('/setup/:action', apiAuthMw, routes.setup_post);
+ router.get('/deployer/status', apiAuthMw, routes.deployer_status);
+ router.post('/deployer/recipe/:action', apiAuthMw, routes.deployer_actions);
+ router.get('/settings/configs', apiAuthMw, routes.settings_getConfigs);
+ router.post('/settings/configs/:card', apiAuthMw, routes.settings_saveConfigs);
+ router.get('/settings/banTemplates', apiAuthMw, routes.settings_getBanTemplates);
+ router.post('/settings/banTemplates', apiAuthMw, routes.settings_saveBanTemplates);
+ router.post('/settings/resetServerDataPath', apiAuthMw, routes.settings_resetServerDataPath);
+
+ //Master Actions
+ router.get('/masterActions/backupDatabase', webAuthMw, routes.masterActions_getBackup);
+ router.post('/masterActions/:action', apiAuthMw, routes.masterActions_actions);
+
+ //FXServer
+ router.post('/fxserver/controls', apiAuthMw, routes.fxserver_controls);
+ router.post('/fxserver/commands', apiAuthMw, routes.fxserver_commands);
+ router.get('/fxserver/downloadLog', webAuthMw, routes.fxserver_downloadLog);
+ router.post('/fxserver/schedule', apiAuthMw, routes.fxserver_schedule);
+
+ //CFG Editor
+ router.post('/cfgEditor/save', apiAuthMw, routes.cfgEditor_save);
+
+ //Control routes
+ router.post('/intercom/:scope', intercomAuthMw, routes.intercom);
+
+ //Diagnostic routes
+ router.post('/diagnostics/sendReport', apiAuthMw, routes.diagnostics_sendReport);
+ router.post('/advanced', apiAuthMw, routes.advanced_actions);
+
+ //Data routes
+ router.get('/serverLog/partial', apiAuthMw, routes.serverLogPartial);
+ router.get('/systemLog/:scope', apiAuthMw, routes.systemLogs);
+ router.get('/perfChartData/:thread', apiAuthMw, routes.perfChart);
+ router.get('/playerDropsData', apiAuthMw, routes.playerDrops);
+
+ /*
+ FIXME: reorganizar TODAS rotas de logs, incluindo listagem e download
+ /logs/:logpage - WEB
+ /logs/:log/list - API
+ /logs/:log/partial - API
+ /logs/:log/download - WEB
+
+ */
+
+ //History routes
+ router.get('/history/stats', apiAuthMw, routes.history_stats);
+ router.get('/history/search', apiAuthMw, routes.history_search);
+ router.get('/history/action', apiAuthMw, routes.history_actionModal);
+ router.post('/history/:action', apiAuthMw, routes.history_actions);
+
+ //Player routes
+ router.get('/player', apiAuthMw, routes.player_modal);
+ router.get('/player/stats', apiAuthMw, routes.player_stats);
+ router.get('/player/search', apiAuthMw, routes.player_search);
+ router.post('/player/checkJoin', intercomAuthMw, routes.player_checkJoin);
+ router.post('/player/:action', apiAuthMw, routes.player_actions);
+ router.get('/whitelist/:table', apiAuthMw, routes.whitelist_list);
+ router.post('/whitelist/:table/:action', apiAuthMw, routes.whitelist_actions);
+
+ //Host routes
+ router.get('/host/status', hostAuthMw, routes.host_status);
+
+ //DevDebug routes - no auth
+ if (txDevEnv.ENABLED) {
+ router.get('/dev/:scope', routes.dev_get);
+ router.post('/dev/:scope', routes.dev_post);
+ };
+
+ //Insights page mock
+ // router.get('/insights', (ctx) => {
+ // return ctx.utils.render('main/insights', { headerTitle: 'Insights' });
+ // });
+
+ //Return router
+ return router;
+};
diff --git a/core/modules/WebServer/webSocket.ts b/core/modules/WebServer/webSocket.ts
new file mode 100644
index 0000000..1771cf8
--- /dev/null
+++ b/core/modules/WebServer/webSocket.ts
@@ -0,0 +1,293 @@
+const modulename = 'WebSocket';
+import { Server as SocketIO, Socket, RemoteSocket } from 'socket.io';
+import consoleFactory from '@lib/console';
+import statusRoom from './wsRooms/status';
+import dashboardRoom from './wsRooms/dashboard';
+import playerlistRoom from './wsRooms/playerlist';
+import liveconsoleRoom from './wsRooms/liveconsole';
+import serverlogRoom from './wsRooms/serverlog';
+import { AuthedAdminType, checkRequestAuth } from './authLogic';
+import { SocketWithSession } from './ctxTypes';
+import { isIpAddressLocal } from '@lib/host/isIpAddressLocal';
+import { txEnv } from '@core/globalData';
+const console = consoleFactory(modulename);
+
+//Types
+export type RoomCommandHandlerType = {
+ permission: string | true;
+ handler: (admin: AuthedAdminType, ...args: any) => any
+}
+
+export type RoomType = {
+ permission: string | true;
+ eventName: string;
+ cumulativeBuffer: boolean;
+ outBuffer: any;
+ commands?: Record;
+ initialData: () => any;
+}
+
+//NOTE: quen adding multiserver, create dynamic rooms like playerlist#
+const VALID_ROOMS = ['status', 'dashboard', 'liveconsole', 'serverlog', 'playerlist'] as const;
+type RoomNames = typeof VALID_ROOMS[number];
+
+
+//Helpers
+const getIP = (socket: SocketWithSession) => {
+ return socket?.request?.socket?.remoteAddress ?? 'unknown';
+};
+const terminateSession = (socket: SocketWithSession, reason: string, shouldLog = true) => {
+ try {
+ socket.emit('logout', reason);
+ socket.disconnect();
+ if (shouldLog) {
+ console.verbose.warn('SocketIO', 'dropping new connection:', reason);
+ }
+ } catch (error) { }
+};
+const forceUiReload = (socket: SocketWithSession) => {
+ try {
+ socket.emit('refreshToUpdate');
+ socket.disconnect();
+ } catch (error) { }
+};
+const sendShutdown = (socket: SocketWithSession) => {
+ try {
+ socket.emit('txAdminShuttingDown');
+ socket.disconnect();
+ } catch (error) { }
+};
+
+export default class WebSocket {
+ readonly #io: SocketIO;
+ readonly #rooms: Record;
+ #eventBuffer: { name: string, data: any }[] = [];
+
+ constructor(io: SocketIO) {
+ this.#io = io;
+ this.#rooms = {
+ status: statusRoom,
+ dashboard: dashboardRoom,
+ playerlist: playerlistRoom,
+ liveconsole: liveconsoleRoom,
+ serverlog: serverlogRoom,
+ };
+
+ setInterval(this.flushBuffers.bind(this), 250);
+ }
+
+
+ /**
+ * Sends a shutdown signal to all connected clients
+ */
+ public async handleShutdown() {
+ const sockets = await this.#io.fetchSockets();
+ for (const socket of sockets) {
+ //@ts-ignore
+ sendShutdown(socket);
+ }
+ }
+
+
+ /**
+ * Refreshes the auth data for all connected admins
+ * If an admin is not authed anymore, they will be disconnected
+ * If an admin lost permission to a room, they will be kicked out of it
+ * This is called from AdminStore.refreshOnlineAdmins()
+ */
+ async reCheckAdminAuths() {
+ const sockets = await this.#io.fetchSockets();
+ console.verbose.warn(`SocketIO`, `AdminStore changed, refreshing auth for ${sockets.length} sockets.`);
+ for (const socket of sockets) {
+ //@ts-ignore
+ const reqIp = getIP(socket);
+ const authResult = checkRequestAuth(
+ socket.handshake.headers,
+ reqIp,
+ isIpAddressLocal(reqIp),
+ //@ts-ignore
+ socket.sessTools
+ );
+ if (!authResult.success) {
+ //@ts-ignore
+ return terminateSession(socket, 'session invalidated by websocket.reCheckAdminAuths()', true);
+ }
+
+ //Sending auth data update - even if nothing changed
+ const { admin: authedAdmin } = authResult;
+ socket.emit('updateAuthData', authedAdmin.getAuthData());
+
+ //Checking permission of all joined rooms
+ for (const roomName of socket.rooms) {
+ if (roomName === socket.id) continue;
+ const roomData = this.#rooms[roomName as RoomNames];
+ if (roomData.permission !== true && !authedAdmin.hasPermission(roomData.permission)) {
+ socket.leave(roomName);
+ }
+ }
+ }
+ }
+
+
+ /**
+ * Handles incoming connection requests,
+ */
+ handleConnection(socket: SocketWithSession) {
+ //Check the UI version
+ if (socket.handshake.query.uiVersion && socket.handshake.query.uiVersion !== txEnv.txaVersion) {
+ return forceUiReload(socket);
+ }
+
+ try {
+ //Checking for session auth
+ const reqIp = getIP(socket);
+ const authResult = checkRequestAuth(
+ socket.handshake.headers,
+ reqIp,
+ isIpAddressLocal(reqIp),
+ socket.sessTools
+ );
+ if (!authResult.success) {
+ return terminateSession(socket, 'invalid session', false);
+ }
+ const { admin: authedAdmin } = authResult;
+
+
+ //Check if joining any room
+ if (typeof socket.handshake.query.rooms !== 'string') {
+ return terminateSession(socket, 'no query.rooms');
+ }
+
+ //Validating requested rooms
+ const requestedRooms = socket.handshake.query.rooms
+ .split(',')
+ .filter((v, i, arr) => arr.indexOf(v) === i)
+ .filter(r => VALID_ROOMS.includes(r as any));
+ if (!requestedRooms.length) {
+ return terminateSession(socket, 'no valid room requested');
+ }
+
+ //To prevent user from receiving data duplicated in initial data and buffer data
+ //we need to flush the buffers first. This is a bit hacky, but performance shouldn't
+ //really be an issue since we are first validating the user auth.
+ this.flushBuffers();
+
+ //For each valid requested room
+ for (const requestedRoomName of requestedRooms) {
+ const room = this.#rooms[requestedRoomName as RoomNames];
+
+ //Checking Perms
+ if (room.permission !== true && !authedAdmin.hasPermission(room.permission)) {
+ continue;
+ }
+
+ //Setting up event handlers
+ for (const [commandName, commandData] of Object.entries(room.commands ?? [])) {
+ if (commandData.permission === true || authedAdmin.hasPermission(commandData.permission)) {
+ socket.on(commandName, (...args) => {
+ //Checking if admin is still in the room - perms change can make them be kicked out of room
+ if (socket.rooms.has(requestedRoomName)) {
+ commandData.handler(authedAdmin, ...args);
+ } else {
+ console.verbose.debug('SocketIO', `Command '${requestedRoomName}#${commandName}' was ignored due to admin not being in the room.`);
+ }
+ });
+ }
+ }
+
+ //Sending initial data
+ socket.join(requestedRoomName);
+ socket.emit(room.eventName, room.initialData());
+ }
+
+ //General events
+ socket.on('disconnect', (reason) => {
+ // console.verbose.debug('SocketIO', `Client disconnected with reason: ${reason}`);
+ });
+ socket.on('error', (error) => {
+ console.verbose.debug('SocketIO', `Socket error with message: ${error.message}`);
+ });
+
+ // console.verbose.log('SocketIO', `Connected: ${authedAdmin.name} from ${getIP(socket)}`);
+ } catch (error) {
+ console.error('SocketIO', `Error handling new connection: ${(error as Error).message}`);
+ socket.disconnect();
+ }
+ }
+
+
+ /**
+ * Adds data to the a room buffer
+ */
+ buffer(roomName: RoomNames, data: T) {
+ const room = this.#rooms[roomName];
+ if (!room) throw new Error('Room not found');
+
+ if (room.cumulativeBuffer) {
+ if (Array.isArray(room.outBuffer)) {
+ room.outBuffer.push(data);
+ } else if (typeof room.outBuffer === 'string') {
+ room.outBuffer += data;
+ } else {
+ throw new Error(`cumulative buffers can only be arrays or strings`);
+ }
+ } else {
+ room.outBuffer = data;
+ }
+ }
+
+
+ /**
+ * Flushes the data buffers
+ * NOTE: this will also send data to users that no longer have permissions
+ */
+ flushBuffers() {
+ //Sending room data
+ for (const [roomName, room] of Object.entries(this.#rooms)) {
+ if (room.cumulativeBuffer && room.outBuffer.length) {
+ this.#io.to(roomName).emit(room.eventName, room.outBuffer);
+ if (Array.isArray(room.outBuffer)) {
+ room.outBuffer = [];
+ } else if (typeof room.outBuffer === 'string') {
+ room.outBuffer = '';
+ } else {
+ throw new Error(`cumulative buffers can only be arrays or strings`);
+ }
+ } else if (!room.cumulativeBuffer && room.outBuffer !== null) {
+ this.#io.to(roomName).emit(room.eventName, room.outBuffer);
+ room.outBuffer = null;
+ }
+ }
+
+ //Sending events
+ for (const event of this.#eventBuffer) {
+ this.#io.emit(event.name, event.data);
+ }
+ this.#eventBuffer = [];
+ }
+
+
+ /**
+ * Pushes the initial data again for everyone in a room
+ * NOTE: we probably don't need to wait one tick, but since we are working with
+ * event handling, things might take a tick to update their status (maybe discord bot?)
+ */
+ pushRefresh(roomName: RoomNames) {
+ if (!VALID_ROOMS.includes(roomName)) throw new Error(`Invalid room '${roomName}'.`);
+ const room = this.#rooms[roomName];
+ if (room.cumulativeBuffer) throw new Error(`The room '${roomName}' has a cumulative buffer.`);
+ setImmediate(() => {
+ room.outBuffer = room.initialData();
+ });
+ }
+
+
+ /**
+ * Broadcasts an event to all connected clients
+ * This is used for data syncs that are not related to a specific room
+ * eg: update available
+ */
+ pushEvent(name: string, data: T) {
+ this.#eventBuffer.push({ name, data });
+ }
+};
diff --git a/core/modules/WebServer/wsRooms/dashboard.ts b/core/modules/WebServer/wsRooms/dashboard.ts
new file mode 100644
index 0000000..3a5e20f
--- /dev/null
+++ b/core/modules/WebServer/wsRooms/dashboard.ts
@@ -0,0 +1,45 @@
+import type { RoomType } from "../webSocket";
+import { DashboardDataEventType } from "@shared/socketioTypes";
+
+
+/**
+ * Returns the dashboard stats data
+ */
+const getInitialData = (): DashboardDataEventType => {
+ const svRuntimeStats = txCore.metrics.svRuntime.getRecentStats();
+
+ return {
+ // joinLeaveTally30m: txCore.FxPlayerlist.joinLeaveTally,
+ playerDrop: {
+ summaryLast6h: txCore.metrics.playerDrop.getRecentDropTally(6),
+ },
+ svRuntime: {
+ fxsMemory: svRuntimeStats.fxsMemory,
+ nodeMemory: svRuntimeStats.nodeMemory,
+ perfBoundaries: svRuntimeStats.perfBoundaries,
+ perfBucketCounts: svRuntimeStats.perfBucketCounts,
+ },
+ }
+}
+
+
+/**
+ * The room for the dashboard page.
+ * It relays server performance stuff and drop reason categories.
+ *
+ * NOTE:
+ * - active push event for only from Metrics.svRuntime
+ * - Metrics.playerDrop does not push events, those are sent alongside the playerlist drop event
+ * which means that if accessing from NUI (ie not joining playerlist room), the chart will only be
+ * updated when the user refreshes the page.
+ * Same goes for "last 6h" not expiring old data if the server is not online pushing new perfs.
+ */
+export default {
+ permission: true, //everyone can see it
+ eventName: 'dashboard',
+ cumulativeBuffer: false,
+ outBuffer: null,
+ initialData: () => {
+ return getInitialData();
+ },
+} satisfies RoomType;
diff --git a/core/modules/WebServer/wsRooms/liveconsole.ts b/core/modules/WebServer/wsRooms/liveconsole.ts
new file mode 100644
index 0000000..a295496
--- /dev/null
+++ b/core/modules/WebServer/wsRooms/liveconsole.ts
@@ -0,0 +1,30 @@
+import type { RoomType } from "../webSocket";
+import { AuthedAdminType } from "../authLogic";
+
+
+/**
+ * The console room is responsible for the server live console page
+ */
+export default {
+ permission: 'console.view',
+ eventName: 'consoleData',
+ cumulativeBuffer: true,
+ outBuffer: '',
+ initialData: () => txCore.logger.fxserver.getRecentBuffer(),
+ commands: {
+ consoleCommand: {
+ permission: 'console.write',
+ handler: (admin: AuthedAdminType, command: string) => {
+ if(typeof command !== 'string' || !command) return;
+ const sanitized = command.replaceAll(/\n/g, ' ');
+ admin.logCommand(sanitized);
+ txCore.fxRunner.sendRawCommand(sanitized, admin.name);
+ txCore.fxRunner.sendEvent('consoleCommand', {
+ channel: 'txAdmin',
+ command: sanitized,
+ author: admin.name,
+ });
+ }
+ },
+ },
+} satisfies RoomType;
diff --git a/core/modules/WebServer/wsRooms/playerlist.ts b/core/modules/WebServer/wsRooms/playerlist.ts
new file mode 100644
index 0000000..c1c21d5
--- /dev/null
+++ b/core/modules/WebServer/wsRooms/playerlist.ts
@@ -0,0 +1,20 @@
+import type { RoomType } from "../webSocket";
+import { FullPlayerlistEventType } from "@shared/socketioTypes";
+
+
+/**
+ * The the playerlist room is joined on all (except solo) pages when in web mode
+ */
+export default {
+ permission: true, //everyone can see it
+ eventName: 'playerlist',
+ cumulativeBuffer: true,
+ outBuffer: [],
+ initialData: () => {
+ return [{
+ mutex: txCore.fxRunner.child?.mutex ?? null,
+ type: 'fullPlayerlist',
+ playerlist: txCore.fxPlayerlist.getPlayerList(),
+ } satisfies FullPlayerlistEventType];
+ },
+} satisfies RoomType;
diff --git a/core/modules/WebServer/wsRooms/serverlog.ts b/core/modules/WebServer/wsRooms/serverlog.ts
new file mode 100644
index 0000000..bb1c73e
--- /dev/null
+++ b/core/modules/WebServer/wsRooms/serverlog.ts
@@ -0,0 +1,13 @@
+import type { RoomType } from "../webSocket";
+
+/**
+ * The console room is responsible for the server log page
+ */
+export default {
+ permission: true, //everyone can see it
+ eventName: 'logData',
+ cumulativeBuffer: true,
+ outBuffer: [],
+ initialData: () => txCore.logger.server.getRecentBuffer(500),
+ commands: {},
+} satisfies RoomType;
diff --git a/core/modules/WebServer/wsRooms/status.ts b/core/modules/WebServer/wsRooms/status.ts
new file mode 100644
index 0000000..44de676
--- /dev/null
+++ b/core/modules/WebServer/wsRooms/status.ts
@@ -0,0 +1,21 @@
+import type { RoomType } from "../webSocket";
+
+
+/**
+ * The main room is joined automatically in every txadmin page (except solo ones)
+ * It relays tx and server status data.
+ *
+ * NOTE:
+ * - active push event for FxMonitor, HostData, fxserver process
+ * - passive update for discord status, scheduler
+ * - the passive ones will be sent every 5 seconds anyways due to HostData updates
+ */
+export default {
+ permission: true, //everyone can see it
+ eventName: 'status',
+ cumulativeBuffer: false,
+ outBuffer: null,
+ initialData: () => {
+ return txManager.globalStatus;
+ },
+} satisfies RoomType;
diff --git a/core/package.json b/core/package.json
new file mode 100644
index 0000000..9b872b2
--- /dev/null
+++ b/core/package.json
@@ -0,0 +1,88 @@
+{
+ "name": "txadmin-core",
+ "version": "1.0.0",
+ "description": "The core package is the backend of txAdmin and handles running fxserver and the web server and every other internal backend stuff.",
+ "type": "module",
+ "scripts": {
+ "build": "cd ../ && npx tsx scripts/build/publish.ts",
+ "dev": "cd ../ && npx tsx scripts/build/dev.ts",
+ "test": "vitest",
+ "typecheck": "tsc -p tsconfig.json --noEmit | node ../scripts/typecheck-formatter.js",
+ "typecheck:full": "tsc -p tsconfig.json --noEmit",
+ "lint": "eslint ./**",
+ "lint:count": "eslint ./** -f ../scripts/lint-formatter.js",
+ "lint:fix": "eslint ./** --fix",
+ "license:report": "npx license-report > ../.reports/license/core.html"
+ },
+ "keywords": [],
+ "author": "André Tabarra",
+ "license": "MIT",
+ "dependencies": {
+ "@koa/cors": "^5.0.0",
+ "@koa/router": "^13.1.0",
+ "boxen": "^7.1.1",
+ "bytes": "^3.1.2",
+ "cookie": "^0.7.0",
+ "d3-array": "^3.2.4",
+ "dateformat": "^5.0.3",
+ "discord.js": "14.11.0",
+ "ejs": "^3.1.10",
+ "error-stack-parser": "^2.1.4",
+ "execa": "^5.1.1",
+ "fs-extra": "^9.1.0",
+ "fuse.js": "^7.0.0",
+ "got": "^13.0.0",
+ "is-localhost-ip": "^2.0.0",
+ "jose": "^4.15.4",
+ "js-yaml": "^4.1.0",
+ "koa": "^2.15.4",
+ "koa-bodyparser": "^4.4.1",
+ "koa-ratelimit": "^5.1.0",
+ "lodash": "^4.17.21",
+ "lowdb": "^6.1.0",
+ "mnemonist": "^0.39.8",
+ "mysql2": "^3.11.3",
+ "nanoid": "^4.0.2",
+ "nanoid-dictionary": "^4.3.0",
+ "node-polyglot": "^2.6.0",
+ "node-stream-zip": "^1.15.0",
+ "open": "7.1.0",
+ "openid-client": "^5.7.0",
+ "pidtree": "^0.6.0",
+ "pidusage": "^3.0.2",
+ "rotating-file-stream": "^3.2.5",
+ "slash": "^5.1.0",
+ "slug": "^8.2.3",
+ "socket.io": "^4.8.0",
+ "source-map-support": "^0.5.21",
+ "stream-json": "^1.9.1",
+ "string-argv": "^0.3.2",
+ "systeminformation": "^5.23.5",
+ "throttle-debounce": "^5.0.2",
+ "unicode-emoji-json": "^0.8.0",
+ "xss": "^1.0.15",
+ "zod": "^3.23.8",
+ "zod-validation-error": "^3.4.0"
+ },
+ "devDependencies": {
+ "@types/bytes": "^3.1.4",
+ "@types/d3-array": "^3.2.1",
+ "@types/dateformat": "^5.0.2",
+ "@types/ejs": "^3.1.5",
+ "@types/fs-extra": "^11.0.4",
+ "@types/js-yaml": "^4.0.9",
+ "@types/koa": "^2.15.0",
+ "@types/koa__cors": "^5.0.0",
+ "@types/koa__router": "^12.0.4",
+ "@types/koa-bodyparser": "^4.3.12",
+ "@types/koa-ratelimit": "^5.0.5",
+ "@types/nanoid-dictionary": "^4.2.3",
+ "@types/node": "^16.9.1",
+ "@types/pidusage": "^2.0.5",
+ "@types/semver": "^7.5.8",
+ "@types/slug": "^5.0.9",
+ "@types/source-map-support": "^0.5.10",
+ "@types/stream-json": "^1.7.7",
+ "windows-release": "^4.0.0"
+ }
+}
diff --git a/core/routes/adminManager/actions.ts b/core/routes/adminManager/actions.ts
new file mode 100644
index 0000000..155600b
--- /dev/null
+++ b/core/routes/adminManager/actions.ts
@@ -0,0 +1,251 @@
+const modulename = 'WebServer:AdminManagerActions';
+import { customAlphabet } from 'nanoid';
+import dict49 from 'nanoid-dictionary/nolookalikes';
+import got from '@lib/got';
+import consts from '@shared/consts';
+import consoleFactory from '@lib/console';
+import { AuthedCtx } from '@modules/WebServer/ctxTypes';
+const console = consoleFactory(modulename);
+
+//Helpers
+const nanoid = customAlphabet(dict49, 20);
+//NOTE: this desc misses that it should start and end with alphanum or _, and cannot have repeated -_.
+const nameRegexDesc = 'up to 20 characters containing only letters, numbers and the characters \`_.-\`';
+const cfxHttpReqOptions = {
+ timeout: { request: 6000 },
+};
+type ProviderDataType = {id: string, identifier: string};
+
+/**
+ * Returns the output page containing the admins.
+ */
+export default async function AdminManagerActions(ctx: AuthedCtx) {
+ //Sanity check
+ if (typeof ctx.params?.action !== 'string') {
+ return ctx.utils.error(400, 'Invalid Request');
+ }
+ const action = ctx.params.action;
+
+ //Check permissions
+ if (!ctx.admin.testPermission('manage.admins', modulename)) {
+ return ctx.send({
+ type: 'danger',
+ message: 'You don\'t have permission to execute this action.',
+ });
+ }
+
+ //Delegate to the specific action handler
+ if (action == 'add') {
+ return await handleAdd(ctx);
+ } else if (action == 'edit') {
+ return await handleEdit(ctx);
+ } else if (action == 'delete') {
+ return await handleDelete(ctx);
+ } else {
+ return ctx.send({
+ type: 'danger',
+ message: 'Unknown action.',
+ });
+ }
+};
+
+
+/**
+ * Handle Add
+ */
+async function handleAdd(ctx: AuthedCtx) {
+ //Sanity check
+ if (
+ typeof ctx.request.body.name !== 'string'
+ || typeof ctx.request.body.citizenfxID !== 'string'
+ || typeof ctx.request.body.discordID !== 'string'
+ || ctx.request.body.permissions === undefined
+ ) {
+ return ctx.utils.error(400, 'Invalid Request - missing parameters');
+ }
+
+ //Prepare and filter variables
+ const name = ctx.request.body.name.trim();
+ const password = nanoid();
+ const citizenfxID = ctx.request.body.citizenfxID.trim();
+ const discordID = ctx.request.body.discordID.trim();
+ let permissions = (Array.isArray(ctx.request.body.permissions)) ? ctx.request.body.permissions : [];
+ permissions = permissions.filter((x: unknown) => typeof x === 'string');
+ if (permissions.includes('all_permissions')) permissions = ['all_permissions'];
+
+
+ //Validate name
+ if (!consts.regexValidFivemUsername.test(name)) {
+ return ctx.send({type: 'danger', markdown: true, message: `**Invalid username, it must follow the rule:**\n${nameRegexDesc}`});
+ }
+
+ //Validate & translate FiveM ID
+ let citizenfxData: ProviderDataType | undefined;
+ if (citizenfxID.length) {
+ try {
+ citizenfxData = {
+ id: citizenfxID,
+ identifier: citizenfxID,
+ };
+ } catch (error) {
+ console.error(`Failed to resolve CitizenFX ID to game identifier with error: ${(error as Error).message}`);
+ }
+ }
+
+ //Validate Discord ID
+ let discordData: ProviderDataType | undefined;
+ if (discordID.length) {
+ if (!consts.validIdentifierParts.discord.test(discordID)) {
+ return ctx.send({type: 'danger', message: 'Invalid Discord ID'});
+ }
+ discordData = {
+ id: discordID,
+ identifier: `discord:${discordID}`,
+ };
+ }
+
+ //Check for privilege escalation
+ if (!ctx.admin.isMaster && !ctx.admin.permissions.includes('all_permissions')) {
+ const deniedPerms = permissions.filter((x: string) => !ctx.admin.permissions.includes(x));
+ if (deniedPerms.length) {
+ return ctx.send({
+ type: 'danger',
+ message: `You cannot give permissions you do not have: ${deniedPerms.join(', ')}`,
+ });
+ }
+ }
+
+ //Add admin and give output
+ try {
+ await txCore.adminStore.addAdmin(name, citizenfxData, discordData, password, permissions);
+ ctx.admin.logAction(`Adding user '${name}'.`);
+ return ctx.send({type: 'showPassword', password});
+ } catch (error) {
+ return ctx.send({type: 'danger', message: (error as Error).message});
+ }
+}
+
+
+/**
+ * Handle Edit
+ */
+async function handleEdit(ctx: AuthedCtx) {
+ //Sanity check
+ if (
+ typeof ctx.request.body.name !== 'string'
+ || typeof ctx.request.body.citizenfxID !== 'string'
+ || typeof ctx.request.body.discordID !== 'string'
+ || ctx.request.body.permissions === undefined
+ ) {
+ return ctx.utils.error(400, 'Invalid Request - missing parameters');
+ }
+
+ //Prepare and filter variables
+ const name = ctx.request.body.name.trim();
+ const citizenfxID = ctx.request.body.citizenfxID.trim();
+ const discordID = ctx.request.body.discordID.trim();
+
+ //Check if editing himself
+ if (ctx.admin.name.toLowerCase() === name.toLowerCase()) {
+ return ctx.send({type: 'danger', message: '(ERR0) You cannot edit yourself.'});
+ }
+
+ //Validate & translate permissions
+ let permissions;
+ if (Array.isArray(ctx.request.body.permissions)) {
+ permissions = ctx.request.body.permissions.filter((x: unknown) => typeof x === 'string');
+ if (permissions.includes('all_permissions')) permissions = ['all_permissions'];
+ } else {
+ permissions = [];
+ }
+
+ //Validate & translate FiveM ID
+ let citizenfxData: ProviderDataType | undefined;
+ if (citizenfxID.length) {
+ try {
+ citizenfxData = {
+ id: citizenfxID,
+ identifier: citizenfxID,
+ };
+ } catch (error) {
+ console.error(`Failed to resolve CitizenFX ID to game identifier with error: ${(error as Error).message}`);
+ }
+ }
+
+ //Validate Discord ID
+ //FIXME: you cannot remove a discord id by erasing from the field
+ let discordData: ProviderDataType | undefined;
+ if (discordID.length) {
+ if (!consts.validIdentifierParts.discord.test(discordID)) {
+ return ctx.send({type: 'danger', message: 'Invalid Discord ID'});
+ }
+ discordData = {
+ id: discordID,
+ identifier: `discord:${discordID}`,
+ };
+ }
+
+ //Check if admin exists
+ const admin = txCore.adminStore.getAdminByName(name);
+ if (!admin) return ctx.send({type: 'danger', message: 'Admin not found.'});
+
+ //Check if editing an master admin
+ if (!ctx.admin.isMaster && admin.master) {
+ return ctx.send({type: 'danger', message: 'You cannot edit an admin master.'});
+ }
+
+ //Check for privilege escalation
+ if (permissions && !ctx.admin.isMaster && !ctx.admin.permissions.includes('all_permissions')) {
+ const deniedPerms = permissions.filter((x: string) => !ctx.admin.permissions.includes(x));
+ if (deniedPerms.length) {
+ return ctx.send({
+ type: 'danger',
+ message: `You cannot give permissions you do not have: ${deniedPerms.join(', ')}`,
+ });
+ }
+ }
+
+ //Add admin and give output
+ try {
+ await txCore.adminStore.editAdmin(name, null, citizenfxData, discordData, permissions);
+ ctx.admin.logAction(`Editing user '${name}'.`);
+ return ctx.send({type: 'success', refresh: true});
+ } catch (error) {
+ return ctx.send({type: 'danger', message: (error as Error).message});
+ }
+}
+
+
+/**
+ * Handle Delete
+ */
+async function handleDelete(ctx: AuthedCtx) {
+ //Sanity check
+ if (typeof ctx.request.body.name !== 'string') {
+ return ctx.utils.error(400, 'Invalid Request - missing parameters');
+ }
+ const name = ctx.request.body.name.trim();
+
+ //Check if deleting himself
+ if (ctx.admin.name.toLowerCase() === name.toLowerCase()) {
+ return ctx.send({type: 'danger', message: "You can't delete yourself."});
+ }
+
+ //Check if admin exists
+ const admin = txCore.adminStore.getAdminByName(name);
+ if (!admin) return ctx.send({type: 'danger', message: 'Admin not found.'});
+
+ //Check if editing an master admin
+ if (admin.master) {
+ return ctx.send({type: 'danger', message: 'You cannot delete an admin master.'});
+ }
+
+ //Delete admin and give output
+ try {
+ await txCore.adminStore.deleteAdmin(name);
+ ctx.admin.logAction(`Deleting user '${name}'.`);
+ return ctx.send({type: 'success', refresh: true});
+ } catch (error) {
+ return ctx.send({type: 'danger', message: (error as Error).message});
+ }
+}
diff --git a/core/routes/adminManager/getModal.ts b/core/routes/adminManager/getModal.ts
new file mode 100644
index 0000000..24989ff
--- /dev/null
+++ b/core/routes/adminManager/getModal.ts
@@ -0,0 +1,106 @@
+const modulename = 'WebServer:AdminManagerGetModal';
+import { AuthedCtx } from '@modules/WebServer/ctxTypes';
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+
+//Separate permissions in general perms and menu perms, and mark the dangerous ones
+const dangerousPerms = ['all_permissions', 'manage.admins', 'console.write', 'settings.write'];
+const getPerms = (checkPerms: string[], allPermissions: [string, string][]) => {
+ type PermType = {
+ id: string;
+ desc: string;
+ checked: string;
+ dangerous: boolean;
+ };
+ const permsGeneral: PermType[] = [];
+ const permsMenu: PermType[] = [];
+ for (const [id, desc] of allPermissions) {
+ const bucket = (id.startsWith('players.') || id.startsWith('menu.')) ? permsGeneral : permsMenu;
+ bucket.push({
+ id,
+ desc,
+ checked: (checkPerms.includes(id)) ? 'checked' : '',
+ dangerous: dangerousPerms.includes(id),
+ });
+ }
+ return [permsGeneral, permsMenu];
+};
+
+
+/**
+ * Returns the output page containing the admins.
+ */
+export default async function AdminManagerGetModal(ctx: AuthedCtx) {
+ //Sanity check
+ if (typeof ctx.params.modalType !== 'string') {
+ return ctx.utils.error(400, 'Invalid Request');
+ }
+ const modalType = ctx.params.modalType;
+
+ //Check permissions
+ if (!ctx.admin.testPermission('manage.admins', modulename)) {
+ return ctx.send({
+ type: 'danger',
+ message: 'You don\'t have permission to execute this action.',
+ });
+ }
+
+ //Check which modal type to show
+ let isNewAdmin;
+ if (modalType == 'add') {
+ isNewAdmin = true;
+ } else if (modalType == 'edit') {
+ isNewAdmin = false;
+ } else {
+ return ctx.send({
+ type: 'danger',
+ message: 'Unknown modalType.',
+ });
+ }
+
+ //If it's a modal for new admin, all fields will be empty
+ const allPermissions = Object.entries(txCore.adminStore.getPermissionsList());
+ if (isNewAdmin) {
+ const [permsGeneral, permsMenu] = getPerms([], allPermissions);
+ const renderData = {
+ isNewAdmin: true,
+ username: '',
+ citizenfx_id: '',
+ discord_id: '',
+ permsGeneral,
+ permsMenu,
+ };
+ return ctx.utils.render('parts/adminModal', renderData);
+ }
+
+ //Sanity check
+ if (typeof ctx.request.body.name !== 'string') {
+ return ctx.utils.error(400, 'Invalid Request - missing parameters');
+ }
+ const name = ctx.request.body.name.trim();
+
+ //Get admin data
+ const admin = txCore.adminStore.getAdminByName(name);
+ if (!admin) return ctx.send('Admin not found');
+
+ //Check if editing an master admin
+ if (!ctx.admin.isMaster && admin.master) {
+ return ctx.send('You cannot edit an admin master.');
+ }
+
+ //Prepare permissions
+ const [permsGeneral, permsMenu] = getPerms(admin.permissions, allPermissions);
+
+ //Set render data
+ const renderData = {
+ isNewAdmin: false,
+ username: admin.name,
+ citizenfx_id: (admin.providers.citizenfx) ? admin.providers.citizenfx.id : '',
+ discord_id: (admin.providers.discord) ? admin.providers.discord.id : '',
+ permsGeneral,
+ permsMenu,
+ };
+
+ //Give output
+ return ctx.utils.render('parts/adminModal', renderData);
+};
diff --git a/core/routes/adminManager/page.ts b/core/routes/adminManager/page.ts
new file mode 100644
index 0000000..3648d64
--- /dev/null
+++ b/core/routes/adminManager/page.ts
@@ -0,0 +1,49 @@
+const modulename = 'WebServer:AdminManagerPage';
+import { AuthedCtx } from '@modules/WebServer/ctxTypes';
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+
+
+/**
+ * Returns the output page containing the admins.
+ */
+export default async function AdminManagerPage(ctx: AuthedCtx) {
+ //Check permission
+ if (!ctx.admin.hasPermission('manage.admins')) {
+ return ctx.utils.render('main/message', {message: 'You don\'t have permission to view this page.'});
+ }
+
+ //Prepare admin array
+ const admins = txCore.adminStore.getAdminsList().map((admin) => {
+ let perms;
+ if (admin.master == true) {
+ perms = 'master account';
+ } else if (admin.permissions.includes('all_permissions')) {
+ perms = 'all permissions';
+ } else if (admin.permissions.length !== 1) {
+ perms = `${admin.permissions.length} permissions`;
+ } else {
+ perms = '1 permission';
+ }
+ const isSelf = ctx.admin.name.toLowerCase() === admin.name.toLowerCase();
+
+ return {
+ hasCitizenFX: (admin.providers.includes('citizenfx')),
+ hasDiscord: (admin.providers.includes('discord')),
+ name: admin.name,
+ perms: perms,
+ isSelf,
+ disableEdit: !ctx.admin.isMaster && admin.master,
+ disableDelete: (admin.master || isSelf),
+ };
+ });
+
+ //Set render data
+ const renderData = {
+ headerTitle: 'Admin Manager',
+ admins,
+ };
+
+ //Give output
+ return ctx.utils.render('main/adminManager', renderData);
+};
diff --git a/core/routes/advanced/actions.js b/core/routes/advanced/actions.js
new file mode 100644
index 0000000..8d3e9fc
--- /dev/null
+++ b/core/routes/advanced/actions.js
@@ -0,0 +1,166 @@
+const modulename = 'WebServer:AdvancedActions';
+import v8 from 'node:v8';
+import bytes from 'bytes';
+import got from '@lib/got';
+import consoleFactory from '@lib/console';
+import { SYM_SYSTEM_AUTHOR } from '@lib/symbols';
+const console = consoleFactory(modulename);
+
+//Helper functions
+const isUndefined = (x) => (x === undefined);
+
+
+/**
+ * Endpoint for running advanced commands - basically, should not ever be used
+ */
+export default async function AdvancedActions(ctx) {
+ //Sanity check
+ if (
+ isUndefined(ctx.request.body.action)
+ || isUndefined(ctx.request.body.parameter)
+ ) {
+ console.warn('Invalid request!');
+ return ctx.send({ type: 'danger', message: 'Invalid request :( ' });
+ }
+ const action = ctx.request.body.action;
+ const parameter = ctx.request.body.parameter;
+
+
+ //Check permissions
+ if (!ctx.admin.testPermission('all_permissions', modulename)) {
+ return ctx.send({
+ type: 'danger',
+ message: 'You don\'t have permission to execute this action.',
+ });
+ }
+
+ //Action: Change Verbosity
+ if (action == 'change_verbosity') {
+ console.setVerbose(parameter == 'true');
+ //temp disabled because the verbosity convar is not being set by this method
+ return ctx.send({ refresh: true });
+ } else if (action == 'perform_magic') {
+ const message = JSON.stringify(txCore.fxPlayerlist.getPlayerList(), null, 2);
+ return ctx.send({ type: 'success', message });
+ } else if (action == 'show_db') {
+ const dbo = txCore.database.getDboRef();
+ console.dir(dbo);
+ return ctx.send({ type: 'success', message: JSON.stringify(dbo, null, 2) });
+ } else if (action == 'show_log') {
+ return ctx.send({ type: 'success', message: JSON.stringify(txCore.logger.server.getRecentBuffer(), null, 2) });
+ } else if (action == 'memory') {
+ let memory;
+ try {
+ const usage = process.memoryUsage();
+ Object.keys(usage).forEach((prop) => {
+ usage[prop] = bytes(usage[prop]);
+ });
+ memory = JSON.stringify(usage, null, 2);
+ } catch (error) {
+ memory = 'error';
+ }
+ return ctx.send({ type: 'success', message: memory });
+ } else if (action == 'freeze') {
+ console.warn('Freezing process for 50 seconds.');
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50 * 1000);
+ } else if (action == 'updateMutableConvars') {
+ txCore.fxRunner.updateMutableConvars();
+ return ctx.send({ refresh: true });
+ } else if (action == 'reauthLast10Players') {
+ // force refresh the admin status of the last 10 players to join
+ const lastPlayers = txCore.fxPlayerlist.getPlayerList().map((p) => p.netid).slice(-10);
+ txCore.fxRunner.sendEvent('adminsUpdated', lastPlayers);
+ return ctx.send({ type: 'success', message: `refreshed: ${JSON.stringify(lastPlayers)}` });
+ } else if (action == 'getLoggerErrors') {
+ const outData = {
+ admin: txCore.logger.admin.lrLastError,
+ fxserver: txCore.logger.fxserver.lrLastError,
+ server: txCore.logger.server.lrLastError,
+ };
+ return ctx.send({ type: 'success', message: JSON.stringify(outData, null, 2) });
+ } else if (action == 'testSrcAddress') {
+ const url = 'https://api.myip.com';
+ const respDefault = await got(url).json();
+ const respReset = await got(url, { localAddress: undefined }).json();
+ const outData = {
+ url,
+ respDefault,
+ respReset,
+ };
+ return ctx.send({ type: 'success', message: JSON.stringify(outData, null, 2) });
+ } else if (action == 'getProcessEnv') {
+ return ctx.send({ type: 'success', message: JSON.stringify(process.env, null, 2) });
+ } else if (action == 'snap') {
+ setTimeout(() => {
+ // if (Citizen && Citizen.snap) Citizen.snap();
+ const snapFile = v8.writeHeapSnapshot();
+ console.warn(`Heap snapshot written to: ${snapFile}`);
+ }, 50);
+ return ctx.send({ type: 'success', message: 'terminal' });
+ } else if (action === 'gc') {
+ if (typeof global.gc === 'function') {
+ global.gc();
+ return ctx.send({ type: 'success', message: 'done' });
+ } else {
+ return ctx.send({ type: 'danger', message: 'GC is not exposed' });
+ }
+ } else if (action === 'safeEnsureMonitor') {
+ const setCmdResult = txCore.fxRunner.sendCommand(
+ 'set',
+ [
+ 'txAdmin-luaComToken',
+ txCore.webServer.luaComToken,
+ ],
+ SYM_SYSTEM_AUTHOR
+ );
+ if (!setCmdResult) {
+ return ctx.send({ type: 'danger', message: 'Failed to reset luaComToken.' });
+ }
+ const ensureCmdResult = txCore.fxRunner.sendCommand(
+ 'ensure',
+ ['monitor'],
+ SYM_SYSTEM_AUTHOR
+ );
+ if (ensureCmdResult) {
+ return ctx.send({ type: 'success', message: 'done' });
+ } else {
+ return ctx.send({ type: 'danger', message: 'Failed to ensure monitor.' });
+ }
+ } else if (action.startsWith('playerDrop')) {
+ const reason = action.split(' ', 2)[1];
+ const category = txCore.metrics.playerDrop.handlePlayerDrop(reason);
+ return ctx.send({ type: 'success', message: category });
+
+ } else if (action.startsWith('set')) {
+ // set general.language "pt"
+ // set general.language "en"
+ // set server.onesync "on"
+ // set server.onesync "legacy"
+ try {
+ const [_, scopeKey, valueJson] = action.split(' ', 3);
+ if (!scopeKey || !valueJson) throw new Error(`Invalid set command: ${action}`);
+ const [scope, key] = scopeKey.split('.');
+ if (!scope || !key) throw new Error(`Invalid set command: ${action}`);
+ const configUpdate = { [scope]: { [key]: JSON.parse(valueJson) } };
+ const storedKeysChanges = txCore.configStore.saveConfigs(configUpdate, ctx.admin.name);
+ const outParts = [
+ 'Keys Updated: ' + JSON.stringify(storedKeysChanges ?? 'not set', null, 2),
+ '-'.repeat(16),
+ 'Stored:' + JSON.stringify(txCore.configStore.getStoredConfig(), null, 2),
+ ];
+ return ctx.send({ type: 'success', message: outParts.join('\n') });
+ } catch (error) {
+ return ctx.send({ type: 'danger', message: error.message });
+ }
+
+ } else if (action == 'printFxRunnerChildHistory') {
+ const message = JSON.stringify(txCore.fxRunner.history, null, 2)
+ return ctx.send({ type: 'success', message });
+
+ } else if (action == 'xxxxxx') {
+ return ctx.send({ type: 'success', message: '😀👍' });
+ }
+
+ //Catch all
+ return ctx.send({ type: 'danger', message: 'Unknown action :( ' });
+};
diff --git a/core/routes/advanced/get.js b/core/routes/advanced/get.js
new file mode 100644
index 0000000..98c53b6
--- /dev/null
+++ b/core/routes/advanced/get.js
@@ -0,0 +1,20 @@
+const modulename = 'WebServer:AdvancedPage';
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+
+
+/**
+ * Returns the output page containing the server.cfg
+ * @param {object} ctx
+ */
+export default async function AdvancedPage(ctx) {
+ //Check permissions
+ if (!ctx.admin.hasPermission('all_permisisons')) {
+ return ctx.utils.render('main/message', {message: 'You don\'t have permission to view this page.'});
+ }
+
+ return ctx.utils.render('main/advanced', {
+ headerTitle: 'Advanced',
+ verbosityEnabled: console.isVerbose,
+ });
+};
diff --git a/core/routes/authentication/addMasterCallback.ts b/core/routes/authentication/addMasterCallback.ts
new file mode 100644
index 0000000..22087aa
--- /dev/null
+++ b/core/routes/authentication/addMasterCallback.ts
@@ -0,0 +1,63 @@
+const modulename = 'WebServer:AuthAddMasterCallback';
+import { InitializedCtx } from '@modules/WebServer/ctxTypes';
+import consoleFactory from '@lib/console';
+import { getIdFromOauthNameid } from '@lib/player/idUtils';
+import { ApiAddMasterCallbackResp } from '@shared/authApiTypes';
+import { z } from 'zod';
+import { handleOauthCallback } from './oauthMethods';
+const console = consoleFactory(modulename);
+
+//Helper functions
+const bodySchema = z.object({
+ redirectUri: z.string(),
+});
+export type ApiAddMasterCallbackReqSchema = z.infer;
+
+/**
+ * Handles the Add Master flow
+ */
+export default async function AuthAddMasterCallback(ctx: InitializedCtx) {
+ const schemaRes = bodySchema.safeParse(ctx.request.body);
+ if (!schemaRes.success) {
+ return ctx.send({
+ errorTitle: 'Invalid request body',
+ errorMessage: schemaRes.error.message,
+ });
+ }
+ const { redirectUri } = schemaRes.data;
+
+ //Check if there are already admins set up
+ if (txCore.adminStore.hasAdmins()) {
+ return ctx.send({
+ errorTitle: `Master account already set.`,
+ errorMessage: `Please return to the login page.`,
+ });
+ }
+
+ //Handling the callback
+ const callbackResp = await handleOauthCallback(ctx, redirectUri);
+ if('errorCode' in callbackResp || 'errorTitle' in callbackResp){
+ return ctx.send(callbackResp);
+ }
+ const userInfo = callbackResp;
+
+ //Getting identifier
+ const fivemIdentifier = getIdFromOauthNameid(userInfo.nameid);
+ if(!fivemIdentifier){
+ return ctx.send({
+ errorTitle: 'Invalid nameid identifier.',
+ errorMessage: `Could not extract the user identifier from the URL below. Please report this to the txAdmin dev team.\n${userInfo.nameid.toString()}`,
+ });
+ }
+
+ //Setting session
+ ctx.sessTools.set({
+ tmpAddMasterUserInfo: userInfo,
+ });
+
+ return ctx.send({
+ fivemName: userInfo.name,
+ fivemId: fivemIdentifier,
+ profilePicture: userInfo.picture,
+ });
+};
diff --git a/core/routes/authentication/addMasterPin.ts b/core/routes/authentication/addMasterPin.ts
new file mode 100644
index 0000000..2d62cbb
--- /dev/null
+++ b/core/routes/authentication/addMasterPin.ts
@@ -0,0 +1,45 @@
+const modulename = 'WebServer:AuthAddMasterPin';
+import { InitializedCtx } from '@modules/WebServer/ctxTypes';
+import consoleFactory from '@lib/console';
+import { ApiOauthRedirectResp } from '@shared/authApiTypes';
+import { z } from 'zod';
+import { getOauthRedirectUrl } from './oauthMethods';
+const console = consoleFactory(modulename);
+
+//Helper functions
+const bodySchema = z.object({
+ pin: z.string().trim(),
+ origin: z.string(),
+});
+export type ApiAddMasterPinReqSchema = z.infer;
+
+/**
+ * Handles the Add Master flow
+ */
+export default async function AuthAddMasterPin(ctx: InitializedCtx) {
+ const schemaRes = bodySchema.safeParse(ctx.request.body);
+ if (!schemaRes.success) {
+ return ctx.send({
+ error: `Invalid request body: ${schemaRes.error.message}`,
+ });
+ }
+ const { pin, origin } = schemaRes.data;
+
+ //Check if there are already admins set up
+ if (txCore.adminStore.hasAdmins()) {
+ return ctx.send({
+ error: `master_already_set`,
+ });
+ }
+
+ //Checking the PIN
+ if (!pin.length || pin !== txCore.adminStore.addMasterPin) {
+ return ctx.send({
+ error: `Wrong PIN.`,
+ });
+ }
+
+ return ctx.send({
+ authUrl: getOauthRedirectUrl(ctx, 'addMaster', origin),
+ });
+};
diff --git a/core/routes/authentication/addMasterSave.ts b/core/routes/authentication/addMasterSave.ts
new file mode 100644
index 0000000..0a3574b
--- /dev/null
+++ b/core/routes/authentication/addMasterSave.ts
@@ -0,0 +1,96 @@
+const modulename = 'WebServer:AuthAddMasterSave';
+import { AuthedAdmin, CfxreSessAuthType } from '@modules/WebServer/authLogic';
+import { InitializedCtx } from '@modules/WebServer/ctxTypes';
+import consoleFactory from '@lib/console';
+import { getIdFromOauthNameid } from '@lib/player/idUtils';
+import { ApiAddMasterSaveResp } from '@shared/authApiTypes';
+import { z } from 'zod';
+import consts from '@shared/consts';
+const console = consoleFactory(modulename);
+
+//Helper functions
+const bodySchema = z.object({
+ password: z.string().min(consts.adminPasswordMinLength).max(consts.adminPasswordMaxLength),
+ discordId: z.string().optional(),
+});
+export type ApiAddMasterSaveReqSchema = z.infer;
+
+/**
+ * Handles the Add Master flow
+ */
+export default async function AuthAddMasterSave(ctx: InitializedCtx) {
+ const schemaRes = bodySchema.safeParse(ctx.request.body);
+ if (!schemaRes.success) {
+ return ctx.send({
+ error: `Invalid request body: ${schemaRes.error.message}`,
+ });
+ }
+ const { password, discordId } = schemaRes.data;
+
+ //Check if there are already admins set up
+ if (txCore.adminStore.hasAdmins()) {
+ return ctx.send({
+ error: `master_already_set`,
+ });
+ }
+
+ //Checking the discordId
+ if (typeof discordId === 'string' && !consts.validIdentifierParts.discord.test(discordId)) {
+ return ctx.send({
+ error: `Invalid Discord ID.`,
+ });
+ }
+
+ //Checking if session is still present
+ const inboundSession = ctx.sessTools.get();
+ if (!inboundSession || !inboundSession?.tmpAddMasterUserInfo) {
+ return ctx.send({
+ error: `invalid_session`,
+ });
+ }
+ const userInfo = inboundSession.tmpAddMasterUserInfo;
+
+ //Getting identifier
+ const fivemIdentifier = getIdFromOauthNameid(userInfo.nameid);
+ if (!fivemIdentifier) {
+ return ctx.send({
+ error: `Could not extract the user identifier from userInfo.nameid.\nPlease report this to the txAdmin dev team.\n${userInfo.nameid.toString()}`,
+ });
+ }
+
+ //Create admins file and log in admin
+ try {
+ const vaultAdmin = txCore.adminStore.createAdminsFile(
+ userInfo.name,
+ fivemIdentifier,
+ discordId,
+ password,
+ true,
+ );
+
+ //If the user has a picture, save it to the cache
+ if (userInfo.picture) {
+ txCore.cacheStore.set(`admin:picture:${userInfo.name}`, userInfo.picture);
+ }
+
+ //Setting session
+ const sessData = {
+ type: 'cfxre',
+ username: userInfo.name,
+ csrfToken: txCore.adminStore.genCsrfToken(),
+ expiresAt: Date.now() + 86_400_000, //24h,
+ identifier: fivemIdentifier,
+ } satisfies CfxreSessAuthType;
+ ctx.sessTools.set({ auth: sessData });
+
+ const authedAdmin = new AuthedAdmin(vaultAdmin, sessData.csrfToken);
+ authedAdmin.logAction(`created admins file`);
+ return ctx.send(authedAdmin.getAuthData());
+ } catch (error) {
+ ctx.sessTools.destroy();
+ console.error(`Failed to create session: ${(error as Error).message}`);
+ return ctx.send({
+ error: `Failed to create session: ${(error as Error).message}`,
+ });
+ }
+};
diff --git a/core/routes/authentication/changeIdentifiers.ts b/core/routes/authentication/changeIdentifiers.ts
new file mode 100644
index 0000000..78133a7
--- /dev/null
+++ b/core/routes/authentication/changeIdentifiers.ts
@@ -0,0 +1,77 @@
+const modulename = 'WebServer:AuthChangeIdentifiers';
+import { AuthedCtx } from '@modules/WebServer/ctxTypes';
+import consoleFactory from '@lib/console';
+import consts from '@shared/consts';
+import { GenericApiResp } from '@shared/genericApiTypes';
+import { z } from 'zod';
+import got from '@lib/got';
+const console = consoleFactory(modulename);
+
+//Helpers
+const cfxHttpReqOptions = {
+ timeout: { request: 6000 },
+};
+type ProviderDataType = {id: string, identifier: string};
+
+const bodySchema = z.object({
+ cfxreId: z.string().trim(),
+ discordId: z.string().trim(),
+});
+export type ApiChangeIdentifiersReqSchema = z.infer;
+
+/**
+ * Route to change your own identifiers
+ */
+export default async function AuthChangeIdentifiers(ctx: AuthedCtx) {
+ //Sanity check
+ const schemaRes = bodySchema.safeParse(ctx.request.body);
+ if (!schemaRes.success) {
+ return ctx.send({
+ error: `Invalid request body: ${schemaRes.error.message}`,
+ });
+ }
+ const { cfxreId, discordId } = schemaRes.data;
+
+ //Validate & translate FiveM ID
+ let citizenfxData: ProviderDataType | false = false;
+ if (cfxreId.length) {
+ try {
+ citizenfxData = {
+ id: cfxreId,
+ identifier: cfxreId,
+ };
+ } catch (error) {
+ return ctx.send({
+ error: `Failed to resolve CitizenFX ID to game identifier with error: ${(error as Error).message}`,
+ });
+ }
+ }
+
+ //Validate Discord ID
+ let discordData: ProviderDataType | false = false;
+ if (discordId.length) {
+ if (!consts.validIdentifiers.discord.test(discordId)) {
+ return ctx.send({
+ error: `The Discord ID needs to be the numeric "User ID" instead of the username.\n You can also leave it blank.`,
+ });
+ }
+ discordData = {
+ id: discordId.substring(8),
+ identifier: discordId,
+ };
+ }
+
+ //Get vault admin
+ const vaultAdmin = txCore.adminStore.getAdminByName(ctx.admin.name);
+ if (!vaultAdmin) throw new Error('Wait, what? Where is that admin?');
+
+ //Edit admin and give output
+ try {
+ await txCore.adminStore.editAdmin(ctx.admin.name, null, citizenfxData, discordData);
+
+ ctx.admin.logAction('Changing own identifiers.');
+ return ctx.send({ success: true });
+ } catch (error) {
+ return ctx.send({ error: (error as Error).message });
+ }
+};
diff --git a/core/routes/authentication/changePassword.ts b/core/routes/authentication/changePassword.ts
new file mode 100644
index 0000000..350a40a
--- /dev/null
+++ b/core/routes/authentication/changePassword.ts
@@ -0,0 +1,69 @@
+const modulename = 'WebServer:AuthChangePassword';
+import { AuthedCtx } from '@modules/WebServer/ctxTypes';
+import consoleFactory from '@lib/console';
+import consts from '@shared/consts';
+import { GenericApiResp } from '@shared/genericApiTypes';
+import { z } from 'zod';
+const console = consoleFactory(modulename);
+
+//Helper functions
+const bodySchema = z.object({
+ oldPassword: z.string().optional(),
+ newPassword: z.string(),
+});
+export type ApiChangePasswordReqSchema = z.infer;
+
+
+/**
+ * Route to change your own password
+ */
+export default async function AuthChangePassword(ctx: AuthedCtx) {
+ //Sanity check
+ const schemaRes = bodySchema.safeParse(ctx.request.body);
+ if (!schemaRes.success) {
+ return ctx.send({
+ error: `Invalid request body: ${schemaRes.error.message}`,
+ });
+ }
+ const { newPassword, oldPassword } = schemaRes.data;
+
+ //Validate new password
+ if (newPassword.trim() !== newPassword) {
+ return ctx.send({
+ error: 'Your password either starts or ends with a space, which was likely an accident. Please remove it and try again.',
+ });
+ }
+ if (newPassword.length < consts.adminPasswordMinLength || newPassword.length > consts.adminPasswordMaxLength) {
+ return ctx.send({ error: 'Invalid new password length.' });
+ }
+
+ //Get vault admin
+ const vaultAdmin = txCore.adminStore.getAdminByName(ctx.admin.name);
+ if (!vaultAdmin) throw new Error('Wait, what? Where is that admin?');
+ if (!ctx.admin.isTempPassword) {
+ if (!oldPassword || !VerifyPasswordHash(oldPassword, vaultAdmin.password_hash)) {
+ return ctx.send({ error: 'Wrong current password.' });
+ }
+ }
+
+ //Edit admin and give output
+ try {
+ const newHash = await txCore.adminStore.editAdmin(ctx.admin.name, newPassword);
+
+ //Update session hash if logged in via password
+ const currSess = ctx.sessTools.get();
+ if (currSess?.auth?.type === 'password') {
+ ctx.sessTools.set({
+ auth: {
+ ...currSess.auth,
+ password_hash: newHash,
+ }
+ });
+ }
+
+ ctx.admin.logAction('Changing own password.');
+ return ctx.send({ success: true });
+ } catch (error) {
+ return ctx.send({ error: (error as Error).message });
+ }
+};
diff --git a/core/routes/authentication/getIdentifiers.ts b/core/routes/authentication/getIdentifiers.ts
new file mode 100644
index 0000000..7dc6aa6
--- /dev/null
+++ b/core/routes/authentication/getIdentifiers.ts
@@ -0,0 +1,27 @@
+const modulename = 'WebServer:AuthGetIdentifiers';
+import { AuthedCtx } from '@modules/WebServer/ctxTypes';
+import consoleFactory from '@lib/console';
+import { z } from 'zod';
+const console = consoleFactory(modulename);
+
+//Helper functions
+const bodySchema = z.object({
+ oldPassword: z.string().optional(),
+ newPassword: z.string(),
+});
+export type ApiChangePasswordReqSchema = z.infer;
+
+
+/**
+ * Returns the identifiers of the current admin
+ */
+export default async function AuthGetIdentifiers(ctx: AuthedCtx) {
+ //Get vault admin
+ const vaultAdmin = txCore.adminStore.getAdminByName(ctx.admin.name);
+ if (!vaultAdmin) throw new Error('Wait, what? Where is that admin?');
+
+ return ctx.send({
+ cfxreId: (vaultAdmin.providers.citizenfx) ? vaultAdmin.providers.citizenfx.identifier : '',
+ discordId: (vaultAdmin.providers.discord) ? vaultAdmin.providers.discord.identifier : '',
+ });
+};
diff --git a/core/routes/authentication/logout.ts b/core/routes/authentication/logout.ts
new file mode 100644
index 0000000..cc9ba8d
--- /dev/null
+++ b/core/routes/authentication/logout.ts
@@ -0,0 +1,22 @@
+const modulename = 'WebServer:AuthLogout';
+import { InitializedCtx } from '@modules/WebServer/ctxTypes';
+import consoleFactory from '@lib/console';
+import { ApiLogoutResp } from '@shared/authApiTypes';
+const console = consoleFactory(modulename);
+
+
+/**
+ * Once upon a cyber-time, in the land of API wonder, there was a humble route called 'AuthLogout'.
+ * It was the epitome of simplicity, with just a single line of code. In a project brimming with
+ * complexity, this little route stood as a beacon of uncomplicated grace. It dutifully ensured
+ * that users could bid farewell to txAdmin with ease, never overstaying its welcome.
+ * And so, with a single request, users embarked on their journeys, leaving behind the virtual
+ * realm, 😄👋 #ByeFelicia
+ */
+export default async function AuthLogout(ctx: InitializedCtx) {
+ ctx.sessTools.destroy();
+
+ return ctx.send({
+ logout: true,
+ });
+};
diff --git a/core/routes/authentication/oauthMethods.ts b/core/routes/authentication/oauthMethods.ts
new file mode 100644
index 0000000..7050708
--- /dev/null
+++ b/core/routes/authentication/oauthMethods.ts
@@ -0,0 +1,90 @@
+
+const modulename = 'WebServer:OauthMethods';
+import { InitializedCtx } from "@modules/WebServer/ctxTypes";
+import { ValidSessionType } from "@modules/WebServer/middlewares/sessionMws";
+import { ApiOauthCallbackErrorResp, ApiOauthCallbackResp } from "@shared/authApiTypes";
+import { randomUUID } from "node:crypto";
+import consoleFactory from '@lib/console';
+import { UserInfoType } from "@modules/AdminStore/providers/CitizenFX";
+const console = consoleFactory(modulename);
+
+
+/**
+ * Sets the user session and generates the provider redirect url
+ */
+export const getOauthRedirectUrl = (ctx: InitializedCtx, purpose: 'login' | 'addMaster', origin: string) => {
+ const callbackUrl = origin + `/${purpose}/callback`;
+
+ //Setting up session
+ const sessData = {
+ tmpOauthLoginStateKern: randomUUID(),
+ tmpOauthLoginCallbackUri: callbackUrl,
+ } satisfies ValidSessionType;
+ ctx.sessTools.set(sessData);
+
+ //Generate CitizenFX provider Auth URL
+ const idmsAuthUrl = txCore.adminStore.providers.citizenfx.getAuthURL(
+ callbackUrl,
+ sessData.tmpOauthLoginStateKern,
+ );
+
+ return idmsAuthUrl;
+}
+
+
+/**
+ * Handles the provider login callbacks by doing the code exchange, validations and returning the userInfo
+ */
+export const handleOauthCallback = async (ctx: InitializedCtx, redirectUri: string): Promise => {
+ //Checking session
+ const inboundSession = ctx.sessTools.get();
+ if (!inboundSession || !inboundSession?.tmpOauthLoginStateKern || !inboundSession?.tmpOauthLoginCallbackUri) {
+ return {
+ errorCode: 'invalid_session',
+ };
+ }
+
+ //Exchange code for access token
+ let tokenSet;
+ try {
+ tokenSet = await txCore.adminStore.providers.citizenfx.processCallback(
+ inboundSession.tmpOauthLoginCallbackUri,
+ inboundSession.tmpOauthLoginStateKern,
+ redirectUri,
+ );
+ if (!tokenSet) throw new Error('tokenSet is undefined');
+ if (!tokenSet.access_token) throw new Error('tokenSet.access_token is undefined');
+ } catch (e) {
+ const error = e as any;
+ console.warn(`Code Exchange error: ${error.message}`);
+ if (error.tolerance !== undefined) {
+ return {
+ errorCode: 'clock_desync',
+ };
+ } else if (error.code === 'ETIMEDOUT') {
+ return {
+ errorCode: 'timeout',
+ };
+ } else if (error.message.startsWith('state mismatch')) {
+ return {
+ errorCode: 'invalid_state', //same as invalid_session?
+ };
+ } else {
+ return {
+ errorTitle: 'Code Exchange error:',
+ errorMessage: error.message,
+ };
+ }
+ }
+
+ //Get userinfo
+ try {
+ return await txCore.adminStore.providers.citizenfx.getUserInfo(tokenSet.access_token);
+ } catch (error) {
+ console.verbose.error(`Get UserInfo error: ${(error as Error).message}`);
+ return {
+ errorTitle: 'Get UserInfo error:',
+ errorMessage: (error as Error).message,
+ };
+ }
+}
diff --git a/core/routes/authentication/providerCallback.ts b/core/routes/authentication/providerCallback.ts
new file mode 100644
index 0000000..4fdf0af
--- /dev/null
+++ b/core/routes/authentication/providerCallback.ts
@@ -0,0 +1,89 @@
+const modulename = 'WebServer:AuthProviderCallback';
+import consoleFactory from '@lib/console';
+import { InitializedCtx } from '@modules/WebServer/ctxTypes';
+import { AuthedAdmin, CfxreSessAuthType } from '@modules/WebServer/authLogic';
+import { z } from 'zod';
+import { ApiOauthCallbackErrorResp, ApiOauthCallbackResp, ReactAuthDataType } from '@shared/authApiTypes';
+import { handleOauthCallback } from './oauthMethods';
+import { getIdFromOauthNameid } from '@lib/player/idUtils';
+const console = consoleFactory(modulename);
+
+//Helper functions
+const bodySchema = z.object({
+ redirectUri: z.string(),
+});
+export type ApiOauthCallbackReqSchema = z.infer;
+
+/**
+ * Handles the provider login callbacks
+ */
+export default async function AuthProviderCallback(ctx: InitializedCtx) {
+ const schemaRes = bodySchema.safeParse(ctx.request.body);
+ if (!schemaRes.success) {
+ return ctx.send({
+ errorTitle: 'Invalid request body',
+ errorMessage: schemaRes.error.message,
+ });
+ }
+ const { redirectUri } = schemaRes.data;
+
+ //Handling the callback
+ const callbackResp = await handleOauthCallback(ctx, redirectUri);
+ if('errorCode' in callbackResp || 'errorTitle' in callbackResp){
+ return ctx.send(callbackResp);
+ }
+ const userInfo = callbackResp;
+
+ //Getting identifier
+ const fivemIdentifier = getIdFromOauthNameid(userInfo.nameid);
+ if(!fivemIdentifier){
+ return ctx.send({
+ errorTitle: 'Invalid nameid identifier.',
+ errorMessage: `Could not extract the user identifier from the URL below. Please report this to the txAdmin dev team.\n${userInfo.nameid.toString()}`,
+ });
+ }
+
+ //Check & Login user
+ try {
+ const vaultAdmin = txCore.adminStore.getAdminByIdentifiers([fivemIdentifier]);
+ if (!vaultAdmin) {
+ ctx.sessTools.destroy();
+ return ctx.send({
+ errorCode: 'not_admin',
+ errorContext: {
+ identifier: fivemIdentifier,
+ name: userInfo.name,
+ profile: userInfo.profile
+ }
+ });
+ }
+
+ //Setting session
+ const sessData = {
+ type: 'cfxre',
+ username: vaultAdmin.name,
+ csrfToken: txCore.adminStore.genCsrfToken(),
+ expiresAt: Date.now() + 86_400_000, //24h,
+ identifier: fivemIdentifier,
+ } satisfies CfxreSessAuthType;
+ ctx.sessTools.set({ auth: sessData });
+
+ //If the user has a picture, save it to the cache
+ if (userInfo.picture) {
+ txCore.cacheStore.set(`admin:picture:${vaultAdmin.name}`, userInfo.picture);
+ }
+
+ const authedAdmin = new AuthedAdmin(vaultAdmin, sessData.csrfToken);
+ authedAdmin.logAction(`logged in from ${ctx.ip} via cfxre`);
+ txCore.metrics.txRuntime.loginOrigins.count(ctx.txVars.hostType);
+ txCore.metrics.txRuntime.loginMethods.count('citizenfx');
+ return ctx.send(authedAdmin.getAuthData());
+ } catch (error) {
+ ctx.sessTools.destroy();
+ console.verbose.error(`Failed to login: ${(error as Error).message}`);
+ return ctx.send({
+ errorTitle: 'Failed to login:',
+ errorMessage: (error as Error).message,
+ });
+ }
+};
diff --git a/core/routes/authentication/providerRedirect.ts b/core/routes/authentication/providerRedirect.ts
new file mode 100644
index 0000000..11edead
--- /dev/null
+++ b/core/routes/authentication/providerRedirect.ts
@@ -0,0 +1,36 @@
+const modulename = 'WebServer:AuthProviderRedirect';
+import { InitializedCtx } from '@modules/WebServer/ctxTypes';
+import consoleFactory from '@lib/console';
+import { ApiOauthRedirectResp } from '@shared/authApiTypes';
+import { z } from 'zod';
+import { getOauthRedirectUrl } from './oauthMethods';
+const console = consoleFactory(modulename);
+
+const querySchema = z.object({
+ origin: z.string(),
+});
+
+
+/**
+ * Generates the provider auth url and redirects the user
+ */
+export default async function AuthProviderRedirect(ctx: InitializedCtx) {
+ const schemaRes = querySchema.safeParse(ctx.request.query);
+ if (!schemaRes.success) {
+ return ctx.send({
+ error: `Invalid request query: ${schemaRes.error.message}`,
+ });
+ }
+ const { origin } = schemaRes.data;
+
+ //Check if there are already admins set up
+ if (!txCore.adminStore.hasAdmins()) {
+ return ctx.send({
+ error: `no_admins_setup`,
+ });
+ }
+
+ return ctx.send({
+ authUrl: getOauthRedirectUrl(ctx, 'login', origin),
+ });
+};
diff --git a/core/routes/authentication/self.ts b/core/routes/authentication/self.ts
new file mode 100644
index 0000000..5fb6fdd
--- /dev/null
+++ b/core/routes/authentication/self.ts
@@ -0,0 +1,13 @@
+const modulename = 'WebServer:AuthSelf';
+import { AuthedCtx } from '@modules/WebServer/ctxTypes';
+import consoleFactory from '@lib/console';
+import { ReactAuthDataType } from '@shared/authApiTypes';
+const console = consoleFactory(modulename);
+
+/**
+ * Method to check for the authentication, returning the admin object if it's valid.
+ * This is used in the NUI auth and in the sv_admins.lua, as well as in the react web ui.
+ */
+export default async function AuthSelf(ctx: AuthedCtx) {
+ ctx.send(ctx.admin.getAuthData());
+};
diff --git a/core/routes/authentication/verifyPassword.ts b/core/routes/authentication/verifyPassword.ts
new file mode 100644
index 0000000..3a8fd3c
--- /dev/null
+++ b/core/routes/authentication/verifyPassword.ts
@@ -0,0 +1,85 @@
+const modulename = 'WebServer:AuthVerifyPassword';
+import { AuthedAdmin, PassSessAuthType } from '@modules/WebServer/authLogic';
+import { InitializedCtx } from '@modules/WebServer/ctxTypes';
+import { txEnv } from '@core/globalData';
+import consoleFactory from '@lib/console';
+import { ApiVerifyPasswordResp, ReactAuthDataType } from '@shared/authApiTypes';
+import { z } from 'zod';
+const console = consoleFactory(modulename);
+
+//Helper functions
+const bodySchema = z.object({
+ username: z.string().trim(),
+ password: z.string().trim(),
+});
+export type ApiVerifyPasswordReqSchema = z.infer;
+
+/**
+ * Verify login
+ */
+export default async function AuthVerifyPassword(ctx: InitializedCtx) {
+ //Check UI version
+ const { uiVersion } = ctx.request.query;
+ if(uiVersion && uiVersion !== txEnv.txaVersion){
+ return ctx.send({
+ error: `refreshToUpdate`,
+ });
+ }
+
+ //Checking body
+ const schemaRes = bodySchema.safeParse(ctx.request.body);
+ if (!schemaRes.success) {
+ return ctx.send({
+ error: `Invalid request body: ${schemaRes.error.message}`,
+ });
+ }
+ const postBody = schemaRes.data;
+
+ //Check if there are already admins set up
+ if (!txCore.adminStore.hasAdmins()) {
+ return ctx.send({
+ error: `no_admins_setup`,
+ });
+ }
+
+ try {
+ //Checking admin
+ const vaultAdmin = txCore.adminStore.getAdminByName(postBody.username);
+ if (!vaultAdmin) {
+ console.warn(`Wrong username from: ${ctx.ip}`);
+ return ctx.send({
+ error: 'Wrong username or password!',
+ });
+ }
+ if (!VerifyPasswordHash(postBody.password, vaultAdmin.password_hash)) {
+ console.warn(`Wrong password from: ${ctx.ip}`);
+ return ctx.send({
+ error: 'Wrong username or password!',
+ });
+ }
+
+ //Setting up session
+ const sessData = {
+ type: 'password',
+ username: vaultAdmin.name,
+ password_hash: vaultAdmin.password_hash,
+ expiresAt: false,
+ csrfToken: txCore.adminStore.genCsrfToken(),
+ } satisfies PassSessAuthType;
+ ctx.sessTools.set({ auth: sessData });
+
+ txCore.logger.admin.write(vaultAdmin.name, `logged in from ${ctx.ip} via password`);
+ txCore.metrics.txRuntime.loginOrigins.count(ctx.txVars.hostType);
+ txCore.metrics.txRuntime.loginMethods.count('password');
+
+ const authedAdmin = new AuthedAdmin(vaultAdmin, sessData.csrfToken)
+ return ctx.send(authedAdmin.getAuthData());
+
+ } catch (error) {
+ console.warn(`Failed to authenticate ${postBody.username} with error: ${(error as Error).message}`);
+ console.verbose.dir(error);
+ return ctx.send({
+ error: 'Error autenticating admin.',
+ });
+ }
+};
diff --git a/core/routes/banTemplates/getBanTemplates.ts b/core/routes/banTemplates/getBanTemplates.ts
new file mode 100644
index 0000000..9cef6fd
--- /dev/null
+++ b/core/routes/banTemplates/getBanTemplates.ts
@@ -0,0 +1,19 @@
+const modulename = 'WebServer:GetBanTemplates';
+import consoleFactory from '@lib/console';
+import { BanTemplatesDataType } from '@modules/ConfigStore/schema/banlist';
+import { AuthedCtx } from '@modules/WebServer/ctxTypes';
+import { GenericApiErrorResp } from '@shared/genericApiTypes';
+const console = consoleFactory(modulename);
+
+
+//Response type
+export type GetBanTemplatesSuccessResp = BanTemplatesDataType[];
+
+
+/**
+ * Retrieves the ban templates from the config file
+ */
+export default async function GetBanTemplates(ctx: AuthedCtx) {
+ const sendTypedResp = (data: GetBanTemplatesSuccessResp | GenericApiErrorResp) => ctx.send(data);
+ return sendTypedResp(txConfig.banlist.templates);
+};
diff --git a/core/routes/banTemplates/saveBanTemplates.ts b/core/routes/banTemplates/saveBanTemplates.ts
new file mode 100644
index 0000000..84fdc8c
--- /dev/null
+++ b/core/routes/banTemplates/saveBanTemplates.ts
@@ -0,0 +1,53 @@
+const modulename = 'WebServer:SaveBanTemplates';
+import consoleFactory from '@lib/console';
+import { BanTemplatesDataType } from '@modules/ConfigStore/schema/banlist';
+import { AuthedCtx } from '@modules/WebServer/ctxTypes';
+import { GenericApiOkResp } from '@shared/genericApiTypes';
+import { z } from 'zod';
+const console = consoleFactory(modulename);
+
+
+//Req validation & types
+const bodySchema = z.any().array();
+export type SaveBanTemplatesReq = BanTemplatesDataType[];
+export type SaveBanTemplatesResp = GenericApiOkResp;
+
+
+/**
+ * Saves the ban templates to the config file
+ */
+export default async function SaveBanTemplates(ctx: AuthedCtx) {
+ const sendTypedResp = (data: SaveBanTemplatesResp) => ctx.send(data);
+
+ //Check permissions
+ if (!ctx.admin.testPermission('settings.write', modulename)) {
+ return sendTypedResp({
+ error: 'You do not have permission to change the settings.'
+ });
+ }
+
+ //Validating input
+ const schemaRes = bodySchema.safeParse(ctx.request.body);
+ if (!schemaRes.success) {
+ return sendTypedResp({
+ error: `Invalid request body: ${schemaRes.error.message}`,
+ });
+ }
+ const banTemplates = schemaRes.data;
+
+ //Preparing & saving config
+ try {
+ txCore.configStore.saveConfigs({
+ banlist: { templates: banTemplates },
+ }, ctx.admin.name);
+ } catch (error) {
+ console.warn(`[${ctx.admin.name}] Error changing banTemplates settings.`);
+ console.verbose.dir(error);
+ return sendTypedResp({
+ error: `Error saving the configuration file: ${(error as Error).message}`
+ });
+ }
+
+ //Sending output
+ return sendTypedResp({ success: true });
+};
diff --git a/core/routes/cfgEditor/get.js b/core/routes/cfgEditor/get.js
new file mode 100644
index 0000000..845d8ce
--- /dev/null
+++ b/core/routes/cfgEditor/get.js
@@ -0,0 +1,38 @@
+const modulename = 'WebServer:CFGEditorPage';
+import { resolveCFGFilePath, readRawCFGFile } from '@lib/fxserver/fxsConfigHelper';
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+
+
+/**
+ * Returns the output page containing the server.cfg
+ * @param {object} ctx
+ */
+export default async function CFGEditorPage(ctx) {
+ //Check permissions
+ if (!ctx.admin.hasPermission('server.cfg.editor')) {
+ return ctx.utils.render('main/message', {message: 'You don\'t have permission to view this page.'});
+ }
+
+ //Check if file is set
+ if (!txCore.fxRunner.isConfigured) {
+ let message = 'You need to configure your server data path before being able to edit the CFG file.';
+ return ctx.utils.render('main/message', {message});
+ }
+
+ //Read cfg file
+ let rawFile;
+ try {
+ let cfgFilePath = resolveCFGFilePath(txConfig.server.cfgPath, txConfig.server.dataPath);
+ rawFile = await readRawCFGFile(cfgFilePath);
+ } catch (error) {
+ let message = `Failed to read CFG File with error: ${error.message}`;
+ return ctx.utils.render('main/message', {message});
+ }
+
+ return ctx.utils.render('main/cfgEditor', {
+ headerTitle: 'CFG Editor',
+ rawFile,
+ disableRestart: (ctx.admin.hasPermission('control.server')) ? '' : 'disabled',
+ });
+};
diff --git a/core/routes/cfgEditor/save.js b/core/routes/cfgEditor/save.js
new file mode 100644
index 0000000..6e60a2c
--- /dev/null
+++ b/core/routes/cfgEditor/save.js
@@ -0,0 +1,74 @@
+const modulename = 'WebServer:CFGEditorSave';
+import { validateModifyServerConfig } from '@lib/fxserver/fxsConfigHelper';
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+
+
+const isUndefined = (x) => (x === undefined);
+
+
+/**
+ * Saves the server.cfg
+ * @param {object} ctx
+ */
+export default async function CFGEditorSave(ctx) {
+ //Sanity check
+ if (
+ isUndefined(ctx.request.body.cfgData)
+ || typeof ctx.request.body.cfgData !== 'string'
+ ) {
+ return ctx.utils.error(400, 'Invalid Request');
+ }
+
+ //Check permissions
+ if (!ctx.admin.testPermission('server.cfg.editor', modulename)) {
+ return ctx.send({
+ type: 'danger',
+ message: 'You don\'t have permission to execute this action.',
+ });
+ }
+
+ //Check if file is set
+ if (!txCore.fxRunner.isConfigured) {
+ const message = 'CFG or Server Data Path not defined. Configure it in the settings page first.';
+ return ctx.send({type: 'danger', message});
+ }
+
+
+ //Validating config contents + saving file and backup
+ let result;
+ try {
+ result = await validateModifyServerConfig(
+ ctx.request.body.cfgData,
+ txConfig.server.cfgPath,
+ txConfig.server.dataPath,
+ );
+ } catch (error) {
+ return ctx.send({
+ type: 'danger',
+ markdown: true,
+ message: `**Failed to save \`server.cfg\` with error:**\n${error.message}`,
+ });
+ }
+
+ //Handle result
+ if (result.errors) {
+ return ctx.send({
+ type: 'danger',
+ markdown: true,
+ message: `**Cannot save \`server.cfg\` due to error(s) in your config file(s):**\n${result.errors}`,
+ });
+ }
+ if (result.warnings) {
+ return ctx.send({
+ type: 'warning',
+ markdown: true,
+ message: `**File saved, but there are warnings you should pay attention to:**\n${result.warnings}`,
+ });
+ }
+ return ctx.send({
+ type: 'success',
+ markdown: true,
+ message: '**File saved.**',
+ });
+};
diff --git a/core/routes/deployer/actions.js b/core/routes/deployer/actions.js
new file mode 100644
index 0000000..c84fa82
--- /dev/null
+++ b/core/routes/deployer/actions.js
@@ -0,0 +1,286 @@
+const modulename = 'WebServer:DeployerActions';
+import path from 'node:path';
+import { cloneDeep } from 'lodash-es';
+import slash from 'slash';
+import mysql from 'mysql2/promise';
+import consts from '@shared/consts';
+import { txEnv, txHostConfig } from '@core/globalData';
+import { validateModifyServerConfig } from '@lib/fxserver/fxsConfigHelper';
+import consoleFactory from '@lib/console';
+import { SYM_RESET_CONFIG } from '@lib/symbols';
+const console = consoleFactory(modulename);
+
+//Helper functions
+const isUndefined = (x) => (x === undefined);
+
+
+/**
+ * Handle all the server control actions
+ * @param {object} ctx
+ */
+export default async function DeployerActions(ctx) {
+ //Sanity check
+ if (isUndefined(ctx.params.action)) {
+ return ctx.utils.error(400, 'Invalid Request');
+ }
+ const action = ctx.params.action;
+
+ //Check permissions
+ if (!ctx.admin.testPermission('master', modulename)) {
+ return ctx.send({ success: false, refresh: true });
+ }
+
+ //Check if this is the correct state for the deployer
+ if (txManager.deployer == null) {
+ return ctx.send({ success: false, refresh: true });
+ }
+
+ //Delegate to the specific action functions
+ if (action == 'confirmRecipe') {
+ return await handleConfirmRecipe(ctx);
+ } else if (action == 'setVariables') {
+ return await handleSetVariables(ctx);
+ } else if (action == 'commit') {
+ return await handleSaveConfig(ctx);
+ } else if (action == 'cancel') {
+ return await handleCancel(ctx);
+ } else {
+ return ctx.send({
+ type: 'danger',
+ message: 'Unknown setup action.',
+ });
+ }
+};
+
+
+//================================================================
+/**
+ * Handle submition of user-edited recipe (record to deployer, starts the process)
+ * @param {object} ctx
+ */
+async function handleConfirmRecipe(ctx) {
+ //Sanity check
+ if (isUndefined(ctx.request.body.recipe)) {
+ return ctx.utils.error(400, 'Invalid Request - missing parameters');
+ }
+ const userEditedRecipe = ctx.request.body.recipe;
+
+ try {
+ ctx.admin.logAction('Setting recipe.');
+ await txManager.deployer.confirmRecipe(userEditedRecipe);
+ } catch (error) {
+ return ctx.send({ type: 'danger', message: error.message });
+ }
+
+ return ctx.send({ success: true });
+}
+
+
+//================================================================
+/**
+ * Handle submition of the input variables/parameters
+ * @param {object} ctx
+ */
+async function handleSetVariables(ctx) {
+ //Sanity check
+ if (isUndefined(ctx.request.body.svLicense)) {
+ return ctx.utils.error(400, 'Invalid Request - missing parameters');
+ }
+ const userVars = cloneDeep(ctx.request.body);
+
+ //Validating sv_licenseKey
+ if (
+ !consts.regexSvLicenseNew.test(userVars.svLicense)
+ && !consts.regexSvLicenseOld.test(userVars.svLicense)
+ ) {
+ return ctx.send({ type: 'danger', message: 'The Server License does not appear to be valid.' });
+ }
+
+ //Validating steam api key requirement
+ if (
+ txManager.deployer.recipe.steamRequired
+ && (typeof userVars.steam_webApiKey !== 'string' || userVars.steam_webApiKey.length < 24)
+ ) {
+ return ctx.send({
+ type: 'danger',
+ message: 'This recipe requires steam_webApiKey to be set and valid.',
+ });
+ }
+
+ //DB Stuff
+ if (typeof userVars.dbDelete !== 'undefined') {
+ //Testing the db config
+ try {
+ userVars.dbPort = parseInt(userVars.dbPort);
+ if (isNaN(userVars.dbPort)) {
+ return ctx.send({ type: 'danger', message: 'The database port is invalid (non-integer). The default is 3306.' });
+ }
+
+ const mysqlOptions = {
+ host: userVars.dbHost,
+ port: userVars.dbPort,
+ user: userVars.dbUsername,
+ password: userVars.dbPassword,
+ connectTimeout: 5000,
+ };
+ await mysql.createConnection(mysqlOptions);
+ } catch (error) {
+ let outMessage = error?.message ?? 'Unknown error occurred.';
+ if (error?.code === 'ECONNREFUSED') {
+ let specificError = (txEnv.isWindows)
+ ? 'If you do not have a database installed, you can download and run XAMPP.'
+ : 'If you do not have a database installed, you must download and run MySQL or MariaDB.';
+ if (userVars.dbPort !== 3306) {
+ specificError += ' \nYou are not using the default DB port 3306, make sure it is correct! ';
+ }
+ outMessage = `${error?.message} \n${specificError}`;
+ } else if (error.message?.includes('auth_gssapi_client')) {
+ outMessage = `Your database does not accept the required authentication method. Please update your MySQL/MariaDB server and try again.`;
+ }
+
+ return ctx.send({ type: 'danger', message: `Database connection failed: ${outMessage}` });
+ }
+
+ //Setting connection string
+ userVars.dbDelete = (userVars.dbDelete === 'true');
+ const dbFullHost = (userVars.dbPort === 3306)
+ ? userVars.dbHost
+ : `${userVars.dbHost}:${userVars.dbPort}`;
+ userVars.dbConnectionString = (userVars.dbPassword.length)
+ ? `mysql://${userVars.dbUsername}:${userVars.dbPassword}@${dbFullHost}/${userVars.dbName}?charset=utf8mb4`
+ : `mysql://${userVars.dbUsername}@${dbFullHost}/${userVars.dbName}?charset=utf8mb4`;
+ }
+
+ //Max Clients & Server Endpoints
+ userVars.maxClients = (txHostConfig.forceMaxClients) ? txHostConfig.forceMaxClients : 48;
+ if (txHostConfig.netInterface || txHostConfig.fxsPort) {
+ const comment = `# ${txHostConfig.sourceName}: do not modify!`;
+ const endpointIface = txHostConfig.netInterface ?? '0.0.0.0';
+ const endpointPort = txHostConfig.fxsPort ?? 30120;
+ userVars.serverEndpoints = [
+ `endpoint_add_tcp "${endpointIface}:${endpointPort}" ${comment}`,
+ `endpoint_add_udp "${endpointIface}:${endpointPort}" ${comment}`,
+ ].join('\n');
+ } else {
+ userVars.serverEndpoints = [
+ 'endpoint_add_tcp "0.0.0.0:30120"',
+ 'endpoint_add_udp "0.0.0.0:30120"',
+ ].join('\n');
+ }
+
+ //Setting identifiers array
+ const admin = txCore.adminStore.getAdminByName(ctx.admin.name);
+ if (!admin) return ctx.send({ type: 'danger', message: 'Admin not found.' });
+ const addPrincipalLines = [];
+ Object.keys(admin.providers).forEach((providerName) => {
+ if (admin.providers[providerName].identifier) {
+ addPrincipalLines.push(`add_principal identifier.${admin.providers[providerName].identifier} group.admin #${ctx.admin.name}`);
+ }
+ });
+ userVars.addPrincipalsMaster = (addPrincipalLines.length)
+ ? addPrincipalLines.join('\n')
+ : '# Deployer Note: this admin master has no identifiers to be automatically added.\n# add_principal identifier.discord:111111111111111111 group.admin #example';
+
+ //Start deployer
+ try {
+ ctx.admin.logAction('Running recipe.');
+ txManager.deployer.start(userVars);
+ } catch (error) {
+ return ctx.send({ type: 'danger', message: error.message });
+ }
+
+ return ctx.send({ success: true });
+}
+
+
+//================================================================
+/**
+ * Handle the commit of a Recipe by receiving the user edited server.cfg
+ * @param {object} ctx
+ */
+async function handleSaveConfig(ctx) {
+ //Sanity check
+ if (isUndefined(ctx.request.body.serverCFG)) {
+ return ctx.utils.error(400, 'Invalid Request - missing parameters');
+ }
+ const serverCFG = ctx.request.body.serverCFG;
+ const cfgFilePath = path.join(txManager.deployer.deployPath, 'server.cfg');
+ txCore.cacheStore.set('deployer:recipe', txManager.deployer?.recipe?.name ?? 'unknown');
+
+ //Validating config contents + saving file and backup
+ try {
+ const result = await validateModifyServerConfig(serverCFG, cfgFilePath, txManager.deployer.deployPath);
+ if (result.errors) {
+ return ctx.send({
+ type: 'danger',
+ success: false,
+ markdown: true,
+ message: `**Cannot save \`server.cfg\` due to error(s) in your config file(s):**\n${result.errors}`,
+ });
+ }
+ } catch (error) {
+ return ctx.send({
+ type: 'danger',
+ success: false,
+ markdown: true,
+ message: `**Failed to save \`server.cfg\` with error:**\n${error.message}`,
+ });
+ }
+
+ //Preparing & saving config
+ let onesync = SYM_RESET_CONFIG;
+ if (typeof txManager.deployer?.recipe?.onesync === 'string' && txManager.deployer.recipe.onesync.length) {
+ onesync = txManager.deployer.recipe.onesync;
+ }
+ try {
+ txCore.configStore.saveConfigs({
+ server: {
+ dataPath: slash(path.normalize(txManager.deployer.deployPath)),
+ cfgPath: 'server.cfg',
+ onesync,
+ }
+ }, ctx.admin.name);
+ } catch (error) {
+ console.warn(`[${ctx.admin.name}] Error changing fxserver settings via deployer.`);
+ console.verbose.dir(error);
+ return ctx.send({
+ type: 'danger',
+ markdown: true,
+ message: `**Error saving the configuration file:** ${error.message}`
+ });
+ }
+
+ ctx.admin.logAction('Completed and committed server deploy.');
+
+ //If running (for some reason), kill it first
+ if (!txCore.fxRunner.isIdle) {
+ ctx.admin.logCommand('STOP SERVER');
+ await txCore.fxRunner.killServer('new server deployed', ctx.admin.name, true);
+ }
+
+ //Starting server
+ const spawnError = await txCore.fxRunner.spawnServer(false);
+ if (spawnError !== null) {
+ return ctx.send({
+ type: 'danger',
+ markdown: true,
+ message: `Config file saved, but failed to start server with error:\n${spawnError}`,
+ });
+ } else {
+ txManager.deployer = null;
+ txCore.webServer.webSocket.pushRefresh('status');
+ return ctx.send({ success: true });
+ }
+}
+
+
+//================================================================
+/**
+ * Handle the cancellation of the deployer proguess
+ * @param {object} ctx
+ */
+async function handleCancel(ctx) {
+ txManager.deployer = null;
+ txCore.webServer.webSocket.pushRefresh('status');
+ return ctx.send({ success: true });
+}
diff --git a/core/routes/deployer/status.js b/core/routes/deployer/status.js
new file mode 100644
index 0000000..74a8327
--- /dev/null
+++ b/core/routes/deployer/status.js
@@ -0,0 +1,35 @@
+const modulename = 'WebServer:DeployerStatus';
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+
+
+/**
+ * Returns the output page containing the live console
+ * @param {object} ctx
+ */
+export default async function DeployerStatus(ctx) {
+ //Check permissions
+ if (!ctx.admin.hasPermission('all_permissions')) {
+ return ctx.send({success: false, refresh: true});
+ }
+
+ //Check if this is the correct state for the deployer
+ if (txManager.deployer == null) {
+ return ctx.send({success: false, refresh: true});
+ }
+
+ //Prepare data
+ const outData = {
+ progress: txManager.deployer.progress,
+ log: txManager.deployer.getDeployerLog(),
+ };
+ if (txManager.deployer.step == 'configure') {
+ outData.status = 'done';
+ } else if (txManager.deployer.deployFailed) {
+ outData.status = 'failed';
+ } else {
+ outData.status = 'running';
+ }
+
+ return ctx.send(outData);
+};
diff --git a/core/routes/deployer/stepper.js b/core/routes/deployer/stepper.js
new file mode 100644
index 0000000..2e0414b
--- /dev/null
+++ b/core/routes/deployer/stepper.js
@@ -0,0 +1,95 @@
+const modulename = 'WebServer:DeployerStepper';
+import fse from 'fs-extra';
+import { txHostConfig } from '@core/globalData';
+import consoleFactory from '@lib/console';
+import { TxConfigState } from '@shared/enums';
+const console = consoleFactory(modulename);
+
+
+/**
+ * Returns the output page containing the deployer stepper page (all 3 stages)
+ * @param {object} ctx
+ */
+export default async function DeployerStepper(ctx) {
+ //Check permissions
+ if (!ctx.admin.hasPermission('master')) {
+ return ctx.utils.render('main/message', { message: 'You need to be the admin master to use the deployer.' });
+ }
+
+ //Ensure the correct state for the deployer
+ if(txManager.configState === TxConfigState.Setup) {
+ return ctx.utils.legacyNavigateToPage('/server/setup');
+ } else if(txManager.configState !== TxConfigState.Deployer) {
+ return ctx.utils.legacyNavigateToPage('/');
+ } else if(!txManager.deployer?.step){
+ throw new Error(`txManager.configState is Deployer but txManager.deployer is not defined`);
+ }
+
+ //Prepare Output
+ const renderData = {
+ step: txManager.deployer.step,
+ deploymentID: txManager.deployer.deploymentID,
+ requireDBConfig: false,
+ defaultLicenseKey: '',
+ recipe: undefined,
+ defaults: {},
+ };
+
+ if (txManager.deployer.step === 'review') {
+ renderData.recipe = {
+ isTrustedSource: txManager.deployer.isTrustedSource,
+ name: txManager.deployer.recipe.name,
+ author: txManager.deployer.recipe.author,
+ description: txManager.deployer.recipe.description,
+ raw: txManager.deployer.recipe.raw,
+ };
+ } else if (txManager.deployer.step === 'input') {
+ renderData.defaultLicenseKey = txHostConfig.defaults.cfxKey ?? '';
+ renderData.requireDBConfig = txManager.deployer.recipe.requireDBConfig;
+ renderData.defaults = {
+ autofilled: Object.values(txHostConfig.defaults).some(Boolean),
+ license: txHostConfig.defaults.cfxKey ?? '',
+ mysqlHost: txHostConfig.defaults.dbHost ?? 'localhost',
+ mysqlPort: txHostConfig.defaults.dbPort ?? '3306',
+ mysqlUser: txHostConfig.defaults.dbUser ?? 'root',
+ mysqlPassword: txHostConfig.defaults.dbPass ?? '',
+ mysqlDatabase: txHostConfig.defaults.dbName ?? txManager.deployer.deploymentID,
+ };
+
+ const knownVarDescriptions = {
+ steam_webApiKey: 'The Steam Web API Key is used to authenticate players when they join. \nYou can get one at https://steamcommunity.com/dev/apikey.',
+ }
+ const recipeVars = txManager.deployer.getRecipeVars();
+ renderData.inputVars = Object.keys(recipeVars).map((name) => {
+ return {
+ name: name,
+ value: recipeVars[name],
+ description: knownVarDescriptions[name] || '',
+ };
+ });
+ } else if (txManager.deployer.step === 'run') {
+ renderData.deployPath = txManager.deployer.deployPath;
+ } else if (txManager.deployer.step === 'configure') {
+ const errorMessage = `# server.cfg Not Found!
+# This probably means you deleted it before pressing "Next".
+# Press cancel and start the deployer again,
+# or insert here the server.cfg contents.
+# (╯°□°)╯︵ ┻━┻`;
+ try {
+ renderData.serverCFG = await fse.readFile(`${txManager.deployer.deployPath}/server.cfg`, 'utf8');
+ if (renderData.serverCFG == '#save_attempt_please_ignore' || !renderData.serverCFG.length) {
+ renderData.serverCFG = errorMessage;
+ } else if (renderData.serverCFG.length > 10240) { //10kb
+ renderData.serverCFG = `# This recipe created a ./server.cfg above 10kb, meaning its probably the wrong data.
+Make sure everything is correct in the recipe and try again.`;
+ }
+ } catch (error) {
+ console.verbose.dir(error);
+ renderData.serverCFG = errorMessage;
+ }
+ } else {
+ return ctx.utils.render('main/message', { message: 'Unknown Deployer step, please report this bug and restart txAdmin.' });
+ }
+
+ return ctx.utils.render('standalone/deployer', renderData);
+};
diff --git a/core/routes/devDebug.ts b/core/routes/devDebug.ts
new file mode 100644
index 0000000..c2aaa1a
--- /dev/null
+++ b/core/routes/devDebug.ts
@@ -0,0 +1,74 @@
+const modulename = 'WebServer:DevDebug';
+import { AuthedCtx } from '@modules/WebServer/ctxTypes';
+import { txDevEnv } from '@core/globalData';
+import consoleFactory from '@lib/console';
+import { z } from 'zod';
+const console = consoleFactory(modulename);
+
+
+//Helpers
+const devWarningMessage = 'The unsafe privileged /dev webroute was called and should only be used in developer mode.';
+const paramsSchema = z.object({
+ scope: z.string(),
+});
+let playerJoinCounter = 0;
+
+
+/**
+ * Handler for the GET calls
+ */
+export const get = async (ctx: AuthedCtx) => {
+ //Sanity check
+ if (!txDevEnv.ENABLED) return ctx.send({ error: 'this route is dev mode only' });
+ const schemaRes = paramsSchema.safeParse(ctx.params);
+ if (!schemaRes.success) return ctx.utils.error(400, 'Invalid Request');
+ console.warn(devWarningMessage);
+ const { scope } = schemaRes.data;
+
+ return ctx.send({
+ rand: Math.random()
+ });
+};
+
+
+/**
+ * Handler for the POST calls
+ */
+export const post = async (ctx: AuthedCtx) => {
+ //Sanity check
+ if (!txDevEnv.ENABLED) return ctx.send({ error: 'this route is dev mode only' });
+ const schemaRes = paramsSchema.safeParse(ctx.params);
+ if (!schemaRes.success) return ctx.utils.error(400, 'Invalid Request');
+ console.warn(devWarningMessage);
+ const { scope } = schemaRes.data;
+
+ if (scope === 'event') {
+ try {
+ if(!txCore.fxRunner.child?.isAlive){
+ return ctx.send({ error: 'server not ready' });
+ }
+ if (ctx.request.body.id === null) {
+ if (ctx.request.body.event === 'playerDropped') {
+ const onlinePlayers = txCore.fxPlayerlist.getPlayerList();
+ if (onlinePlayers.length){
+ ctx.request.body.id = onlinePlayers[0].netid;
+ }
+ } else if (ctx.request.body.event === 'playerJoining') {
+ ctx.request.body.id = playerJoinCounter + 101;
+ }
+ }
+ if (ctx.request.body.event === 'playerJoining') {
+ playerJoinCounter++;
+ }
+ txCore.fxPlayerlist.handleServerEvents(ctx.request.body, txCore.fxRunner.child.mutex);
+ return ctx.send({ success: true });
+ } catch (error) {
+ console.dir(error);
+ }
+
+ } else {
+ console.dir(scope);
+ console.dir(ctx.request.body);
+ return ctx.send({ error: 'unknown scope' });
+ }
+};
diff --git a/core/routes/diagnostics/page.ts b/core/routes/diagnostics/page.ts
new file mode 100644
index 0000000..b35ac26
--- /dev/null
+++ b/core/routes/diagnostics/page.ts
@@ -0,0 +1,37 @@
+const modulename = 'WebServer:Diagnostics';
+import { AuthedCtx } from '@modules/WebServer/ctxTypes';
+import MemCache from '@lib/MemCache';
+import * as diagnosticsFuncs from '@lib/diagnostics';
+import consoleFactory from '@lib/console';
+const console = consoleFactory(modulename);
+const cache = new MemCache(5);
+
+
+/**
+ * Returns the output page containing the full report
+ */
+export default async function Diagnostics(ctx: AuthedCtx) {
+ const cachedData = cache.get();
+ if (cachedData) {
+ cachedData.message = 'This page was cached in the last 5 seconds';
+ return ctx.utils.render('main/diagnostics', cachedData);
+ }
+
+ const timeStart = Date.now();
+ const data: any = {
+ headerTitle: 'Diagnostics',
+ message: '',
+ };
+ [data.host, data.txadmin, data.fxserver, data.proccesses] = await Promise.all([
+ diagnosticsFuncs.getHostData(),
+ diagnosticsFuncs.getTxAdminData(),
+ diagnosticsFuncs.getFXServerData(),
+ diagnosticsFuncs.getProcessesData(),
+ ]);
+
+ const timeElapsed = Date.now() - timeStart;
+ data.message = `Executed in ${timeElapsed} ms`;
+
+ cache.set(data);
+ return ctx.utils.render('main/diagnostics', data);
+};
diff --git a/core/routes/diagnostics/sendReport.ts b/core/routes/diagnostics/sendReport.ts
new file mode 100644
index 0000000..770d076
--- /dev/null
+++ b/core/routes/diagnostics/sendReport.ts
@@ -0,0 +1,172 @@
+const modulename = 'WebServer:SendDiagnosticsReport';
+import got from '@lib/got';
+import { txEnv, txHostConfig } from '@core/globalData';
+import { GenericApiErrorResp } from '@shared/genericApiTypes';
+import * as diagnosticsFuncs from '@lib/diagnostics';
+import { redactApiKeys, redactStartupSecrets } from '@lib/misc';
+import { type ServerDataContentType, type ServerDataConfigsType, getServerDataContent, getServerDataConfigs } from '@lib/fxserver/serverData';
+import MemCache from '@lib/MemCache';
+import consoleFactory, { getLogBuffer } from '@lib/console';
+import { AuthedCtx } from '@modules/WebServer/ctxTypes';
+import scanMonitorFiles from '@lib/fxserver/scanMonitorFiles';
+const console = consoleFactory(modulename);
+
+//Consts & Helpers
+const reportIdCache = new MemCache(60);
+const maskedKeywords = ['key', 'license', 'pass', 'private', 'secret', 'token', 'webhook'];
+const maskString = (input: string) => input.replace(/\w/gi, 'x');
+const maskIps = (input: string) => input.replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/gi, 'x.x.x.x');
+type ServerLogType = {
+ ts: number;
+ type: string;
+ src: {
+ id: string | false;
+ name: string;
+ };
+ msg: string;
+}
+
+/**
+ * Prepares and sends the diagnostics report to txAPI
+ */
+export default async function SendDiagnosticsReport(ctx: AuthedCtx) {
+ type SuccessResp = {
+ reportId: string;
+ };
+ const sendTypedResp = (data: SuccessResp | GenericApiErrorResp) => ctx.send(data);
+
+ //Rate limit (and cache) report submissions
+ const cachedReportId = reportIdCache.get();
+ if (cachedReportId) {
+ return sendTypedResp({
+ error: `You can send at most one report per minute. Your last report ID was ${cachedReportId}.`
+ });
+ }
+
+ //Diagnostics
+ let diagnostics;
+ try {
+ const [host, txadmin, fxserver, proccesses] = await Promise.all([
+ diagnosticsFuncs.getHostData(),
+ diagnosticsFuncs.getTxAdminData(),
+ diagnosticsFuncs.getFXServerData(),
+ diagnosticsFuncs.getProcessesData(),
+ ]);
+ diagnostics = { host, txadmin, fxserver, proccesses };
+ } catch (error) { }
+
+ //Admins
+ const adminList = (txCore.adminStore.getRawAdminsList() as any[])
+ .map(a => ({ ...a, password_hash: '[REDACTED]' }));
+
+ //Settings
+ const storedConfigs = txCore.configStore.getStoredConfig() as any;
+ if (storedConfigs?.discordBot?.token) {
+ storedConfigs.discordBot.token = '[REDACTED]';
+ }
+ if (storedConfigs?.server?.startupArgs) {
+ storedConfigs.server.startupArgs = redactStartupSecrets(storedConfigs.server.startupArgs);
+ }
+
+ //Env vars
+ const envVars: Record = {};
+ for (const [envKey, envValue] of Object.entries(process.env)) {
+ if (!envValue) continue;
+
+ if (maskedKeywords.some((kw) => envKey.toLowerCase().includes(kw))) {
+ envVars[envKey] = maskString(envValue);
+ } else {
+ envVars[envKey] = envValue;
+ }
+ }
+
+ //Remove IP from logs
+ const txSystemLog = maskIps(getLogBuffer());
+
+ const rawTxActionLog = await txCore.logger.admin.getRecentBuffer();
+ const txActionLog = (typeof rawTxActionLog !== 'string')
+ ? 'error reading log file'
+ : maskIps(rawTxActionLog).split('\n').slice(-500).join('\n');
+
+ const serverLog = (txCore.logger.server.getRecentBuffer(500) as ServerLogType[])
+ .map((l) => ({ ...l, msg: maskIps(l.msg) }));
+ const fxserverLog = maskIps(txCore.logger.fxserver.getRecentBuffer());
+
+ //Getting server data content
+ let serverDataContent: ServerDataContentType = [];
+ let cfgFiles: ServerDataConfigsType = [];
+ //FIXME: use txCore.fxRunner.serverPaths
+ if (storedConfigs.server?.dataPath) {
+ serverDataContent = await getServerDataContent(storedConfigs.server.dataPath);
+ const rawCfgFiles = await getServerDataConfigs(storedConfigs.server.dataPath, serverDataContent);
+ cfgFiles = rawCfgFiles.map(([fName, fData]) => [fName, redactApiKeys(fData)]);
+ }
+
+ //Database & perf stats
+ let dbStats = {};
+ try {
+ dbStats = txCore.database.stats.getDatabaseStats();
+ } catch (error) { }
+
+ let perfSvMain: ReturnType = null;
+ try {
+ perfSvMain = txCore.metrics.svRuntime.getServerPerfSummary();
+ } catch (error) { }
+
+ //Monitor integrity check
+ let monitorContent = null;
+ try {
+ monitorContent = await scanMonitorFiles();
+ } catch (error) { }
+
+ //Prepare report object
+ const reportData = {
+ $schemaVersion: 2,
+ $txVersion: txEnv.txaVersion,
+ $fxVersion: txEnv.fxsVersion,
+ $provider: String(txHostConfig.providerName), //we want an 'undefined'
+ diagnostics,
+ txSystemLog,
+ txActionLog,
+ serverLog,
+ fxserverLog,
+ envVars,
+ perfSvMain,
+ dbStats,
+ settings: storedConfigs,
+ adminList,
+ serverDataContent,
+ cfgFiles,
+ monitorContent,
+ };
+
+ // //Preparing request
+ const requestOptions = {
+ url: `https://txapi.cfx-services.net/public/submit`,
+ // url: `http://127.0.0.1:8121/public/submit`,
+ retry: { limit: 1 },
+ json: reportData,
+ };
+
+ //Making HTTP Request
+ try {
+ type ResponseType = { reportId: string } | { error: string, message?: string };
+ const apiResp = await got.post(requestOptions).json() as ResponseType;
+ if ('reportId' in apiResp) {
+ reportIdCache.set(apiResp.reportId);
+ console.warn(`Diagnostics data report ID ${apiResp.reportId} sent by ${ctx.admin.name}`);
+ return sendTypedResp({ reportId: apiResp.reportId });
+ } else {
+ console.verbose.dir(apiResp);
+ return sendTypedResp({ error: `Report failed: ${apiResp.message ?? apiResp.error}` });
+ }
+ } catch (error) {
+ try {
+ const apiErrorResp = JSON.parse((error as any)?.response?.body);
+ const reason = apiErrorResp.message ?? apiErrorResp.error ?? (error as Error).message;
+ return sendTypedResp({ error: `Report failed: ${reason}` });
+ } catch (error2) {
+ return sendTypedResp({ error: `Report failed: ${(error as Error).message}` });
+ }
+ }
+};
diff --git a/core/routes/fxserver/commands.ts b/core/routes/fxserver/commands.ts
new file mode 100644
index 0000000..445f347
--- /dev/null
+++ b/core/routes/fxserver/commands.ts
@@ -0,0 +1,194 @@
+const modulename = 'WebServer:FXServerCommands';
+import { AuthedCtx } from '@modules/WebServer/ctxTypes';
+import consoleFactory from '@lib/console';
+import { ApiToastResp } from '@shared/genericApiTypes';
+import { txEnv } from '@core/globalData';
+const console = consoleFactory(modulename);
+
+//Helper functions
+const delay = async (ms: number) => {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+};
+
+
+/**
+ * Handle all the server commands
+ * @param {object} ctx
+ */
+export default async function FXServerCommands(ctx: AuthedCtx) {
+ if (
+ typeof ctx.request.body.action === 'undefined'
+ || typeof ctx.request.body.parameter === 'undefined'
+ ) {
+ return ctx.send({
+ type: 'error',
+ msg: 'Invalid request.',
+ });
+ }
+ const action = ctx.request.body.action;
+ const parameter = ctx.request.body.parameter;
+
+ //Ignore commands when the server is offline
+ if (!txCore.fxRunner.child?.isAlive) {
+ return ctx.send({
+ type: 'error',
+ msg: 'The server is not running.',
+ });
+ }
+
+ //Block starting/restarting the 'runcode' resource
+ const unsafeActions = ['restart_res', 'start_res', 'ensure_res'];
+ if (unsafeActions.includes(action) && parameter.includes('runcode')) {
+ return ctx.send({
+ type: 'error',
+ msg: 'The resource "runcode" might be unsafe. If you know what you are doing, run it via the Live Console.',
+ });
+ }
+
+
+ //==============================================
+ //DEBUG: Only available in the /advanced page
+ //FIXME: move to the advanced route, give button for profiling, saving mem snapshot, verbose, etc.
+ if (action == 'profile_monitor') {
+ if (!ensurePermission(ctx, 'all_permissions')) return false;
+ ctx.admin.logAction('Profiling txAdmin instance.');
+
+ const profileDuration = 5;
+ const savePath = `${txEnv.profilePath}/data/txProfile.bin`;
+ ExecuteCommand('profiler record start');
+ await delay(profileDuration * 1000);
+ ExecuteCommand('profiler record stop');
+ await delay(150);
+ ExecuteCommand(`profiler save "${savePath}"`);
+ await delay(150);
+ console.ok(`Profile saved to: ${savePath}`);
+ txCore.fxRunner.sendCommand('profiler', ['view', savePath], ctx.admin.name);
+ return ctx.send({
+ type: 'success',
+ msg: 'Check your live console in a few seconds.',
+ });
+
+ //==============================================
+ } else if (action == 'admin_broadcast') {
+ if (!ensurePermission(ctx, 'announcement')) return false;
+ const message = (parameter ?? '').trim();
+
+ // Dispatch `txAdmin:events:announcement`
+ txCore.fxRunner.sendEvent('announcement', {
+ message,
+ author: ctx.admin.name,
+ });
+ ctx.admin.logAction(`Sending announcement: ${parameter}`);
+
+ // Sending discord announcement
+ const publicAuthor = txCore.adminStore.getAdminPublicName(ctx.admin.name, 'message');
+ txCore.discordBot.sendAnnouncement({
+ type: 'info',
+ title: {
+ key: 'nui_menu.misc.announcement_title',
+ data: { author: publicAuthor }
+ },
+ description: message
+ });
+
+ return ctx.send({
+ type: 'success',
+ msg: 'Announcement command sent.',
+ });
+
+ //==============================================
+ } else if (action == 'kick_all') {
+ if (!ensurePermission(ctx, 'control.server')) return false;
+ const kickReason = (parameter ?? '').trim() || txCore.translator.t('kick_messages.unknown_reason');
+ const dropMessage = txCore.translator.t(
+ 'kick_messages.everyone',
+ { reason: kickReason }
+ );
+ ctx.admin.logAction(`Kicking all players: ${kickReason}`);
+ // Dispatch `txAdmin:events:playerKicked`
+ txCore.fxRunner.sendEvent('playerKicked', {
+ target: -1,
+ author: ctx.admin.name,
+ reason: kickReason,
+ dropMessage,
+ });
+ return ctx.send({
+ type: 'success',
+ msg: 'Kick All command sent.',
+ });
+
+ //==============================================
+ } else if (action == 'restart_res') {
+ if (!ensurePermission(ctx, 'commands.resources')) return false;
+ ctx.admin.logAction(`Restarted resource "${parameter}"`);
+ txCore.fxRunner.sendCommand('restart', [parameter], ctx.admin.name);
+ return ctx.send({
+ type: 'warning',
+ msg: 'Resource restart command sent.',
+ });
+
+ //==============================================
+ } else if (action == 'start_res') {
+ if (!ensurePermission(ctx, 'commands.resources')) return false;
+ ctx.admin.logAction(`Started resource "${parameter}"`);
+ txCore.fxRunner.sendCommand('start', [parameter], ctx.admin.name);
+ return ctx.send({
+ type: 'warning',
+ msg: 'Resource start command sent.',
+ });
+
+ //==============================================
+ } else if (action == 'ensure_res') {
+ if (!ensurePermission(ctx, 'commands.resources')) return false;
+ ctx.admin.logAction(`Ensured resource "${parameter}"`);
+ txCore.fxRunner.sendCommand('ensure', [parameter], ctx.admin.name);
+ return ctx.send({
+ type: 'warning',
+ msg: 'Resource ensure command sent.',
+ });
+
+ //==============================================
+ } else if (action == 'stop_res') {
+ if (!ensurePermission(ctx, 'commands.resources')) return false;
+ ctx.admin.logAction(`Stopped resource "${parameter}"`);
+ txCore.fxRunner.sendCommand('stop', [parameter], ctx.admin.name);
+ return ctx.send({
+ type: 'warning',
+ msg: 'Resource stop command sent.',
+ });
+
+ //==============================================
+ } else if (action == 'refresh_res') {
+ if (!ensurePermission(ctx, 'commands.resources')) return false;
+ ctx.admin.logAction(`Refreshed resources`);
+ txCore.fxRunner.sendCommand('refresh', [], ctx.admin.name);
+ return ctx.send({
+ type: 'warning',
+ msg: 'Refresh command sent.',
+ });
+
+ //==============================================
+ } else {
+ return ctx.send({
+ type: 'error',
+ msg: 'Unknown Action.',
+ });
+ }
+};
+
+
+//================================================================
+/**
+ * Wrapper function to check permission and give output if denied
+ */
+function ensurePermission(ctx: AuthedCtx, perm: string) {
+ if (ctx.admin.testPermission(perm, modulename)) {
+ return true;
+ } else {
+ ctx.send({
+ type: 'error',
+ msg: 'You don\'t have permission to execute this action.',
+ });
+ return false;
+ }
+}
diff --git a/core/routes/fxserver/controls.ts b/core/routes/fxserver/controls.ts
new file mode 100644
index 0000000..93ab82d
--- /dev/null
+++ b/core/routes/fxserver/controls.ts
@@ -0,0 +1,81 @@
+const modulename = 'WebServer:FXServerControls';
+import { AuthedCtx } from '@modules/WebServer/ctxTypes';
+import consoleFactory from '@lib/console';
+import { ApiToastResp } from '@shared/genericApiTypes';
+import { msToShortishDuration } from '@lib/misc';
+import ConfigStore from '@modules/ConfigStore';
+const console = consoleFactory(modulename);
+
+
+/**
+ * Handle all the server control actions
+ */
+export default async function FXServerControls(ctx: AuthedCtx) {
+ //Sanity check
+ if (typeof ctx.request.body?.action !== 'string') {
+ return ctx.utils.error(400, 'Invalid Request');
+ }
+ const { action } = ctx.request.body;
+
+ //Check permissions
+ if (!ctx.admin.testPermission('control.server', modulename)) {
+ return ctx.send({
+ type: 'error',
+ msg: 'You don\'t have permission to execute this action.',
+ });
+ }
+
+ if (action === 'restart') {
+ ctx.admin.logCommand('RESTART SERVER');
+
+ //If too much of a delay, do it async
+ const respawnDelay = txCore.fxRunner.restartSpawnDelay;
+ if (respawnDelay.ms > 10_000) {
+ txCore.fxRunner.restartServer('admin request', ctx.admin.name).catch((e) => { });
+ const durationStr = msToShortishDuration(
+ respawnDelay.ms,
+ { units: ['m', 's', 'ms'] }
+ );
+ return ctx.send({
+ type: 'warning',
+ msg: `The server is will restart with delay of ${durationStr}.`
+ });
+ } else {
+ const restartError = await txCore.fxRunner.restartServer('admin request', ctx.admin.name);
+ if (restartError !== null) {
+ return ctx.send({ type: 'error', md: true, msg: restartError });
+ } else {
+ return ctx.send({ type: 'success', msg: 'The server is now restarting.' });
+ }
+ }
+
+ } else if (action === 'stop') {
+ if (txCore.fxRunner.isIdle) {
+ return ctx.send({ type: 'success', msg: 'The server is already stopped.' });
+ }
+ ctx.admin.logCommand('STOP SERVER');
+ await txCore.fxRunner.killServer('admin request', ctx.admin.name, false);
+ return ctx.send