#!/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 = /\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('
') || !index.includes('') || !/