import { createServer, type Server, type ServerResponse } from 'node:http'; import { createHash, randomBytes } 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', }; // Course archives are untrusted content. Keep this narrower than the verified // player artifact MIME table so a course asset can never become an executable // same-origin document, script, stylesheet, PDF, or SVG image. const COURSE_ASSET_MIME_TYPES: Readonly> = Object.freeze({ '.aac': 'audio/aac', '.gif': 'image/gif', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.m4a': 'audio/mp4', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.ogg': 'audio/ogg', '.otf': 'font/otf', '.png': 'image/png', '.ttf': 'font/ttf', '.wav': 'audio/wav', '.webm': 'video/webm', '.webp': 'image/webp', '.woff': 'font/woff', '.woff2': 'font/woff2', }); const COURSE_ASSET_CSP = "default-src 'none'; sandbox; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; function setCourseAssetSecurityHeaders(res: ServerResponse): void { res.setHeader('Cache-Control', 'private, no-store'); res.setHeader('Content-Security-Policy', COURSE_ASSET_CSP); res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); res.setHeader('X-Content-Type-Options', 'nosniff'); } const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; const SHA256_PATTERN = /^[0-9a-f]{64}$/; const registeredCoursePackages = new Map>(); const PLAYER_COOKIE_NAME = 'makelore_learning_player'; function coursePackageKey(courseId: string, contentHash: string): string { return `${courseId}:${contentHash}`; } function validateAccountKey(accountKey: string): void { if (!accountKey || accountKey.length > 256 || [...accountKey].some((character) => { const code = character.codePointAt(0) ?? 0; return code <= 31 || code === 127; })) throw new Error('Invalid learning account partition'); } /** Register only a verified installed archive; requests still receive an asset allowlist below. */ export function registerLearningCoursePackage( accountKey: string, courseId: string, contentHash: string, archivePath: string, ): void { validateAccountKey(accountKey); if (!COURSE_ID_PATTERN.test(courseId) || !SHA256_PATTERN.test(contentHash)) { throw new Error('Invalid learning course package identity'); } let accountPackages = registeredCoursePackages.get(accountKey); if (!accountPackages) { accountPackages = new Map(); registeredCoursePackages.set(accountKey, accountPackages); } accountPackages.set(coursePackageKey(courseId, contentHash), resolve(archivePath)); } export function unregisterLearningCoursePackage(accountKey: string, courseId: string, contentHash: string): boolean { validateAccountKey(accountKey); const accountPackages = registeredCoursePackages.get(accountKey); const deleted = accountPackages?.delete(coursePackageKey(courseId, contentHash)) ?? false; if (accountPackages?.size === 0) registeredCoursePackages.delete(accountKey); return deleted; } export function evictLearningCoursePackagesForAccount(accountKey: string): void { validateAccountKey(accountKey); registeredCoursePackages.delete(accountKey); } export function assertLearningCoursePackageRegistered( accountKey: string, courseId: string, contentHash: string, ): void { validateAccountKey(accountKey); if (!COURSE_ID_PATTERN.test(courseId) || !SHA256_PATTERN.test(contentHash)) { throw new Error('Invalid learning course package identity'); } if (sharedServerAccountKey !== accountKey || !registeredCoursePackages.get(accountKey)?.has(coursePackageKey(courseId, contentHash))) { throw new Error('Learning course package is not registered for the active account'); } } 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(accountKey: string, 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|fonts|media)\/.+/.test(entryName) || !COURSE_ASSET_MIME_TYPES[extname(entryName).toLowerCase()]) { throw new Error('Invalid course asset path'); } const archivePath = registeredCoursePackages.get(accountKey)?.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: { accountKey: string; artifactRoot?: string }): Promise<{ url: string; close: () => Promise; }> { validateAccountKey(options.accountKey); 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 nonce = randomBytes(32).toString('base64url'); const noncePrefix = `/${nonce}`; const expectedCookie = `${PLAYER_COOKIE_NAME}=${nonce}`; let expectedHost = ''; let closed = false; const server: Server = createServer(async (req, res) => { try { if (closed) { res.writeHead(404).end(); return; } if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(404).end(); return; } if (!expectedHost || req.headers.host !== expectedHost) { res.writeHead(404).end(); return; } const url = new URL(req.url || '/', 'http://127.0.0.1'); if (url.pathname.includes('/course-assets/')) setCourseAssetSecurityHeaders(res); const hasNoncePath = url.pathname === noncePrefix || url.pathname.startsWith(`${noncePrefix}/`); const hasNonceCookie = (req.headers.cookie || '').split(';').some((part) => part.trim() === expectedCookie); if (!hasNoncePath && !hasNonceCookie) { res.writeHead(404).end(); return; } const pathname = hasNoncePath ? url.pathname.slice(noncePrefix.length) || '/' : url.pathname; if (hasNoncePath) { res.setHeader('Set-Cookie', `${expectedCookie}; HttpOnly; SameSite=Strict; Path=/`); } if (pathname.startsWith('/course-assets/')) { const asset = await serveCourseAsset(options.accountKey, pathname); if (!asset) throw new Error('Course asset is unavailable'); const contentType = COURSE_ASSET_MIME_TYPES[extname(asset.entryName).toLowerCase()]; if (!contentType) throw new Error('Course asset type is unavailable'); res.statusCode = 200; res.setHeader('Content-Type', contentType); res.setHeader('Content-Length', String(asset.bytes.byteLength)); res.end(req.method === 'HEAD' ? undefined : asset.bytes); return; } let target: string; if (pathname === '/' || pathname === '/index.html' || 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 (pathname.startsWith('/_next/static/')) { const relativePath = decodeURIComponent(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 (pathname.startsWith('/avatars/')) { const relativePath = decodeURIComponent(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(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(req.method === 'HEAD' ? undefined : 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'); expectedHost = `127.0.0.1:${address.port}`; let closePromise: Promise | null = null; return { url: `http://${expectedHost}${noncePrefix}/makelore-player?embedded=1`, close: () => { closed = true; closePromise ??= server.listening ? new Promise((resolveClose, reject) => { server.close((error) => error ? reject(error) : resolveClose()); server.closeAllConnections(); }) : Promise.resolve(); return closePromise; }, }; } let sharedServer: Promise>> | null = null; let sharedServerAccountKey: string | null = null; let sharedServerTransition: Promise = Promise.resolve(); export function getLearningPlayerServer(accountKey: string): Promise>> { validateAccountKey(accountKey); const operation = sharedServerTransition.then(async () => { if (sharedServer && sharedServerAccountKey !== accountKey) { const previous = sharedServer; const previousAccountKey = sharedServerAccountKey; sharedServer = null; sharedServerAccountKey = null; if (previousAccountKey) registeredCoursePackages.delete(previousAccountKey); const previousServer = await previous.catch(() => null); if (previousServer) await previousServer.close(); } if (!sharedServer) { sharedServerAccountKey = accountKey; sharedServer = createLearningPlayerServer({ accountKey }); } const pending = sharedServer; try { return await pending; } catch (error) { if (sharedServer === pending) { sharedServer = null; sharedServerAccountKey = null; } throw error; } }); sharedServerTransition = operation.then(() => undefined, () => undefined); return operation; } export function closeLearningPlayerServer(): Promise { const operation = sharedServerTransition.then(async () => { const pending = sharedServer; const accountKey = sharedServerAccountKey; sharedServer = null; sharedServerAccountKey = null; if (accountKey) registeredCoursePackages.delete(accountKey); if (pending) await (await pending).close(); }); sharedServerTransition = operation.then(() => undefined, () => undefined); return operation; }