import AdmZip from 'adm-zip'; import type { LearningClassroomPayload, LearningCourse, LearningCourseModule, } from '../../shared/learning'; const SHA256_PATTERN = /^[0-9a-f]{64}$/; const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/; const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024; const MAX_ARCHIVE_ENTRIES = 4_096; const MAX_ARCHIVE_UNCOMPRESSED_BYTES = 512 * 1024 * 1024; const MAX_ENTRY_NAME_BYTES = 1_024; const MAX_COMPRESSION_RATIO = 200; const MAX_MODULES = 128; const MAX_SCENES_PER_MODULE = 10_000; const MAX_JSON_DEPTH = 64; const MAX_JSON_NODES = 1_000_000; const UNSAFE_MEDIA_ERROR = '课程包包含不安全媒体资源'; /** * Keep this list exactly in sync with COURSE_ASSET_MIME_TYPES in * learning-player-server.ts. A media entry must be a passive browser resource * that the player can serve, never a document, script, stylesheet, or plugin * payload. The MIME value is part of the signed course manifest, so an * extension alone is not sufficient for admission. */ const COURSE_MEDIA_MIME_BY_EXTENSION: Readonly> = { '.gif': 'image/gif', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.aac': 'audio/aac', '.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_MEDIA_ROOTS = new Set(['audio', 'media', 'fonts']); const SAFE_MEDIA_SEGMENT_PATTERN = /^[A-Za-z0-9_-]+$/; type ModuleLocation = { module: LearningCourseModule; root: string; kind: 'legacy' | 'frozen'; }; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function validateArchiveMetadata(zip: AdmZip): void { const entries = zip.getEntries(); if (entries.length === 0 || entries.length > MAX_ARCHIVE_ENTRIES) { throw new Error('课程包结构过于复杂'); } let totalSize = 0; for (const entry of entries) { const size = entry.header.size; const compressedSize = entry.header.compressedSize; if (!Number.isSafeInteger(size) || size < 0 || !Number.isSafeInteger(compressedSize) || compressedSize < 0 || Buffer.byteLength(entry.entryName, 'utf8') > MAX_ENTRY_NAME_BYTES || (!entry.isDirectory && size > MAX_DOCUMENT_BYTES)) { throw new Error('课程包资源超出限制'); } totalSize += size; if (!Number.isSafeInteger(totalSize) || totalSize > MAX_ARCHIVE_UNCOMPRESSED_BYTES) { throw new Error('课程包资源超出限制'); } if (!entry.isDirectory && size >= 1024 * 1024 && (compressedSize === 0 || size / compressedSize > MAX_COMPRESSION_RATIO)) { throw new Error('课程包压缩数据异常'); } } } function validateJsonComplexity(value: unknown): void { const pending: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]; let nodes = 0; while (pending.length > 0) { const current = pending.pop()!; nodes += 1; if (current.depth > MAX_JSON_DEPTH || nodes > MAX_JSON_NODES) { throw new Error('课程包 JSON 结构过于复杂'); } if (Array.isArray(current.value)) { for (const child of current.value) pending.push({ value: child, depth: current.depth + 1 }); } else if (isRecord(current.value)) { for (const child of Object.values(current.value)) pending.push({ value: child, depth: current.depth + 1 }); } } } function readEntry(zip: AdmZip, name: string): Promise { const entry = zip.getEntry(name); if (!entry || entry.isDirectory || entry.header.size > MAX_DOCUMENT_BYTES) { return Promise.reject(new Error(`课程包缺少 ${name}`)); } return new Promise((resolveData, rejectData) => { entry.getDataAsync((data, error) => { if (error || data.byteLength !== entry.header.size || data.byteLength > MAX_DOCUMENT_BYTES) { rejectData(new Error('课程包资源读取失败')); } else { resolveData(data); } }); }); } async function readJson(zip: AdmZip, name: string): Promise { try { const value = JSON.parse((await readEntry(zip, name)).toString('utf8')) as unknown; validateJsonComplexity(value); return value; } catch { throw new Error(`${name} 无效`); } } function safeModuleId(value: unknown, fallback: string): string { return typeof value === 'string' && MODULE_ID_PATTERN.test(value) ? value : fallback; } function safeCount(value: unknown, fallback: number): number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : fallback; } function safeHash(value: unknown, fallback: string): string { return typeof value === 'string' && SHA256_PATTERN.test(value) ? value : fallback; } function normalizeRoot(value: unknown): string | null { if (typeof value !== 'string') return null; const root = value.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); if (!root || root.split('/').some((part) => part === '.' || part === '..')) return null; return `${root}/`; } function manifestRoots(zip: AdmZip): string[] { return zip.getEntries() .map((entry) => entry.entryName.replace(/\\/g, '/')) .flatMap((name) => { if (name === 'manifest.json') return ['']; const match = name.match(/^(modules\/[^/]+\/)manifest\.json$/); return match ? [match[1]] : []; }) .sort((left, right) => left.localeCompare(right, undefined, { numeric: true })); } function courseJsonModules(value: unknown): Array> { return isRecord(value) && Array.isArray(value.modules) ? value.modules.filter(isRecord) : []; } async function frozenModuleMetadata( zip: AdmZip, root: string, fallback: LearningCourseModule, ): Promise { const bundle = await readJson(zip, `${root}bundle.json`); const meta = isRecord(bundle) && isRecord(bundle.meta) ? bundle.meta : null; if (!meta || typeof meta.coursewareId !== 'string' || !MODULE_ID_PATTERN.test(meta.coursewareId) || !Number.isSafeInteger(meta.sceneCount) || Number(meta.sceneCount) < 0 || Number(meta.sceneCount) > MAX_SCENES_PER_MODULE || typeof meta.contentHash !== 'string' || !SHA256_PATTERN.test(meta.contentHash)) { throw new Error(`${root}bundle.json 模块身份无效`); } return { moduleId: meta.coursewareId, title: typeof meta.stageName === 'string' && meta.stageName.trim() ? meta.stageName : fallback.title, summary: fallback.summary, sceneCount: Number(meta.sceneCount), contentHash: meta.contentHash, }; } async function resolveModuleLocations(zip: AdmZip, course: LearningCourse): Promise { const roots = manifestRoots(zip); if (roots.length > MAX_MODULES || (course.modules?.length ?? 0) > MAX_MODULES) { throw new Error('课程包模块数量超出限制'); } const rawCourseJson = zip.getEntry('course.json') ? await readJson(zip, 'course.json') : null; const descriptors = courseJsonModules(rawCourseJson); const declared = course.modules?.length ? course.modules : descriptors.map((value, index) => ({ moduleId: safeModuleId(value.moduleId ?? value.id, `module-${index + 1}`), title: typeof value.title === 'string' && value.title.trim() ? value.title : `模块 ${index + 1}`, summary: typeof value.summary === 'string' ? value.summary : null, sceneCount: safeCount(value.sceneCount, 0), contentHash: safeHash(value.contentHash, course.contentHash), })); if (declared.length > 0) { if (declared.length > MAX_MODULES) throw new Error('课程包模块数量超出限制'); const locations: ModuleLocation[] = []; for (const [index, module] of declared.entries()) { const descriptor = descriptors.find((value) => value.moduleId === module.moduleId || value.id === module.moduleId) ?? descriptors[index]; const preferredRoot = normalizeRoot(descriptor?.path ?? descriptor?.directory); const candidates = [ preferredRoot, `modules/${module.moduleId}/`, `modules/${index}/`, `modules/${index + 1}/`, roots[index], ].filter((value): value is string => value !== null && value !== undefined); const root = candidates.find((candidate) => zip.getEntry(`${candidate}manifest.json`)); if (!root) throw new Error(`课程模块“${module.title}”缺少 frozen manifest`); const metadata = await frozenModuleMetadata(zip, root, module); if (metadata.moduleId !== module.moduleId || metadata.contentHash !== module.contentHash) { throw new Error(`课程模块“${module.title}”内容校验不一致`); } locations.push({ module: { ...module, ...metadata, moduleId: module.moduleId }, root, kind: 'frozen' }); } return locations; } if (roots.length > 0) { return Promise.all(roots.map(async (root, index) => { const fallback: LearningCourseModule = { moduleId: roots.length === 1 ? 'main' : `module-${index + 1}`, title: roots.length === 1 ? course.title : `模块 ${index + 1}`, summary: roots.length === 1 ? course.summary : null, sceneCount: roots.length === 1 ? course.sceneCount : 0, contentHash: course.contentHash, }; const module = await frozenModuleMetadata(zip, root, fallback); return { module, root, kind: 'frozen' as const }; })); } if (zip.getEntry('classroom.json')) { return [{ module: { moduleId: 'main', title: course.title, summary: course.summary, sceneCount: course.sceneCount, contentHash: course.contentHash, }, root: '', kind: 'legacy', }]; } throw new Error('课程包缺少可播放模块'); } function assetUrl(course: LearningCourse, entryName: string): string { const encodedPath = entryName.split('/').map(encodeURIComponent).join('/'); return `/course-assets/${encodeURIComponent(course.id)}/${course.contentHash}/${encodedPath}`; } function hasMediaControlCharacter(value: string): boolean { for (const character of value) { const code = character.charCodeAt(0); if ((code >= 0 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f) || code === 0x2028 || code === 0x2029) { return true; } } return false; } function validateCourseMediaEntry(relativePath: string, mimeType: unknown): void { if (typeof relativePath !== 'string' || !relativePath || relativePath.includes('\\') || relativePath.startsWith('/') || relativePath.endsWith('/') || relativePath.includes('//') || hasMediaControlCharacter(relativePath)) { throw new Error(UNSAFE_MEDIA_ERROR); } const segments = relativePath.split('/'); if (segments.some((segment) => !segment || segment === '.' || segment === '..')) { throw new Error(UNSAFE_MEDIA_ERROR); } // `location.root` is authoritative for the module and is prepended below. // Keeping the manifest key module-relative prevents a duplicate // `modules//` prefix in the immutable player URL. if (!COURSE_MEDIA_ROOTS.has(segments[0] ?? '') || segments.length <= 1) { throw new Error(UNSAFE_MEDIA_ERROR); } const filename = segments.at(-1)!; const directorySegments = segments.slice(1, -1); if (directorySegments.some((segment) => !SAFE_MEDIA_SEGMENT_PATTERN.test(segment))) { throw new Error(UNSAFE_MEDIA_ERROR); } const extensionStart = filename.lastIndexOf('.'); if (extensionStart <= 0 || extensionStart === filename.length - 1) { throw new Error(UNSAFE_MEDIA_ERROR); } const stem = filename.slice(0, extensionStart); const extension = filename.slice(extensionStart).toLowerCase(); // A second suffix is ambiguous (for example `lesson.html.png`). Reject it // even when the final extension is otherwise safe. if (!SAFE_MEDIA_SEGMENT_PATTERN.test(stem) || stem.includes('.')) { throw new Error(UNSAFE_MEDIA_ERROR); } const expectedMimeTypes = COURSE_MEDIA_MIME_BY_EXTENSION[extension]; const normalizedMimeType = typeof mimeType === 'string' ? mimeType.trim().toLowerCase() : ''; if (!expectedMimeTypes || hasMediaControlCharacter(normalizedMimeType) || expectedMimeTypes !== normalizedMimeType) { throw new Error(UNSAFE_MEDIA_ERROR); } } function mediaRefFromPath(path: string, mimeType?: unknown): string { const relative = path.startsWith('media/') ? path.slice('media/'.length) : path; const suffix = typeof mimeType === 'string' ? mimeType.split('/')[1] : ''; if (suffix && relative.toLowerCase().endsWith(`.${suffix.toLowerCase()}`)) { return relative.slice(0, -suffix.length - 1); } return relative.replace(/\.[^/.]+$/, ''); } function rewriteDeep(value: unknown, mediaUrls: Map): unknown { if (typeof value === 'string') return mediaUrls.get(value) ?? value; if (Array.isArray(value)) return value.map((item) => rewriteDeep(item, mediaUrls)); if (!isRecord(value)) return value; return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewriteDeep(item, mediaUrls)])); } function rewriteVideoManifest(value: unknown, mediaUrls: Map): unknown { if (!isRecord(value)) return value; return Object.fromEntries(Object.entries(value).map(([key, entry]) => [ mediaUrls.get(key) ?? key, rewriteDeep(entry, mediaUrls), ])); } async function buildFrozenClassroom( zip: AdmZip, course: LearningCourse, location: ModuleLocation, ): Promise<{ stage: Record; scenes: unknown[] }> { const [manifestValue, bundleValue] = await Promise.all([ readJson(zip, `${location.root}manifest.json`), readJson(zip, `${location.root}bundle.json`), readJson(zip, `${location.root}quiz/quiz.json`), readJson(zip, `${location.root}knowledge/knowledge.json`), ]); if (!isRecord(manifestValue) || !isRecord(manifestValue.stage) || !Array.isArray(manifestValue.scenes) || !Array.isArray(manifestValue.agents) || manifestValue.agents.some((agent) => !isRecord(agent)) || !isRecord(manifestValue.mediaIndex)) { throw new Error('Frozen manifest 结构无效'); } if (manifestValue.scenes.length > MAX_SCENES_PER_MODULE) { throw new Error('课程包场景数量超出限制'); } const bundle = isRecord(bundleValue) ? bundleValue : {}; const meta = isRecord(bundle.meta) ? bundle.meta : {}; const completeness = isRecord(bundle.completeness) ? bundle.completeness : {}; if (completeness.complete !== true) throw new Error('课程模块资源不完整,无法离线播放'); if (safeCount(meta.sceneCount, -1) !== manifestValue.scenes.length) { throw new Error('课程模块场景数量不一致'); } if (safeHash(meta.contentHash, '') !== location.module.contentHash) { throw new Error('课程模块内容哈希不一致'); } const mediaUrls = new Map(); for (const [relativePath, mediaValue] of Object.entries(manifestValue.mediaIndex)) { if (!isRecord(mediaValue)) throw new Error(UNSAFE_MEDIA_ERROR); validateCourseMediaEntry(relativePath, mediaValue.mimeType); if (mediaValue.missing === true) continue; const fullPath = `${location.root}${relativePath}`; if (!zip.getEntry(fullPath)) throw new Error(`课程模块缺少资源 ${relativePath}`); const url = assetUrl(course, fullPath); mediaUrls.set(relativePath, url); if (mediaValue.type === 'generated' || mediaValue.type === 'image') { mediaUrls.set(mediaRefFromPath(relativePath, mediaValue.mimeType), url); } } const stageId = `learning_${course.id}_${location.module.moduleId}`; const agentIds = manifestValue.agents.map((_agent, index) => `${stageId}_a${index}`); const agents = manifestValue.agents.filter(isRecord).map((agent, index) => ({ id: agentIds[index], name: typeof agent.name === 'string' ? agent.name : `Agent ${index + 1}`, role: typeof agent.role === 'string' ? agent.role : 'teacher', persona: typeof agent.persona === 'string' ? agent.persona : '', avatar: typeof agent.avatar === 'string' ? agent.avatar : '/avatars/teacher.png', color: typeof agent.color === 'string' ? agent.color : '#3b82f6', priority: typeof agent.priority === 'number' ? agent.priority : index, ...(isRecord(agent.voiceConfig) ? { voiceConfig: agent.voiceConfig } : {}), ...(isRecord(agent.voiceDesign) ? { voiceDesign: agent.voiceDesign } : {}), })); const fallbackAgentIndex = manifestValue.agents.findIndex((agent) => isRecord(agent) && agent.role !== 'teacher'); const stageSource = manifestValue.stage; const stage: Record = { id: stageId, name: typeof stageSource.name === 'string' ? stageSource.name : location.module.title, ...(typeof stageSource.description === 'string' ? { description: stageSource.description } : {}), ...(typeof stageSource.language === 'string' ? { languageDirective: stageSource.language } : {}), ...(typeof stageSource.style === 'string' ? { style: stageSource.style } : {}), createdAt: typeof stageSource.createdAt === 'number' ? stageSource.createdAt : Date.now(), updatedAt: typeof stageSource.updatedAt === 'number' ? stageSource.updatedAt : Date.now(), agentIds, generatedAgentConfigs: agents, ...(Array.isArray(stageSource.whiteboard) ? { whiteboard: rewriteDeep(stageSource.whiteboard, mediaUrls) } : {}), ...(stageSource.interactiveMode === true ? { interactiveMode: true } : {}), ...(stageSource.taskEngineMode === true ? { taskEngineMode: true } : {}), ...(stageSource.videoManifest ? { videoManifest: rewriteVideoManifest(stageSource.videoManifest, mediaUrls) } : {}), }; const scenes = manifestValue.scenes.map((sceneValue, index) => { if (!isRecord(sceneValue) || !isRecord(sceneValue.content)) { throw new Error(`课程模块第 ${index + 1} 个场景无效`); } const actions = Array.isArray(sceneValue.actions) ? sceneValue.actions.map((actionValue) => { if (!isRecord(actionValue)) return actionValue; if (actionValue.type === 'speech' && typeof actionValue.audioRef === 'string') { const { audioRef, ...rest } = actionValue; const audioUrl = mediaUrls.get(audioRef); if (!audioUrl) throw new Error(`课程旁白资源缺失:${audioRef}`); return { ...rest, audioUrl }; } if (actionValue.type === 'discussion') { const { agentIndex, agentId: legacyAgentId, ...rest } = actionValue; const indexValue = typeof agentIndex === 'number' ? agentIndex : fallbackAgentIndex; const agentId = agentIds[indexValue] || (typeof legacyAgentId === 'string' ? legacyAgentId : undefined); return { ...rest, ...(agentId ? { agentId } : {}) }; } return rewriteDeep(actionValue, mediaUrls); }) : undefined; const multiAgent = isRecord(sceneValue.multiAgent) && sceneValue.multiAgent.enabled === true ? { enabled: true, agentIds: Array.isArray(sceneValue.multiAgent.agentIndices) ? sceneValue.multiAgent.agentIndices .map((value) => typeof value === 'number' ? agentIds[value] : undefined) .filter(Boolean) : [], ...(typeof sceneValue.multiAgent.directorPrompt === 'string' ? { directorPrompt: sceneValue.multiAgent.directorPrompt } : {}), } : undefined; return { id: `${stageId}_s${index}`, stageId, title: typeof sceneValue.title === 'string' ? sceneValue.title : `场景 ${index + 1}`, order: safeCount(sceneValue.order, index), type: typeof sceneValue.type === 'string' ? sceneValue.type : sceneValue.content.type, content: rewriteDeep(sceneValue.content, mediaUrls), ...(actions ? { actions } : {}), ...(Array.isArray(sceneValue.whiteboards) ? { whiteboards: rewriteDeep(sceneValue.whiteboards, mediaUrls) } : {}), ...(multiAgent ? { multiAgent } : {}), createdAt: Date.now(), updatedAt: Date.now(), }; }); return { stage, scenes }; } export async function readLearningPackageClassroom( zip: AdmZip, course: LearningCourse, requestedModuleId?: string, ): Promise { validateArchiveMetadata(zip); const locations = await resolveModuleLocations(zip, course); const selected = requestedModuleId ? locations.find((location) => location.module.moduleId === requestedModuleId) : locations[0]; if (!selected) throw new Error('找不到指定课程模块'); const classroomValue = selected.kind === 'legacy' ? await readJson(zip, 'classroom.json') : await buildFrozenClassroom(zip, course, selected); if (isRecord(classroomValue) && Array.isArray(classroomValue.scenes) && classroomValue.scenes.length > MAX_SCENES_PER_MODULE) { throw new Error('课程包场景数量超出限制'); } if (!isRecord(classroomValue) || !isRecord(classroomValue.stage) || typeof classroomValue.stage.id !== 'string' || !Array.isArray(classroomValue.scenes) || classroomValue.scenes.length !== selected.module.sceneCount) { throw new Error('课程播放数据与模块信息不匹配'); } return { courseId: course.id, courseContentHash: course.contentHash, contentHash: course.contentHash, // Works treats modules as an aggregate-course concept. Keep the local // synthetic `main` descriptor for playback metadata, but omit it from // cloud progress/runtime context for a single-course package. moduleId: course.capabilities.modular === true ? selected.module.moduleId : null, moduleContentHash: selected.module.contentHash, modules: locations.map((location) => location.module), classroom: classroomValue as { stage: Record; scenes: unknown[] }, }; }