mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): ship bounded legacy panel console
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCHEMA = 'qinglong/local-legacy-panel-assets@v1';
|
||||
const MAX_FILES = 256;
|
||||
const MAX_TOTAL_BYTES = 13 * 1024 * 1024;
|
||||
const MAX_FILE_BYTES = 3 * 1024 * 1024;
|
||||
const SUPPORTED_ROUTES = Object.freeze(['/login', '/crontab', '/error']);
|
||||
const CONTENT_TYPES = Object.freeze({
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.ttf': 'font/ttf',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
});
|
||||
const HASHED_ASSET =
|
||||
/\.[0-9a-f]{8}(?:\.[A-Za-z0-9_-]+)*\.(?:css|js|ttf|woff2?)$/u;
|
||||
const EXTERNAL_FAVICON =
|
||||
/<link rel="shortcut icon" href="https:\/\/qn\.whyour\.cn\/favicon\.svg">\r?\n?/u;
|
||||
const ENVIRONMENT_SOURCE =
|
||||
"window.__ENV__ = Object.freeze({ QlBaseUrl: '/', DeployEnv: '', QL_DIR: '' });\n";
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(`QingLong legacy panel bundle failed: ${message}`);
|
||||
}
|
||||
|
||||
function sha256(body) {
|
||||
return crypto.createHash('sha256').update(body).digest('hex');
|
||||
}
|
||||
|
||||
function canonicalDirectory(directory, label) {
|
||||
const resolved = path.resolve(directory);
|
||||
if (resolved === path.parse(resolved).root) fail(`${label} is too broad`);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(resolved);
|
||||
} catch {
|
||||
fail(`${label} is unavailable`);
|
||||
}
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
fs.realpathSync(resolved) !== resolved
|
||||
) {
|
||||
fail(`${label} must be a canonical directory`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function outputDirectory(directory, sourceRoot) {
|
||||
const resolved = path.resolve(directory);
|
||||
if (
|
||||
resolved === path.parse(resolved).root ||
|
||||
resolved === sourceRoot ||
|
||||
resolved.startsWith(`${sourceRoot}${path.sep}`) ||
|
||||
sourceRoot.startsWith(`${resolved}${path.sep}`) ||
|
||||
fs.existsSync(resolved)
|
||||
) {
|
||||
fail('output must be an absent directory outside the source closure');
|
||||
}
|
||||
const parent = canonicalDirectory(path.dirname(resolved), 'output parent');
|
||||
if (path.dirname(resolved) !== parent) fail('output parent drifted');
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function sourceFiles(root) {
|
||||
const files = [];
|
||||
const pending = [root];
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop();
|
||||
const entries = fs
|
||||
.readdirSync(directory, { withFileTypes: true })
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
const filePath = path.join(directory, entry.name);
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (stat.isSymbolicLink()) fail('source closure contains a symlink');
|
||||
if (stat.isDirectory()) {
|
||||
pending.push(filePath);
|
||||
continue;
|
||||
}
|
||||
if (!stat.isFile()) fail('source closure contains a special file');
|
||||
const relative = path.relative(root, filePath).split(path.sep).join('/');
|
||||
if (relative.endsWith('.gz') || relative.startsWith('monaco-editor/')) {
|
||||
continue;
|
||||
}
|
||||
const extension = path.extname(relative);
|
||||
if (!Object.hasOwn(CONTENT_TYPES, extension)) {
|
||||
fail(`unsupported source asset ${relative}`);
|
||||
}
|
||||
if (relative !== 'index.html' && !HASHED_ASSET.test(relative)) {
|
||||
fail(`mutable source asset ${relative}`);
|
||||
}
|
||||
files.push(
|
||||
Object.freeze({ filePath, relative, extension, bytes: stat.size }),
|
||||
);
|
||||
}
|
||||
}
|
||||
files.sort((left, right) => left.relative.localeCompare(right.relative));
|
||||
if (
|
||||
files.length < 3 ||
|
||||
files.length > MAX_FILES - 1 ||
|
||||
files[0]?.relative === undefined ||
|
||||
!files.some(({ relative }) => relative === 'index.html')
|
||||
) {
|
||||
fail('source asset count or entrypoint is invalid');
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function normalizedIndex(source) {
|
||||
const index = source.toString('utf8');
|
||||
if (
|
||||
!index.includes('<div id="root"></div>') ||
|
||||
!index.includes('<script src="./api/env.js"></script>') ||
|
||||
!/<script src="\.\/umi\.[0-9a-f]{8}\.js"><\/script>/u.test(index) ||
|
||||
!/<link rel="stylesheet" href="\.\/umi\.[0-9a-f]{8}\.css">/u.test(index) ||
|
||||
!EXTERNAL_FAVICON.test(index)
|
||||
) {
|
||||
fail('legacy panel entrypoint contract drifted');
|
||||
}
|
||||
const normalized = index.replace(EXTERNAL_FAVICON, '');
|
||||
if (/https?:\/\//u.test(normalized)) {
|
||||
fail('legacy panel entrypoint retains an external origin');
|
||||
}
|
||||
return Buffer.from(normalized, 'utf8');
|
||||
}
|
||||
|
||||
function assetRecord(relative, body) {
|
||||
const extension = path.extname(relative);
|
||||
const requestPath = relative === 'index.html' ? '/' : `/${relative}`;
|
||||
return Object.freeze({
|
||||
requestPath,
|
||||
file: relative,
|
||||
bytes: body.byteLength,
|
||||
sha256: sha256(body),
|
||||
contentType: CONTENT_TYPES[extension],
|
||||
cacheControl:
|
||||
relative === 'index.html' || relative === 'api/env.js'
|
||||
? 'no-store'
|
||||
: 'public, max-age=31536000, immutable',
|
||||
});
|
||||
}
|
||||
|
||||
function writeAsset(outputRoot, relative, body) {
|
||||
const target = path.join(outputRoot, ...relative.split('/'));
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o755 });
|
||||
fs.writeFileSync(target, body, { flag: 'wx', mode: 0o444 });
|
||||
}
|
||||
|
||||
function exactKeys(value, expected) {
|
||||
return (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
JSON.stringify(Object.keys(value).sort()) ===
|
||||
JSON.stringify([...expected].sort())
|
||||
);
|
||||
}
|
||||
|
||||
function readManifest(root) {
|
||||
const manifestPath = path.join(root, 'manifest.json');
|
||||
const stat = fs.lstatSync(manifestPath);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.size < 256 ||
|
||||
stat.size > 128 * 1024
|
||||
) {
|
||||
fail('manifest identity is invalid');
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
}
|
||||
|
||||
function auditLegacyPanelBundle(directory) {
|
||||
const root = canonicalDirectory(directory, 'bundle root');
|
||||
const manifest = readManifest(root);
|
||||
if (
|
||||
!exactKeys(manifest, [
|
||||
'schema',
|
||||
'source',
|
||||
'supportedRoutes',
|
||||
'fileCount',
|
||||
'totalBytes',
|
||||
'limits',
|
||||
'files',
|
||||
]) ||
|
||||
manifest.schema !== SCHEMA ||
|
||||
manifest.source !== 'qinglong-2.x-capability-gated-panel' ||
|
||||
JSON.stringify(manifest.supportedRoutes) !==
|
||||
JSON.stringify(SUPPORTED_ROUTES) ||
|
||||
!exactKeys(manifest.limits, [
|
||||
'maxFiles',
|
||||
'maxTotalBytes',
|
||||
'maxFileBytes',
|
||||
]) ||
|
||||
manifest.limits.maxFiles !== MAX_FILES ||
|
||||
manifest.limits.maxTotalBytes !== MAX_TOTAL_BYTES ||
|
||||
manifest.limits.maxFileBytes !== MAX_FILE_BYTES ||
|
||||
!Array.isArray(manifest.files) ||
|
||||
manifest.files.length < 4 ||
|
||||
manifest.files.length > MAX_FILES
|
||||
) {
|
||||
fail('manifest contract drifted');
|
||||
}
|
||||
const observed = [];
|
||||
let totalBytes = 0;
|
||||
let previousPath = '';
|
||||
const seenFiles = new Set(['manifest.json']);
|
||||
for (const entry of manifest.files) {
|
||||
if (
|
||||
!exactKeys(entry, [
|
||||
'requestPath',
|
||||
'file',
|
||||
'bytes',
|
||||
'sha256',
|
||||
'contentType',
|
||||
'cacheControl',
|
||||
]) ||
|
||||
typeof entry.requestPath !== 'string' ||
|
||||
!entry.requestPath.startsWith('/') ||
|
||||
entry.requestPath.includes('?') ||
|
||||
entry.requestPath <= previousPath ||
|
||||
typeof entry.file !== 'string' ||
|
||||
!/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u.test(entry.file) ||
|
||||
entry.file.split('/').includes('..') ||
|
||||
!Number.isSafeInteger(entry.bytes) ||
|
||||
entry.bytes < 0 ||
|
||||
entry.bytes > MAX_FILE_BYTES ||
|
||||
!/^[0-9a-f]{64}$/u.test(entry.sha256) ||
|
||||
!Object.values(CONTENT_TYPES).includes(entry.contentType) ||
|
||||
(entry.cacheControl !== 'no-store' &&
|
||||
entry.cacheControl !== 'public, max-age=31536000, immutable') ||
|
||||
seenFiles.has(entry.file)
|
||||
) {
|
||||
fail(
|
||||
`manifest asset entry is invalid after ${previousPath}: ${String(
|
||||
entry?.requestPath,
|
||||
).slice(0, 256)}`,
|
||||
);
|
||||
}
|
||||
previousPath = entry.requestPath;
|
||||
seenFiles.add(entry.file);
|
||||
const filePath = path.join(root, ...entry.file.split('/'));
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
fs.realpathSync(filePath) !== filePath ||
|
||||
stat.size !== entry.bytes
|
||||
) {
|
||||
fail(`asset identity drifted: ${entry.file}`);
|
||||
}
|
||||
const body = fs.readFileSync(filePath);
|
||||
if (sha256(body) !== entry.sha256) {
|
||||
fail(`asset digest drifted: ${entry.file}`);
|
||||
}
|
||||
totalBytes += body.byteLength;
|
||||
observed.push(entry.requestPath);
|
||||
}
|
||||
const diskFiles = sourceFilesForAudit(root);
|
||||
if (
|
||||
diskFiles.length !== seenFiles.size ||
|
||||
diskFiles.some((relative) => !seenFiles.has(relative)) ||
|
||||
manifest.fileCount !== manifest.files.length ||
|
||||
manifest.totalBytes !== totalBytes ||
|
||||
totalBytes > MAX_TOTAL_BYTES ||
|
||||
!observed.includes('/') ||
|
||||
!observed.includes('/api/env.js')
|
||||
) {
|
||||
fail('bundle closure or budget drifted');
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: manifest.schema,
|
||||
files: manifest.fileCount,
|
||||
bytes: manifest.totalBytes,
|
||||
maxFiles: MAX_FILES,
|
||||
maxBytes: MAX_TOTAL_BYTES,
|
||||
supportedRoutes: Object.freeze([...SUPPORTED_ROUTES]),
|
||||
compatible: true,
|
||||
});
|
||||
}
|
||||
|
||||
function sourceFilesForAudit(root) {
|
||||
const result = [];
|
||||
const pending = [root];
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop();
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const target = path.join(directory, entry.name);
|
||||
const stat = fs.lstatSync(target);
|
||||
if (stat.isSymbolicLink()) fail('bundle contains a symlink');
|
||||
if (stat.isDirectory()) pending.push(target);
|
||||
else if (stat.isFile()) {
|
||||
result.push(path.relative(root, target).split(path.sep).join('/'));
|
||||
} else fail('bundle contains a special file');
|
||||
}
|
||||
}
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
function bundleLegacyPanel(sourceDirectory, outputDirectoryPath) {
|
||||
const sourceRoot = canonicalDirectory(sourceDirectory, 'source root');
|
||||
const outputRoot = outputDirectory(outputDirectoryPath, sourceRoot);
|
||||
const files = sourceFiles(sourceRoot);
|
||||
fs.mkdirSync(outputRoot, { mode: 0o755 });
|
||||
try {
|
||||
const records = [];
|
||||
for (const file of files) {
|
||||
const source = fs.readFileSync(file.filePath);
|
||||
if (
|
||||
source.byteLength !== file.bytes ||
|
||||
source.byteLength > MAX_FILE_BYTES
|
||||
) {
|
||||
fail(`source asset size drifted: ${file.relative}`);
|
||||
}
|
||||
const body =
|
||||
file.relative === 'index.html' ? normalizedIndex(source) : source;
|
||||
writeAsset(outputRoot, file.relative, body);
|
||||
records.push(assetRecord(file.relative, body));
|
||||
}
|
||||
writeAsset(outputRoot, 'api/env.js', Buffer.from(ENVIRONMENT_SOURCE));
|
||||
records.push(assetRecord('api/env.js', Buffer.from(ENVIRONMENT_SOURCE)));
|
||||
records.sort((left, right) =>
|
||||
left.requestPath < right.requestPath
|
||||
? -1
|
||||
: left.requestPath > right.requestPath
|
||||
? 1
|
||||
: 0,
|
||||
);
|
||||
const totalBytes = records.reduce((total, entry) => total + entry.bytes, 0);
|
||||
if (records.length > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) {
|
||||
fail('output asset budget exceeded');
|
||||
}
|
||||
const manifest = {
|
||||
schema: SCHEMA,
|
||||
source: 'qinglong-2.x-capability-gated-panel',
|
||||
supportedRoutes: [...SUPPORTED_ROUTES],
|
||||
fileCount: records.length,
|
||||
totalBytes,
|
||||
limits: {
|
||||
maxFiles: MAX_FILES,
|
||||
maxTotalBytes: MAX_TOTAL_BYTES,
|
||||
maxFileBytes: MAX_FILE_BYTES,
|
||||
},
|
||||
files: records,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(outputRoot, 'manifest.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
{ flag: 'wx', mode: 0o444 },
|
||||
);
|
||||
return auditLegacyPanelBundle(outputRoot);
|
||||
} catch (error) {
|
||||
fs.rmSync(outputRoot, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function argumentsFrom(argv) {
|
||||
if (argv.length === 1 && argv[0].startsWith('--audit=')) {
|
||||
const directory = argv[0].slice('--audit='.length);
|
||||
if (!path.isAbsolute(directory)) fail('audit root must be absolute');
|
||||
return Object.freeze({ mode: 'audit', directory });
|
||||
}
|
||||
if (
|
||||
argv.length !== 2 ||
|
||||
!argv[0].startsWith('--source=') ||
|
||||
!argv[1].startsWith('--output=')
|
||||
) {
|
||||
fail('usage: --source=/absolute/static/dist --output=/absolute/bundle');
|
||||
}
|
||||
const source = argv[0].slice('--source='.length);
|
||||
const output = argv[1].slice('--output='.length);
|
||||
if (!path.isAbsolute(source) || !path.isAbsolute(output)) {
|
||||
fail('source and output must be absolute');
|
||||
}
|
||||
return Object.freeze({ mode: 'bundle', source, output });
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
const options = argumentsFrom(process.argv.slice(2));
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
options.mode === 'audit'
|
||||
? auditLegacyPanelBundle(options.directory)
|
||||
: bundleLegacyPanel(options.source, options.output),
|
||||
)}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Object.freeze({
|
||||
auditLegacyPanelBundle,
|
||||
bundleLegacyPanel,
|
||||
});
|
||||
Reference in New Issue
Block a user