mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
368 lines
11 KiB
TypeScript
368 lines
11 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import {
|
|
existsSync,
|
|
lstatSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
} from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const MAX_LITE_ASSET_BYTES = 96 * 1_024;
|
|
const MAX_LITE_TOTAL_BYTES = 192 * 1_024;
|
|
const MAX_PANEL_FILES = 256;
|
|
const MAX_PANEL_TOTAL_BYTES = 13 * 1_024 * 1_024;
|
|
const MAX_PANEL_FILE_BYTES = 3 * 1_024 * 1_024;
|
|
const MAX_MANIFEST_BYTES = 128 * 1_024;
|
|
const PANEL_SCHEMA = 'qinglong/local-legacy-panel-assets@v1';
|
|
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';
|
|
const NO_STORE_CACHE = 'no-store';
|
|
const LITE_CONTENT_SECURITY_POLICY =
|
|
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
|
|
const PANEL_CONTENT_SECURITY_POLICY =
|
|
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; worker-src 'none'; manifest-src 'none'";
|
|
const PANEL_CONTENT_TYPES = new Set([
|
|
'text/css; charset=utf-8',
|
|
'text/html; charset=utf-8',
|
|
'text/javascript; charset=utf-8',
|
|
'font/ttf',
|
|
'font/woff',
|
|
'font/woff2',
|
|
]);
|
|
const PANEL_SUPPORTED_ROUTES = Object.freeze(['/login', '/crontab', '/error']);
|
|
|
|
const DEFINITIONS = Object.freeze([
|
|
Object.freeze({
|
|
requestPath: '/',
|
|
file: 'index.html',
|
|
contentType: 'text/html; charset=utf-8',
|
|
}),
|
|
Object.freeze({
|
|
requestPath: '/console.css',
|
|
file: 'console.css',
|
|
contentType: 'text/css; charset=utf-8',
|
|
}),
|
|
Object.freeze({
|
|
requestPath: '/console.js',
|
|
file: 'console.js',
|
|
contentType: 'text/javascript; charset=utf-8',
|
|
}),
|
|
]);
|
|
|
|
interface LocalConsoleAssetBase {
|
|
readonly contentType: string;
|
|
readonly etag: string;
|
|
readonly byteLength: number;
|
|
readonly cacheControl: string;
|
|
readonly contentSecurityPolicy: string;
|
|
}
|
|
|
|
export type LocalConsoleAsset =
|
|
| (LocalConsoleAssetBase &
|
|
Readonly<{
|
|
body: Buffer;
|
|
filePath?: never;
|
|
}>)
|
|
| (LocalConsoleAssetBase &
|
|
Readonly<{
|
|
body?: never;
|
|
filePath: string;
|
|
}>);
|
|
|
|
export type LocalConsoleAssets = ReadonlyMap<string, LocalConsoleAsset>;
|
|
|
|
export class LocalConsoleAssetError extends Error {
|
|
readonly code = 'QL3_LOCAL_CONSOLE_ASSET_INVALID';
|
|
|
|
constructor(message: string, options?: ErrorOptions) {
|
|
super(`Local Console asset is invalid: ${message}`, options);
|
|
this.name = 'LocalConsoleAssetError';
|
|
}
|
|
}
|
|
|
|
function exactKeys(value: unknown, expected: readonly string[]): boolean {
|
|
return (
|
|
!!value &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
JSON.stringify(Object.keys(value).sort()) ===
|
|
JSON.stringify([...expected].sort())
|
|
);
|
|
}
|
|
|
|
function canonicalDirectory(directory: string, label: string): string {
|
|
const resolved = path.resolve(directory);
|
|
try {
|
|
const stat = lstatSync(resolved);
|
|
if (
|
|
!stat.isDirectory() ||
|
|
stat.isSymbolicLink() ||
|
|
realpathSync(resolved) !== resolved
|
|
) {
|
|
throw new TypeError();
|
|
}
|
|
} catch (error) {
|
|
throw new LocalConsoleAssetError(label, { cause: error });
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function loadLiteAsset(
|
|
root: string,
|
|
definition: (typeof DEFINITIONS)[number],
|
|
): Readonly<LocalConsoleAsset> {
|
|
const filePath = path.join(root, definition.file);
|
|
let stat;
|
|
let body: Buffer;
|
|
try {
|
|
stat = lstatSync(filePath);
|
|
if (
|
|
!stat.isFile() ||
|
|
stat.isSymbolicLink() ||
|
|
stat.size < 2 ||
|
|
stat.size > MAX_LITE_ASSET_BYTES ||
|
|
realpathSync(filePath) !== filePath
|
|
) {
|
|
throw new TypeError('asset identity is incompatible');
|
|
}
|
|
body = readFileSync(filePath);
|
|
} catch (error) {
|
|
throw new LocalConsoleAssetError(definition.file, { cause: error });
|
|
}
|
|
if (body.byteLength !== stat.size) {
|
|
throw new LocalConsoleAssetError(
|
|
`${definition.file} changed while loading`,
|
|
);
|
|
}
|
|
return Object.freeze({
|
|
contentType: definition.contentType,
|
|
etag: `"${createHash('sha256').update(body).digest('hex')}"`,
|
|
byteLength: body.byteLength,
|
|
cacheControl: NO_STORE_CACHE,
|
|
contentSecurityPolicy: LITE_CONTENT_SECURITY_POLICY,
|
|
body,
|
|
});
|
|
}
|
|
|
|
function loadLiteAssets(root: string): LocalConsoleAssets {
|
|
const canonicalRoot = canonicalDirectory(root, 'asset root');
|
|
const assets = new Map<string, Readonly<LocalConsoleAsset>>();
|
|
let totalBytes = 0;
|
|
for (const definition of DEFINITIONS) {
|
|
const asset = loadLiteAsset(canonicalRoot, definition);
|
|
totalBytes += asset.byteLength;
|
|
if (totalBytes > MAX_LITE_TOTAL_BYTES) {
|
|
throw new LocalConsoleAssetError('asset set exceeds its byte budget');
|
|
}
|
|
assets.set(definition.requestPath, asset);
|
|
}
|
|
return assets;
|
|
}
|
|
|
|
function parsePanelManifest(root: string): Record<string, unknown> {
|
|
const manifestPath = path.join(root, 'manifest.json');
|
|
try {
|
|
const stat = lstatSync(manifestPath);
|
|
if (
|
|
!stat.isFile() ||
|
|
stat.isSymbolicLink() ||
|
|
stat.size < 256 ||
|
|
stat.size > MAX_MANIFEST_BYTES ||
|
|
realpathSync(manifestPath) !== manifestPath
|
|
) {
|
|
throw new TypeError();
|
|
}
|
|
const value: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new TypeError();
|
|
}
|
|
return value as Record<string, unknown>;
|
|
} catch (error) {
|
|
throw new LocalConsoleAssetError('panel manifest', { cause: error });
|
|
}
|
|
}
|
|
|
|
function panelDiskFiles(root: string): readonly string[] {
|
|
const result: string[] = [];
|
|
const pending = [root];
|
|
while (pending.length > 0) {
|
|
const directory = pending.pop()!;
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
const target = path.join(directory, entry.name);
|
|
const stat = lstatSync(target);
|
|
if (stat.isSymbolicLink()) {
|
|
throw new LocalConsoleAssetError('panel closure contains a symlink');
|
|
}
|
|
if (stat.isDirectory()) pending.push(target);
|
|
else if (stat.isFile()) {
|
|
result.push(path.relative(root, target).split(path.sep).join('/'));
|
|
} else {
|
|
throw new LocalConsoleAssetError(
|
|
'panel closure contains a special file',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return result.sort();
|
|
}
|
|
|
|
export function loadLocalConsolePanelAssets(
|
|
directory: string,
|
|
): LocalConsoleAssets {
|
|
const root = canonicalDirectory(directory, 'panel asset root');
|
|
const manifest = parsePanelManifest(root);
|
|
if (
|
|
!exactKeys(manifest, [
|
|
'schema',
|
|
'source',
|
|
'supportedRoutes',
|
|
'fileCount',
|
|
'totalBytes',
|
|
'limits',
|
|
'files',
|
|
]) ||
|
|
manifest.schema !== PANEL_SCHEMA ||
|
|
manifest.source !== 'qinglong-2.x-capability-gated-panel' ||
|
|
JSON.stringify(manifest.supportedRoutes) !==
|
|
JSON.stringify(PANEL_SUPPORTED_ROUTES) ||
|
|
!exactKeys(manifest.limits, [
|
|
'maxFiles',
|
|
'maxTotalBytes',
|
|
'maxFileBytes',
|
|
]) ||
|
|
(manifest.limits as Record<string, unknown>).maxFiles !== MAX_PANEL_FILES ||
|
|
(manifest.limits as Record<string, unknown>).maxTotalBytes !==
|
|
MAX_PANEL_TOTAL_BYTES ||
|
|
(manifest.limits as Record<string, unknown>).maxFileBytes !==
|
|
MAX_PANEL_FILE_BYTES ||
|
|
!Array.isArray(manifest.files) ||
|
|
manifest.files.length < 4 ||
|
|
manifest.files.length > MAX_PANEL_FILES
|
|
) {
|
|
throw new LocalConsoleAssetError('panel manifest contract drifted');
|
|
}
|
|
const assets = new Map<string, LocalConsoleAsset>();
|
|
const seenFiles = new Set<string>(['manifest.json']);
|
|
let previousRequestPath = '';
|
|
let totalBytes = 0;
|
|
for (const raw of manifest.files) {
|
|
if (
|
|
!exactKeys(raw, [
|
|
'requestPath',
|
|
'file',
|
|
'bytes',
|
|
'sha256',
|
|
'contentType',
|
|
'cacheControl',
|
|
])
|
|
) {
|
|
throw new LocalConsoleAssetError('panel asset entry shape drifted');
|
|
}
|
|
const entry = raw as Record<string, unknown>;
|
|
if (
|
|
typeof entry.requestPath !== 'string' ||
|
|
!entry.requestPath.startsWith('/') ||
|
|
entry.requestPath.includes('?') ||
|
|
entry.requestPath <= previousRequestPath ||
|
|
(entry.requestPath.startsWith('/api/') &&
|
|
entry.requestPath !== '/api/env.js') ||
|
|
typeof entry.file !== 'string' ||
|
|
!/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u.test(entry.file) ||
|
|
entry.file.split('/').includes('..') ||
|
|
seenFiles.has(entry.file) ||
|
|
!Number.isSafeInteger(entry.bytes) ||
|
|
Number(entry.bytes) < 0 ||
|
|
Number(entry.bytes) > MAX_PANEL_FILE_BYTES ||
|
|
typeof entry.sha256 !== 'string' ||
|
|
!/^[0-9a-f]{64}$/u.test(entry.sha256) ||
|
|
typeof entry.contentType !== 'string' ||
|
|
!PANEL_CONTENT_TYPES.has(entry.contentType) ||
|
|
(entry.cacheControl !== NO_STORE_CACHE &&
|
|
entry.cacheControl !== IMMUTABLE_CACHE)
|
|
) {
|
|
throw new LocalConsoleAssetError('panel asset entry is invalid');
|
|
}
|
|
previousRequestPath = entry.requestPath;
|
|
seenFiles.add(entry.file);
|
|
const filePath = path.join(root, ...entry.file.split('/'));
|
|
let body: Buffer;
|
|
let stat;
|
|
try {
|
|
stat = lstatSync(filePath);
|
|
if (
|
|
!stat.isFile() ||
|
|
stat.isSymbolicLink() ||
|
|
stat.size !== entry.bytes ||
|
|
realpathSync(filePath) !== filePath
|
|
) {
|
|
throw new TypeError();
|
|
}
|
|
body = readFileSync(filePath);
|
|
} catch (error) {
|
|
throw new LocalConsoleAssetError(`panel asset ${entry.file}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
if (
|
|
body.byteLength !== entry.bytes ||
|
|
createHash('sha256').update(body).digest('hex') !== entry.sha256
|
|
) {
|
|
throw new LocalConsoleAssetError(`panel asset ${entry.file} drifted`);
|
|
}
|
|
totalBytes += body.byteLength;
|
|
assets.set(
|
|
entry.requestPath,
|
|
Object.freeze({
|
|
contentType: entry.contentType,
|
|
etag: `"${entry.sha256}"`,
|
|
byteLength: body.byteLength,
|
|
cacheControl: entry.cacheControl,
|
|
contentSecurityPolicy: PANEL_CONTENT_SECURITY_POLICY,
|
|
filePath,
|
|
}),
|
|
);
|
|
}
|
|
if (
|
|
manifest.fileCount !== assets.size ||
|
|
manifest.totalBytes !== totalBytes ||
|
|
totalBytes > MAX_PANEL_TOTAL_BYTES ||
|
|
!assets.has('/') ||
|
|
!assets.has('/api/env.js')
|
|
) {
|
|
throw new LocalConsoleAssetError('panel asset closure drifted');
|
|
}
|
|
const diskFiles = panelDiskFiles(root);
|
|
if (
|
|
diskFiles.length !== seenFiles.size ||
|
|
diskFiles.some((file) => !seenFiles.has(file))
|
|
) {
|
|
throw new LocalConsoleAssetError('panel disk closure drifted');
|
|
}
|
|
const index = assets.get('/')!;
|
|
for (const route of PANEL_SUPPORTED_ROUTES) assets.set(route, index);
|
|
return assets;
|
|
}
|
|
|
|
export function loadLocalConsoleAssets(): LocalConsoleAssets {
|
|
const assetsRoot = path.resolve(__dirname, '../../assets');
|
|
const panelRoot = path.join(assetsRoot, 'panel');
|
|
const liteAssets = loadLiteAssets(path.join(assetsRoot, 'console'));
|
|
if (!existsSync(path.join(panelRoot, 'manifest.json'))) return liteAssets;
|
|
const assets = new Map(loadLocalConsolePanelAssets(panelRoot));
|
|
for (const requestPath of ['/console.css', '/console.js']) {
|
|
if (assets.has(requestPath)) {
|
|
throw new LocalConsoleAssetError(
|
|
`panel conflicts with native Console asset ${requestPath}`,
|
|
);
|
|
}
|
|
assets.set(requestPath, liteAssets.get(requestPath)!);
|
|
}
|
|
if (assets.has('/console')) {
|
|
throw new LocalConsoleAssetError(
|
|
'panel conflicts with native Console route /console',
|
|
);
|
|
}
|
|
assets.set('/console', liteAssets.get('/')!);
|
|
return assets;
|
|
}
|