Files
makelore/electron/services/learning-player-server.ts
brother7 f7171a471a
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
merge: integrate remote learning module safely
2026-08-17 01:05:49 +08:00

353 lines
14 KiB
TypeScript

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<string, string> = {
'.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<Record<string, string>> = 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<string, Map<string, string>>();
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<Buffer> {
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<string> {
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<string, unknown>;
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<void>;
}> {
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<void>((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<void> | null = null;
return {
url: `http://${expectedHost}${noncePrefix}/makelore-player?embedded=1`,
close: () => {
closed = true;
closePromise ??= server.listening
? new Promise<void>((resolveClose, reject) => {
server.close((error) => error ? reject(error) : resolveClose());
server.closeAllConnections();
})
: Promise.resolve();
return closePromise;
},
};
}
let sharedServer: Promise<Awaited<ReturnType<typeof createLearningPlayerServer>>> | null = null;
let sharedServerAccountKey: string | null = null;
let sharedServerTransition: Promise<void> = Promise.resolve();
export function getLearningPlayerServer(accountKey: string): Promise<Awaited<ReturnType<typeof createLearningPlayerServer>>> {
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<void> {
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;
}