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(); };