feat: integrate learning module
This commit is contained in:
195
electron/services/learning-player-server.ts
Normal file
195
electron/services/learning-player-server.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user