mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): ship bounded legacy panel console
This commit is contained in:
@@ -1,9 +1,35 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const MAX_ASSET_BYTES = 96 * 1_024;
|
||||
const MAX_TOTAL_BYTES = 192 * 1_024;
|
||||
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({
|
||||
@@ -23,12 +49,26 @@ const DEFINITIONS = Object.freeze([
|
||||
}),
|
||||
]);
|
||||
|
||||
export interface LocalConsoleAsset {
|
||||
interface LocalConsoleAssetBase {
|
||||
readonly contentType: string;
|
||||
readonly etag: string;
|
||||
readonly body: Buffer;
|
||||
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 {
|
||||
@@ -40,7 +80,34 @@ export class LocalConsoleAssetError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function loadAsset(
|
||||
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> {
|
||||
@@ -53,7 +120,7 @@ function loadAsset(
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_ASSET_BYTES ||
|
||||
stat.size > MAX_LITE_ASSET_BYTES ||
|
||||
realpathSync(filePath) !== filePath
|
||||
) {
|
||||
throw new TypeError('asset identity is incompatible');
|
||||
@@ -70,29 +137,231 @@ function loadAsset(
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
export function loadLocalConsoleAssets(): LocalConsoleAssets {
|
||||
const root = path.resolve(__dirname, '../../assets/console');
|
||||
let canonicalRoot: string;
|
||||
try {
|
||||
const stat = lstatSync(root);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new TypeError();
|
||||
canonicalRoot = realpathSync(root);
|
||||
} catch (error) {
|
||||
throw new LocalConsoleAssetError('asset root', { cause: error });
|
||||
}
|
||||
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 = loadAsset(canonicalRoot, definition);
|
||||
totalBytes += asset.body.byteLength;
|
||||
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import type { LocalApplicationProfile } from '@qinglong/local-application';
|
||||
|
||||
@@ -52,9 +54,6 @@ const SECRET_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/secrets$/;
|
||||
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const LOCAL_CONSOLE_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'";
|
||||
|
||||
type LocalApiRouteResolution =
|
||||
| LocalApiAdmissionOperation
|
||||
| Readonly<{
|
||||
@@ -895,19 +894,22 @@ function send(
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function sendConsoleAsset(
|
||||
async function sendConsoleAsset(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
requestId: string,
|
||||
asset: Readonly<LocalConsoleAsset>,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (response.destroyed || response.headersSent) return;
|
||||
response.statusCode = 200;
|
||||
const ifNoneMatch = rawHeaderValues(request, 'if-none-match');
|
||||
const notModified =
|
||||
asset.cacheControl !== 'no-store' &&
|
||||
ifNoneMatch.length === 1 &&
|
||||
ifNoneMatch[0] === asset.etag;
|
||||
response.statusCode = notModified ? 304 : 200;
|
||||
response.setHeader('content-type', asset.contentType);
|
||||
response.setHeader('cache-control', 'no-store');
|
||||
response.setHeader(
|
||||
'content-security-policy',
|
||||
LOCAL_CONSOLE_CONTENT_SECURITY_POLICY,
|
||||
);
|
||||
response.setHeader('cache-control', asset.cacheControl);
|
||||
response.setHeader('content-security-policy', asset.contentSecurityPolicy);
|
||||
response.setHeader('cross-origin-opener-policy', 'same-origin');
|
||||
response.setHeader('cross-origin-resource-policy', 'same-origin');
|
||||
response.setHeader(
|
||||
@@ -919,8 +921,28 @@ function sendConsoleAsset(
|
||||
response.setHeader('x-frame-options', 'DENY');
|
||||
response.setHeader('x-request-id', requestId);
|
||||
response.setHeader('etag', asset.etag);
|
||||
response.setHeader('content-length', asset.body.byteLength);
|
||||
response.end(asset.body);
|
||||
if (notModified) {
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.setHeader('content-length', asset.byteLength);
|
||||
if ('body' in asset) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const finish = () => {
|
||||
response.off('error', reject);
|
||||
response.off('close', finish);
|
||||
resolve();
|
||||
};
|
||||
response.once('error', reject);
|
||||
response.once('close', finish);
|
||||
response.end(asset.body, finish);
|
||||
});
|
||||
return;
|
||||
}
|
||||
await pipeline(
|
||||
createReadStream(asset.filePath, { highWaterMark: 64 * 1_024 }),
|
||||
response,
|
||||
);
|
||||
}
|
||||
|
||||
function sendConsoleFavicon(response: ServerResponse, requestId: string): void {
|
||||
@@ -965,10 +987,13 @@ export async function startLocalApiHttpSurface(
|
||||
validateOptions(options);
|
||||
const consoleAssets = loadLocalConsoleAssets();
|
||||
const uuid = options.randomUuid ?? randomUUID;
|
||||
const maxConcurrentRequests = options.profile === 'edge' ? 4 : 32;
|
||||
const maxConcurrentApiRequests = options.profile === 'edge' ? 4 : 32;
|
||||
const maxConcurrentAssetRequests = options.profile === 'edge' ? 16 : 64;
|
||||
const drainTimeoutMs = options.profile === 'edge' ? 5_000 : 10_000;
|
||||
let accepting = true;
|
||||
const inFlight = new Set<Promise<void>>();
|
||||
const apiInFlight = new Set<Promise<void>>();
|
||||
const assetInFlight = new Set<Promise<void>>();
|
||||
const sockets = new Set<Socket>();
|
||||
|
||||
const server = http.createServer(
|
||||
@@ -983,21 +1008,39 @@ export async function startLocalApiHttpSurface(
|
||||
send(response, requestId, errorResponse(503, 'server_draining'));
|
||||
return;
|
||||
}
|
||||
if (inFlight.size >= maxConcurrentRequests) {
|
||||
send(response, requestId, errorResponse(503, 'server_overloaded'));
|
||||
return;
|
||||
}
|
||||
const consoleAsset =
|
||||
request.method === 'GET' && typeof request.url === 'string'
|
||||
? consoleAssets.get(request.url)
|
||||
: undefined;
|
||||
if (consoleAsset) {
|
||||
if (assetInFlight.size >= maxConcurrentAssetRequests) {
|
||||
send(response, requestId, errorResponse(503, 'server_overloaded'));
|
||||
return;
|
||||
}
|
||||
if (hasRequestBody(request)) {
|
||||
send(response, requestId, errorResponse(400, 'invalid_request_body'));
|
||||
request.resume();
|
||||
return;
|
||||
}
|
||||
sendConsoleAsset(response, requestId, consoleAsset);
|
||||
let operation: Promise<void>;
|
||||
operation = sendConsoleAsset(request, response, requestId, consoleAsset)
|
||||
.catch(() => {
|
||||
if (!response.headersSent) {
|
||||
send(
|
||||
response,
|
||||
requestId,
|
||||
errorResponse(503, 'response_unavailable'),
|
||||
);
|
||||
} else if (!response.destroyed) {
|
||||
response.destroy();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
assetInFlight.delete(operation);
|
||||
inFlight.delete(operation);
|
||||
});
|
||||
assetInFlight.add(operation);
|
||||
inFlight.add(operation);
|
||||
return;
|
||||
}
|
||||
if (request.method === 'GET' && request.url === '/favicon.ico') {
|
||||
@@ -1025,6 +1068,10 @@ export async function startLocalApiHttpSurface(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (apiInFlight.size >= maxConcurrentApiRequests) {
|
||||
send(response, requestId, errorResponse(503, 'server_overloaded'));
|
||||
return;
|
||||
}
|
||||
const resolvedRoute = route(request, options.profile);
|
||||
if (!resolvedRoute) {
|
||||
send(response, requestId, errorResponse(404, 'route_not_found'));
|
||||
@@ -1126,15 +1173,17 @@ export async function startLocalApiHttpSurface(
|
||||
send(response, requestId, errorResponse(503, 'request_unavailable')),
|
||||
)
|
||||
.finally(() => {
|
||||
apiInFlight.delete(operation);
|
||||
inFlight.delete(operation);
|
||||
});
|
||||
apiInFlight.add(operation);
|
||||
inFlight.add(operation);
|
||||
},
|
||||
);
|
||||
server.headersTimeout = 5_000;
|
||||
server.keepAliveTimeout = 5_000;
|
||||
server.maxRequestsPerSocket = 100;
|
||||
server.maxConnections = maxConcurrentRequests * 2;
|
||||
server.maxConnections = maxConcurrentApiRequests + maxConcurrentAssetRequests;
|
||||
server.on('connection', (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
|
||||
@@ -1,14 +1,56 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const net = require('node:net');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
loadLocalConsoleAssets,
|
||||
loadLocalConsolePanelAssets,
|
||||
} = require('../dist/console/localConsoleAssets.js');
|
||||
const {
|
||||
startLocalApiHttpSurface,
|
||||
} = require('../dist/transport/httpSurface.js');
|
||||
const {
|
||||
bundleLegacyPanel,
|
||||
} = require('../../../scripts/ql3-legacy-panel-bundle.cjs');
|
||||
|
||||
function panelFixture() {
|
||||
const root = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-api-panel-')),
|
||||
);
|
||||
const source = path.join(root, 'source');
|
||||
const output = path.join(root, 'output');
|
||||
fs.mkdirSync(source);
|
||||
fs.writeFileSync(
|
||||
path.join(source, 'index.html'),
|
||||
'<!DOCTYPE html>\n' +
|
||||
'<html><head>\n' +
|
||||
'<link rel="shortcut icon" href="https://qn.whyour.cn/favicon.svg">\n' +
|
||||
'<link rel="stylesheet" href="./umi.1234abcd.css">\n' +
|
||||
'<script src="./api/env.js"></script>\n' +
|
||||
'</head><body><div id="root"></div>\n' +
|
||||
'<script src="./umi.1234abcd.js"></script></body></html>\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(source, 'umi.1234abcd.css'),
|
||||
'body { color: #123; }\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(source, 'umi.1234abcd.js'),
|
||||
'globalThis.__panel = true;\n',
|
||||
);
|
||||
bundleLegacyPanel(source, output);
|
||||
return {
|
||||
root,
|
||||
output,
|
||||
close() {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function reservePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -117,6 +159,63 @@ test('loads one bounded offline Console asset closure', () => {
|
||||
assert.ok(totalBytes <= 192 * 1024);
|
||||
});
|
||||
|
||||
test('keeps the native Console route beside a manifested legacy panel', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, '../dist/console/localConsoleAssets.js'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /assets\.set\('\/console', liteAssets\.get\('\/'\)\)/u);
|
||||
assert.match(source, /panel conflicts with native Console asset/u);
|
||||
});
|
||||
|
||||
test('loads the manifested legacy panel as a streamed bounded closure', (t) => {
|
||||
const current = panelFixture();
|
||||
t.after(() => current.close());
|
||||
const assets = loadLocalConsolePanelAssets(current.output);
|
||||
assert.deepEqual(
|
||||
[...assets.keys()],
|
||||
[
|
||||
'/',
|
||||
'/api/env.js',
|
||||
'/umi.1234abcd.css',
|
||||
'/umi.1234abcd.js',
|
||||
'/login',
|
||||
'/crontab',
|
||||
'/error',
|
||||
],
|
||||
);
|
||||
const index = assets.get('/');
|
||||
assert.equal(index, assets.get('/login'));
|
||||
assert.equal(index, assets.get('/crontab'));
|
||||
assert.equal(index, assets.get('/error'));
|
||||
assert.equal(index.body, undefined);
|
||||
assert.equal(path.isAbsolute(index.filePath), true);
|
||||
assert.equal(index.cacheControl, 'no-store');
|
||||
assert.match(
|
||||
index.contentSecurityPolicy,
|
||||
/style-src 'self' 'unsafe-inline'/u,
|
||||
);
|
||||
assert.match(index.contentSecurityPolicy, /connect-src 'self'/u);
|
||||
const script = assets.get('/umi.1234abcd.js');
|
||||
assert.equal(script.body, undefined);
|
||||
assert.equal(script.cacheControl, 'public, max-age=31536000, immutable');
|
||||
assert.equal(script.byteLength, 27);
|
||||
assert.match(script.etag, /^"[0-9a-f]{64}"$/u);
|
||||
assert.equal(assets.get('/api/env.js').cacheControl, 'no-store');
|
||||
});
|
||||
|
||||
test('rejects a manifested panel whose immutable asset changed', (t) => {
|
||||
const current = panelFixture();
|
||||
t.after(() => current.close());
|
||||
const scriptPath = path.join(current.output, 'umi.1234abcd.js');
|
||||
fs.chmodSync(scriptPath, 0o600);
|
||||
fs.appendFileSync(scriptPath, 'drift');
|
||||
assert.throws(
|
||||
() => loadLocalConsolePanelAssets(current.output),
|
||||
/panel asset /u,
|
||||
);
|
||||
});
|
||||
|
||||
test('serves the Console without authentication and preserves API admission', async (t) => {
|
||||
const calls = [];
|
||||
const port = await reservePort();
|
||||
@@ -177,6 +276,31 @@ test('serves the Console without authentication and preserves API admission', as
|
||||
assert.deepEqual(calls, ['task.list']);
|
||||
});
|
||||
|
||||
test('serves an Edge browser asset burst without consuming API admission slots', async (t) => {
|
||||
const port = await reservePort();
|
||||
const active = await startLocalApiHttpSurface({
|
||||
profile: 'edge',
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: {
|
||||
async prepare() {
|
||||
throw new Error('static assets must not reach admission');
|
||||
},
|
||||
},
|
||||
});
|
||||
t.after(() => active.stopAndDrain());
|
||||
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 12 }, (_, index) =>
|
||||
request(port, index % 2 === 0 ? '/console.js' : '/console.css'),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
responses.map(({ statusCode }) => statusCode),
|
||||
Array.from({ length: 12 }, () => 200),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects request bodies and query aliases on Console assets', async (t) => {
|
||||
const port = await reservePort();
|
||||
const active = await startLocalApiHttpSurface({
|
||||
|
||||
Reference in New Issue
Block a user