在 Makelore 构建并预检静态发布产物
This commit is contained in:
202
electron/services/project-release-builder.ts
Normal file
202
electron/services/project-release-builder.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative, sep } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createStaticProjectPackage, type StaticProjectPackageSummary } from './project-packager';
|
||||
import { PublishRuntimeError, resolvePublishRuntime, runElectronNode } from './publish-runtime';
|
||||
import { createStaticArtifactSnapshot, type StaticArtifactSnapshot } from './static-release-server';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const AdmZip = require('adm-zip') as typeof import('adm-zip');
|
||||
const FIXED_ZIP_TIME = new Date(1980, 0, 1);
|
||||
const FILE_MODE = 0o100644;
|
||||
const MAX_OUTPUT_FILES = 2_000;
|
||||
const MAX_OUTPUT_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export type ArtifactContract = {
|
||||
schema_version: 1;
|
||||
entry_path: 'index.html';
|
||||
source_digest: string;
|
||||
built_archive_digest: string;
|
||||
artifact_digest: string;
|
||||
file_count: number;
|
||||
total_bytes: number;
|
||||
files: Array<{ path: string; size: number; sha256: string }>;
|
||||
security_profile: 'works-square-static-sandbox-v1';
|
||||
toolchain: { client: 'makelore'; client_version: string; node: string; npm: string; vite: string };
|
||||
};
|
||||
|
||||
export type PreparedRelease = {
|
||||
sourceArchive: { path: string; name: 'project.zip'; bytes: Buffer; summary: StaticProjectPackageSummary };
|
||||
builtArchive: { path: string; name: 'built-project.zip'; bytes: Buffer };
|
||||
contract: ArtifactContract;
|
||||
distRoot: string;
|
||||
staticArtifact: StaticArtifactSnapshot;
|
||||
dispose(): Promise<void>;
|
||||
};
|
||||
|
||||
export class ProjectReleaseBuildError extends Error {
|
||||
constructor(readonly code: 'LOCAL_BUILD_RUNTIME_UNAVAILABLE' | 'LOCAL_BUILD_FAILED' | 'LOCAL_BUILD_TIMEOUT' | 'LOCAL_BUILD_OUTPUT_MISSING') {
|
||||
super(code);
|
||||
this.name = 'ProjectReleaseBuildError';
|
||||
}
|
||||
}
|
||||
|
||||
const sha256 = (bytes: Buffer | string) => createHash('sha256').update(bytes).digest('hex');
|
||||
const archivePath = (value: string) => sep === '/' ? value : value.split(sep).join('/');
|
||||
const comparePaths = (left: string, right: string) => Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'));
|
||||
|
||||
function isSafeArchiveFile(entry: import('adm-zip').IZipEntry): boolean {
|
||||
const unixMode = (entry.attr >>> 16) & 0xffff;
|
||||
const unixType = unixMode & 0xf000;
|
||||
return unixType === 0 || unixType === 0x8000;
|
||||
}
|
||||
|
||||
function safeArchivePath(name: string): string[] | null {
|
||||
if (!name || name.includes('\\') || name.startsWith('/') || name.includes('\0')) return null;
|
||||
const parts = name.split('/');
|
||||
if (parts.some((part) => !part || part === '.' || part === '..' || part.includes(':') || /[. ]$/.test(part))) return null;
|
||||
if (parts.some((part) => /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) return null;
|
||||
return parts;
|
||||
}
|
||||
|
||||
async function extractSnapshot(archive: string, target: string): Promise<void> {
|
||||
const zip = new AdmZip(archive);
|
||||
const destinations = new Set<string>();
|
||||
for (const entry of zip.getEntries()) {
|
||||
const name = entry.entryName;
|
||||
const parts = safeArchivePath(name);
|
||||
if (entry.header.encripted || !parts || (!entry.isDirectory && !isSafeArchiveFile(entry))) {
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const unixType = ((entry.attr >>> 16) & 0xffff) & 0xf000;
|
||||
if (unixType !== 0 && unixType !== 0x4000) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
continue;
|
||||
}
|
||||
const destinationKey = name.toLowerCase();
|
||||
if (destinations.has(destinationKey)) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
destinations.add(destinationKey);
|
||||
if (entry.header.size > MAX_OUTPUT_BYTES) {
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
}
|
||||
const destination = join(target, ...parts);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
try {
|
||||
const data = entry.getData();
|
||||
await writeFile(destination, data, { flag: 'wx', mode: 0o600 });
|
||||
} catch {
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectOutput(root: string): Promise<Array<{ path: string; bytes: Buffer }>> {
|
||||
const files: Array<{ path: string; bytes: Buffer }> = [];
|
||||
let total = 0;
|
||||
async function walk(directory: string): Promise<void> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
entries.sort((a, b) => comparePaths(a.name, b.name));
|
||||
for (const entry of entries) {
|
||||
const absolute = join(directory, entry.name);
|
||||
const stats = await lstat(absolute);
|
||||
if (stats.isSymbolicLink() || (!stats.isFile() && !stats.isDirectory())) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
||||
if (stats.isDirectory()) await walk(absolute);
|
||||
else {
|
||||
total += stats.size;
|
||||
if (files.length + 1 > MAX_OUTPUT_FILES || total > MAX_OUTPUT_BYTES) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
||||
files.push({ path: archivePath(relative(root, absolute)), bytes: await readFile(absolute) });
|
||||
}
|
||||
}
|
||||
}
|
||||
try { await walk(root); } catch (error) {
|
||||
if (error instanceof ProjectReleaseBuildError) throw error;
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
||||
}
|
||||
files.sort((a, b) => comparePaths(a.path, b.path));
|
||||
if (files.some((file) => file.path.toLowerCase() === 'release.json')) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
||||
const index = files.find((file) => file.path === 'index.html');
|
||||
if (!index) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
||||
try {
|
||||
if (!new TextDecoder('utf-8', { fatal: true }).decode(index.bytes).trim()) throw new Error('empty');
|
||||
} catch { throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); }
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function prepareProjectRelease(input: { projectPath: string; clientVersion: string }): Promise<PreparedRelease> {
|
||||
const taskRoot = await mkdtemp(join(tmpdir(), 'makelore-release-'));
|
||||
try {
|
||||
const sourcePath = join(taskRoot, 'project.zip');
|
||||
const summary = await createStaticProjectPackage({ projectPath: input.projectPath, archivePath: sourcePath });
|
||||
const sourceBytes = await readFile(sourcePath);
|
||||
const snapshot = join(taskRoot, 'snapshot');
|
||||
const distRoot = join(taskRoot, 'owned-dist');
|
||||
const npmUserConfig = join(taskRoot, 'empty-npmrc');
|
||||
const npmCache = join(taskRoot, 'npm-cache');
|
||||
await mkdir(snapshot, { recursive: true });
|
||||
await writeFile(npmUserConfig, '', 'utf8');
|
||||
await extractSnapshot(sourcePath, snapshot);
|
||||
const runtime = await resolvePublishRuntime();
|
||||
await runElectronNode({
|
||||
args: [runtime.npmCli, 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--userconfig', npmUserConfig, '--cache', npmCache],
|
||||
cwd: snapshot,
|
||||
deadlineMs: 5 * 60_000,
|
||||
});
|
||||
let viteVersion = '';
|
||||
try {
|
||||
const vitePackage = JSON.parse(await readFile(join(snapshot, 'node_modules', 'vite', 'package.json'), 'utf8')) as { version?: unknown };
|
||||
if (typeof vitePackage.version !== 'string') throw new Error('missing vite version');
|
||||
viteVersion = vitePackage.version;
|
||||
} catch { throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); }
|
||||
// Vite config and plugins execute with desktop-user authority. This build is not a sandbox or trust proof.
|
||||
await runElectronNode({
|
||||
args: [join(snapshot, 'node_modules', 'vite', 'bin', 'vite.js'), 'build', '--base', './', '--outDir', distRoot, '--emptyOutDir'],
|
||||
cwd: snapshot,
|
||||
deadlineMs: 3 * 60_000,
|
||||
});
|
||||
const output = await collectOutput(distRoot);
|
||||
const zip = new AdmZip();
|
||||
const files = output.map(({ path, bytes }) => ({ path, size: bytes.length, sha256: sha256(bytes) }));
|
||||
for (const file of output) {
|
||||
zip.addFile(file.path, file.bytes, '', FILE_MODE);
|
||||
const entry = zip.getEntry(file.path);
|
||||
if (entry) entry.header.time = FIXED_ZIP_TIME;
|
||||
}
|
||||
const builtBytes = zip.toBuffer();
|
||||
const builtPath = join(taskRoot, 'built-project.zip');
|
||||
await writeFile(builtPath, builtBytes);
|
||||
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
||||
// Matches the server's canonical JSON: sorted files, sorted object keys, no whitespace.
|
||||
const artifactDigest = sha256(JSON.stringify(files.map((file) => ({
|
||||
path: file.path,
|
||||
sha256: file.sha256,
|
||||
size: file.size,
|
||||
}))));
|
||||
const staticArtifact = createStaticArtifactSnapshot(output);
|
||||
return {
|
||||
sourceArchive: { path: sourcePath, name: 'project.zip', bytes: sourceBytes, summary },
|
||||
builtArchive: { path: builtPath, name: 'built-project.zip', bytes: builtBytes },
|
||||
distRoot,
|
||||
staticArtifact,
|
||||
contract: {
|
||||
schema_version: 1,
|
||||
entry_path: 'index.html',
|
||||
source_digest: sha256(sourceBytes),
|
||||
built_archive_digest: sha256(builtBytes),
|
||||
artifact_digest: artifactDigest,
|
||||
file_count: files.length,
|
||||
total_bytes: totalBytes,
|
||||
files,
|
||||
security_profile: 'works-square-static-sandbox-v1',
|
||||
toolchain: { client: 'makelore', client_version: input.clientVersion, node: runtime.nodeVersion, npm: runtime.npmVersion, vite: viteVersion },
|
||||
},
|
||||
dispose: async () => { await rm(taskRoot, { recursive: true, force: true }); },
|
||||
};
|
||||
} catch (error) {
|
||||
await rm(taskRoot, { recursive: true, force: true }).catch(() => undefined);
|
||||
if (error instanceof ProjectReleaseBuildError) throw error;
|
||||
if (error instanceof PublishRuntimeError) throw new ProjectReleaseBuildError(error.code);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
108
electron/services/publish-runtime.ts
Normal file
108
electron/services/publish-runtime.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { app } from 'electron';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const OUTPUT_LIMIT = 64 * 1024;
|
||||
|
||||
export class PublishRuntimeError extends Error {
|
||||
constructor(readonly code: 'LOCAL_BUILD_RUNTIME_UNAVAILABLE' | 'LOCAL_BUILD_FAILED' | 'LOCAL_BUILD_TIMEOUT') {
|
||||
super(code);
|
||||
this.name = 'PublishRuntimeError';
|
||||
}
|
||||
}
|
||||
|
||||
export type PublishRuntime = {
|
||||
npmCli: string;
|
||||
nodeVersion: string;
|
||||
npmVersion: string;
|
||||
};
|
||||
|
||||
function childEnvironment(): NodeJS.ProcessEnv {
|
||||
const allowed = ['ALLUSERSPROFILE', 'APPDATA', 'COMMONPROGRAMFILES', 'COMMONPROGRAMFILES(X86)',
|
||||
'COMMONPROGRAMW6432', 'COMSPEC', 'HOME', 'HOMEDRIVE', 'HOMEPATH', 'LOCALAPPDATA',
|
||||
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'NODE_EXTRA_CA_CERTS',
|
||||
'http_proxy', 'https_proxy', 'no_proxy',
|
||||
'NUMBER_OF_PROCESSORS', 'OS', 'PATH', 'PATHEXT', 'PROGRAMDATA', 'PROGRAMFILES',
|
||||
'PROGRAMFILES(X86)', 'PROGRAMW6432', 'SYSTEMDRIVE', 'SYSTEMROOT', 'TEMP', 'TMP',
|
||||
'USERPROFILE', 'WINDIR', 'XDG_CACHE_HOME', 'XDG_CONFIG_HOME'];
|
||||
const env: NodeJS.ProcessEnv = { ELECTRON_RUN_AS_NODE: '1', CI: '1', npm_config_update_notifier: 'false' };
|
||||
for (const key of allowed) {
|
||||
const value = process.env[key];
|
||||
if (value !== undefined) env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
async function terminateTree(child: ChildProcess): Promise<void> {
|
||||
if (!child.pid || child.exitCode !== null) return;
|
||||
if (process.platform === 'win32') {
|
||||
const taskkill = join(process.env.SYSTEMROOT ?? 'C:\\Windows', 'System32', 'taskkill.exe');
|
||||
await new Promise<void>((resolve) => {
|
||||
const killer = spawn(taskkill, ['/pid', String(child.pid), '/t', '/f'], { shell: false, windowsHide: true });
|
||||
killer.once('close', () => resolve());
|
||||
killer.once('error', () => resolve());
|
||||
});
|
||||
} else {
|
||||
try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); }
|
||||
}
|
||||
}
|
||||
|
||||
export async function runElectronNode(input: {
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
deadlineMs: number;
|
||||
}): Promise<{ stdout: string; stderr: string }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, input.args, {
|
||||
cwd: input.cwd,
|
||||
env: childEnvironment(),
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const append = (current: string, chunk: Buffer) => (current + chunk.toString('utf8')).slice(-OUTPUT_LIMIT);
|
||||
child.stdout?.on('data', (chunk: Buffer) => { stdout = append(stdout, chunk); });
|
||||
child.stderr?.on('data', (chunk: Buffer) => { stderr = append(stderr, chunk); });
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
void terminateTree(child);
|
||||
}, input.deadlineMs);
|
||||
child.once('error', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new PublishRuntimeError('LOCAL_BUILD_RUNTIME_UNAVAILABLE'));
|
||||
});
|
||||
child.once('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (timedOut) reject(new PublishRuntimeError('LOCAL_BUILD_TIMEOUT'));
|
||||
else if (code !== 0) reject(new PublishRuntimeError('LOCAL_BUILD_FAILED'));
|
||||
else resolve({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolvePublishRuntime(): Promise<PublishRuntime> {
|
||||
try {
|
||||
const packagedNpmPackage = join(process.resourcesPath, 'publish-runtime', 'package.json');
|
||||
const npmPackage = app.isPackaged && existsSync(packagedNpmPackage)
|
||||
? packagedNpmPackage
|
||||
: require.resolve('npm/package.json');
|
||||
const npmCli = join(dirname(npmPackage), 'bin', 'npm-cli.js');
|
||||
const npmResult = await runElectronNode({ args: [npmCli, '--version'], deadlineMs: 10_000 });
|
||||
const nodeResult = await runElectronNode({ args: ['--version'], deadlineMs: 10_000 });
|
||||
return {
|
||||
npmCli,
|
||||
nodeVersion: nodeResult.stdout.trim().replace(/^v/, ''),
|
||||
npmVersion: npmResult.stdout.trim(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof PublishRuntimeError) throw error;
|
||||
throw new PublishRuntimeError('LOCAL_BUILD_RUNTIME_UNAVAILABLE');
|
||||
}
|
||||
}
|
||||
182
electron/services/static-release-server.ts
Normal file
182
electron/services/static-release-server.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { createServer, type ServerResponse } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import { extname } from 'node:path';
|
||||
|
||||
const MIME_TYPES: Readonly<Record<string, string>> = {
|
||||
'.avif': 'image/avif',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.otf': 'font/otf',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.ttf': 'font/ttf',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.wasm': 'application/wasm',
|
||||
'.webm': 'video/webm',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.wav': 'audio/wav',
|
||||
};
|
||||
const MAX_FILES = 2_000;
|
||||
const MAX_BYTES = 50 * 1024 * 1024;
|
||||
declare const staticArtifactSnapshotBrand: unique symbol;
|
||||
|
||||
export interface StaticArtifactSnapshot {
|
||||
readonly [staticArtifactSnapshotBrand]: true;
|
||||
}
|
||||
|
||||
export interface StaticArtifactFile {
|
||||
readonly path: string;
|
||||
readonly bytes: Buffer;
|
||||
}
|
||||
|
||||
export interface StaticReleaseServer {
|
||||
readonly entryUrl: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const snapshotFiles = new WeakMap<StaticArtifactSnapshot, ReadonlyMap<string, Buffer>>();
|
||||
|
||||
export function createStaticArtifactSnapshot(
|
||||
files: readonly StaticArtifactFile[],
|
||||
): StaticArtifactSnapshot {
|
||||
if (!Array.isArray(files) || files.length === 0 || files.length > MAX_FILES) {
|
||||
throw new Error('Static artifact file set is invalid.');
|
||||
}
|
||||
const owned = new Map<string, Buffer>();
|
||||
const destinationKeys = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
for (const file of files) {
|
||||
if (!file || !isSafeArtifactPath(file.path) || !Buffer.isBuffer(file.bytes)) {
|
||||
throw new Error('Static artifact file is invalid.');
|
||||
}
|
||||
const destinationKey = file.path.toLowerCase();
|
||||
if (destinationKeys.has(destinationKey)) {
|
||||
throw new Error('Static artifact paths must be unique.');
|
||||
}
|
||||
destinationKeys.add(destinationKey);
|
||||
const bytes = Buffer.from(file.bytes);
|
||||
totalBytes += bytes.length;
|
||||
if (totalBytes > MAX_BYTES) throw new Error('Static artifact is too large.');
|
||||
owned.set(file.path, bytes);
|
||||
}
|
||||
const entry = owned.get('index.html');
|
||||
if (!entry?.length) throw new Error('Static artifact entry is missing.');
|
||||
|
||||
const handle = Object.create(null) as StaticArtifactSnapshot;
|
||||
Object.defineProperty(handle, 'toJSON', {
|
||||
value: () => { throw new Error('Static artifact snapshots are Main-owned and cannot be serialized.'); },
|
||||
});
|
||||
Object.freeze(handle);
|
||||
snapshotFiles.set(handle, owned);
|
||||
return handle;
|
||||
}
|
||||
|
||||
export function staticArtifactSnapshotFiles(
|
||||
snapshot: StaticArtifactSnapshot,
|
||||
): readonly StaticArtifactFile[] {
|
||||
const files = snapshotFiles.get(snapshot);
|
||||
if (!files) throw new Error('Static artifact snapshot must be Main-owned.');
|
||||
return Array.from(files, ([path, bytes]) => ({ path, bytes: Buffer.from(bytes) }));
|
||||
}
|
||||
|
||||
export async function startStaticReleaseServer(
|
||||
snapshot: StaticArtifactSnapshot,
|
||||
): Promise<StaticReleaseServer> {
|
||||
const files = snapshotFiles.get(snapshot);
|
||||
if (!files) throw new Error('Static artifact snapshot must be Main-owned.');
|
||||
|
||||
const nonce = randomBytes(24).toString('hex');
|
||||
const sockets = new Set<Socket>();
|
||||
const server = createServer((request, response) => {
|
||||
response.setHeader('Cache-Control', 'no-store');
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
response.setHeader('Allow', 'GET, HEAD');
|
||||
sendEmpty(response, 405);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const rawUrl = request.url ?? '';
|
||||
if (rawUrl.includes('\0') || rawUrl.includes('\\')) throw new Error('unsafe');
|
||||
const url = new URL(rawUrl, 'http://127.0.0.1');
|
||||
const prefix = `/${nonce}/`;
|
||||
if (!url.pathname.startsWith(prefix)) throw new Error('outside');
|
||||
const rawPath = url.pathname.slice(prefix.length);
|
||||
if (!rawPath || rawPath.endsWith('/')) throw new Error('directory');
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(rawPath);
|
||||
} catch {
|
||||
throw new Error('encoding');
|
||||
}
|
||||
if (!isSafeArtifactPath(decoded)) throw new Error('unsafe');
|
||||
const bytes = files.get(decoded);
|
||||
if (!bytes) throw new Error('missing');
|
||||
response.statusCode = 200;
|
||||
response.setHeader('Content-Type', MIME_TYPES[extname(decoded).toLowerCase()] ?? 'application/octet-stream');
|
||||
response.setHeader('Content-Length', String(bytes.length));
|
||||
response.end(request.method === 'HEAD' ? undefined : bytes);
|
||||
} catch {
|
||||
sendEmpty(response, 404);
|
||||
}
|
||||
});
|
||||
server.on('connection', (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolveListen, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.removeListener('error', reject);
|
||||
resolveListen();
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
throw error;
|
||||
}
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Static artifact server did not bind TCP.');
|
||||
let closed: Promise<void> | null = null;
|
||||
return {
|
||||
entryUrl: `http://127.0.0.1:${address.port}/${nonce}/index.html`,
|
||||
close: () => {
|
||||
closed ??= new Promise<void>((resolveClose) => {
|
||||
server.close(() => resolveClose());
|
||||
server.closeAllConnections?.();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
setTimeout(resolveClose, 1_000).unref?.();
|
||||
});
|
||||
return closed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isSafeArtifactPath(path: string): boolean {
|
||||
if (typeof path !== 'string' || !path || path.startsWith('/') || path.includes('\\') || path.includes('\0')) return false;
|
||||
const parts = path.split('/');
|
||||
if (parts.some((part) => !part || part === '.' || part === '..' || part.includes(':') || /[. ]$/.test(part))) return false;
|
||||
return !parts.some((part) => /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part));
|
||||
}
|
||||
|
||||
function sendEmpty(response: ServerResponse, status: number): void {
|
||||
if (response.headersSent) return;
|
||||
response.statusCode = status;
|
||||
response.setHeader('Content-Length', '0');
|
||||
response.end();
|
||||
}
|
||||
Reference in New Issue
Block a user