feat: integrate learning module
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

This commit is contained in:
inman
2026-08-16 20:52:16 +08:00
parent 26b52d76e3
commit 01bee3188b
107 changed files with 6318 additions and 12063 deletions

View File

@@ -0,0 +1,195 @@
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<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',
};
const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
const registeredCoursePackages = new Map<string, string>();
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<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(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<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: { artifactRoot?: string } = {}): Promise<{
url: string;
close: () => Promise<void>;
}> {
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<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');
return {
url: `http://127.0.0.1:${address.port}/makelore-player?embedded=1`,
close: () => new Promise<void>((resolveClose, reject) => server.close((error) => error ? reject(error) : resolveClose())),
};
}
let sharedServer: Promise<Awaited<ReturnType<typeof createLearningPlayerServer>>> | null = null;
export function getLearningPlayerServer(): Promise<Awaited<ReturnType<typeof createLearningPlayerServer>>> {
sharedServer ??= createLearningPlayerServer();
return sharedServer;
}