import { createServer, type Server } from 'node:http'; import { createHash } from 'node:crypto'; import { readFile, stat } from 'node:fs/promises'; import { extname, resolve, sep } from 'node:path'; import AdmZip from 'adm-zip'; const MIME_TYPES: Record = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.aac': 'audio/aac', '.gif': 'image/gif', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.m4a': 'audio/mp4', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.ogg': 'audio/ogg', '.png': 'image/png', '.svg': 'image/svg+xml', '.wav': 'audio/wav', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.otf': 'font/otf', '.wasm': 'application/wasm', '.webp': 'image/webp', '.webm': 'video/webm', }; const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; const SHA256_PATTERN = /^[0-9a-f]{64}$/; const registeredCoursePackages = new Map(); function coursePackageKey(courseId: string, contentHash: string): string { return `${courseId}:${contentHash}`; } /** Register only a verified installed archive; requests still receive an asset allowlist below. */ export function registerLearningCoursePackage( courseId: string, contentHash: string, archivePath: string, ): void { if (!COURSE_ID_PATTERN.test(courseId) || !SHA256_PATTERN.test(contentHash)) { throw new Error('Invalid learning course package identity'); } registeredCoursePackages.set(coursePackageKey(courseId, contentHash), resolve(archivePath)); } function readZipEntry(zip: AdmZip, entryName: string): Promise { const entry = zip.getEntry(entryName); if (!entry || entry.isDirectory || entry.header.size > 256 * 1024 * 1024) { return Promise.reject(new Error('Course asset is unavailable')); } return new Promise((resolveData, rejectData) => { entry.getDataAsync((data, error) => error ? rejectData(error) : resolveData(data)); }); } async function serveCourseAsset(pathname: string): Promise<{ bytes: Buffer; entryName: string } | null> { const prefix = '/course-assets/'; if (!pathname.startsWith(prefix)) return null; const segments = pathname.slice(prefix.length).split('/').map((part) => decodeURIComponent(part)); const [courseId, contentHash, ...entryParts] = segments; const entryName = entryParts.join('/'); if (!COURSE_ID_PATTERN.test(courseId || '') || !SHA256_PATTERN.test(contentHash || '') || entryParts.some((part) => !part || part === '.' || part === '..') || !/^(?:(?:modules\/[^/]+)\/)?(?:audio|media)\/.+/.test(entryName)) { throw new Error('Invalid course asset path'); } const archivePath = registeredCoursePackages.get(coursePackageKey(courseId, contentHash)); if (!archivePath) throw new Error('Course package is not registered'); return { bytes: await readZipEntry(new AdmZip(archivePath), entryName), entryName }; } function isInside(root: string, target: string): boolean { return target === root || target.startsWith(`${root}${sep}`); } export async function resolveLearningPlayerArtifactRoot(explicitRoot?: string): Promise { const candidates = [ explicitRoot, process.env.MAKELORE_LEARNING_PLAYER_ROOT, process.resourcesPath ? resolve(process.resourcesPath, 'resources', 'learning-player') : undefined, resolve(process.cwd(), 'build', 'learning-player'), ].filter((value): value is string => Boolean(value)); for (const candidate of candidates) { const root = resolve(candidate); try { const artifact = JSON.parse(await readFile(resolve(root, 'artifact.json'), 'utf8')) as Record; const indexPath = resolve(root, 'index.html'); const html = await readFile(indexPath); if (artifact.schemaVersion !== 1 || artifact.entrypoint !== 'index.html' || typeof artifact.htmlSha256 !== 'string' || createHash('sha256').update(html).digest('hex') !== artifact.htmlSha256 || !await stat(resolve(root, '_next', 'static')).then((entry) => entry.isDirectory()).catch(() => false) || !await stat(resolve(root, 'avatars')).then((entry) => entry.isDirectory()).catch(() => false) || !await stat(resolve(root, 'public')).then((entry) => entry.isDirectory()).catch(() => false)) continue; return root; } catch { // Try the next verified artifact location. } } throw new Error('学习播放器资源未随安装包提供,请重新安装 Makelore'); } export async function createLearningPlayerServer(options: { artifactRoot?: string } = {}): Promise<{ url: string; close: () => Promise; }> { const root = await resolveLearningPlayerArtifactRoot(options.artifactRoot); const indexPath = resolve(root, 'index.html'); const staticRoot = resolve(root, '_next', 'static'); const avatarRoot = resolve(root, 'avatars'); const publicRoot = resolve(root, 'public'); const server: Server = createServer(async (req, res) => { try { const url = new URL(req.url || '/', 'http://127.0.0.1'); if (url.pathname.startsWith('/course-assets/')) { const asset = await serveCourseAsset(url.pathname); if (!asset) throw new Error('Course asset is unavailable'); res.statusCode = 200; res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); res.setHeader('Content-Type', MIME_TYPES[extname(asset.entryName).toLowerCase()] || 'application/octet-stream'); res.setHeader('Content-Length', String(asset.bytes.byteLength)); res.end(asset.bytes); return; } let target: string; if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname === '/makelore-player') { target = indexPath; res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; frame-src 'self' data: blob:; object-src 'none'; base-uri 'none'; form-action 'none'"); res.setHeader('Cache-Control', 'no-store'); } else if (url.pathname.startsWith('/_next/static/')) { const relativePath = decodeURIComponent(url.pathname.slice('/_next/static/'.length)); target = resolve(staticRoot, relativePath); if (!isInside(staticRoot, target)) { res.writeHead(404).end(); return; } res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } else if (url.pathname.startsWith('/avatars/')) { const relativePath = decodeURIComponent(url.pathname.slice('/avatars/'.length)); target = resolve(avatarRoot, relativePath); if (!isInside(avatarRoot, target)) { res.writeHead(404).end(); return; } res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } else { // OpenMAIC's production Stage references a small public asset tree // (for example PBL marks and vendor/fonts) with root-relative URLs. // Resolve those URLs only inside the verified artifact's public root. const relativePath = decodeURIComponent(url.pathname.slice(1)); target = resolve(publicRoot, relativePath); if (!relativePath || !isInside(publicRoot, target)) { res.writeHead(404).end(); return; } res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } const bytes = await readFile(target); res.statusCode = 200; res.setHeader('Content-Type', MIME_TYPES[extname(target)] || 'application/octet-stream'); res.setHeader('Content-Length', String(bytes.byteLength)); res.end(bytes); } catch { res.writeHead(404).end(); } }); await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => resolveListen()); }); server.unref(); const address = server.address(); if (!address || typeof address === 'string') throw new Error('Learning player failed to bind'); return { url: `http://127.0.0.1:${address.port}/makelore-player?embedded=1`, close: () => new Promise((resolveClose, reject) => server.close((error) => error ? reject(error) : resolveClose())), }; } let sharedServer: Promise>> | null = null; export function getLearningPlayerServer(): Promise>> { sharedServer ??= createLearningPlayerServer(); return sharedServer; }