230 lines
7.4 KiB
TypeScript
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();
|
|
};
|