353 lines
14 KiB
TypeScript
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;
|
|
}
|