monitor/core/modules/WebServer/middlewares/ctxUtilsMw.ts
2025-04-16 22:30:27 +07:00

230 lines
7.4 KiB
TypeScript

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: <T = string | object>(data: T) => void;
utils: {
render: (view: string, data?: { headerTitle?: string, [key: string]: any }) => Promise<void>;
error: (httpStatus?: number, message?: string) => void;
serveReactIndex: () => Promise<void>;
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 [
'<pre style="color: red">',
`Error rendering '${view}'.`,
`Message: ${xss(error.message)}`,
'The data provided was:',
'================',
xss(JSON.stringify(data, null, 2)),
'</pre>',
].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<object> = {}) => {
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 = `<style>
body {
margin: 0;
}
.notice {
font-family: sans-serif;
font-size: 1.5em;
text-align: center;
background-color: #222326;
color: #F7F7F8;
padding: 2em;
border: 1px solid #333539;
border-radius: 0.5em;
}
.notice a {
color: #F00A53;
}
</style>
<p class="notice">
Redirecting to <a href="{{href}}" target="_parent">{{href}}</a>...
</p>
<script>
// Notify parent window that auth failed
window.parent.postMessage({ type: 'navigateToPage', href: '{{href}}'});
// If parent redirect didn't work, redirect here
setTimeout(function() {
window.parent.location.href = '{{href}}';
}, 2000);
</script>`
/**
* 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 = <T = string | object>(data: T) => {
ctx.body = data;
};
return next();
};