import { createHash, randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import AdmZip from 'adm-zip'; import type { InstalledLearningCourse, LearningClassroomPayload, LearningCourse } from '../../shared/learning'; import { WORKS_SQUARE_CONFIG } from '../api/works-config'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getValidWorksSquareAccessToken } from './works-square-session'; import { readLearningPackageClassroom } from './learning-package-consumer'; import { registerLearningCoursePackage } from './learning-player-server'; const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/; const SHA256_PATTERN = /^[0-9a-f]{64}$/; export class LearningCourseLibraryError extends Error { constructor(readonly code: string, message: string) { super(message); this.name = 'LearningCourseLibraryError'; } } type Dependencies = { rootDirectory: string; fetchImpl?: typeof fetch; getAccessToken?: typeof getValidWorksSquareAccessToken; apiBaseUrl?: string; now?: () => Date; }; function courseDirectory(root: string, course: LearningCourse): string { return join(root, 'learning', 'courses', course.id, course.contentHash); } function assertCourseId(courseId: string): void { if (!COURSE_ID_PATTERN.test(courseId)) { throw new LearningCourseLibraryError('LEARNING_COURSE_ID_INVALID', '课程编号无效'); } } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function parseCourse(payload: unknown): LearningCourse { const capabilities = isRecord(payload) && isRecord(payload.capabilities) ? payload.capabilities : null; const hasSingleCapabilities = Boolean(capabilities && (capabilities.modular === undefined || capabilities.modular === false) && Array.isArray(capabilities.sceneKinds) && capabilities.sceneKinds.every((kind) => ['slide', 'interactive', 'quiz', 'pbl'].includes(String(kind))) && typeof capabilities.hasAudio === 'boolean' && typeof capabilities.hasWhiteboard === 'boolean' && typeof capabilities.hasAgent === 'boolean'); const hasLargeCapabilities = Boolean(capabilities && capabilities.modular === true && capabilities.orderedModules === true && capabilities.productionStagePerModule === true); const modules = isRecord(payload) && Array.isArray(payload.modules) ? payload.modules : null; const moduleIds = modules?.flatMap((module) => isRecord(module) && typeof module.moduleId === 'string' ? [module.moduleId] : []) ?? []; if (!isRecord(payload) || typeof payload.id !== 'string' || !COURSE_ID_PATTERN.test(payload.id) || !['user_single', 'ops_large'].includes(String(payload.origin)) || !['generating', 'ready', 'published', 'failed', 'archived'].includes(String(payload.status)) || typeof payload.title !== 'string' || !payload.title.trim() || (payload.summary !== null && typeof payload.summary !== 'string') || (payload.language !== null && typeof payload.language !== 'string') || typeof payload.contentHash !== 'string' || !SHA256_PATTERN.test(payload.contentHash) || typeof payload.archiveSha256 !== 'string' || !SHA256_PATTERN.test(payload.archiveSha256) || typeof payload.archiveBytes !== 'number' || !Number.isSafeInteger(payload.archiveBytes) || payload.archiveBytes <= 0 || !Number.isSafeInteger(payload.formatVersion) || Number(payload.formatVersion) < 1 || typeof payload.minPlayerVersion !== 'string' || !payload.minPlayerVersion.trim() || !Number.isSafeInteger(payload.sceneCount) || Number(payload.sceneCount) < 0 || (!hasSingleCapabilities && !hasLargeCapabilities) || typeof payload.createdAt !== 'string' || (payload.publishedAt !== null && typeof payload.publishedAt !== 'string') || (modules && new Set(moduleIds).size !== modules.length) || (payload.modules !== undefined && (!modules || modules.some((module) => ( !isRecord(module) || typeof module.moduleId !== 'string' || !MODULE_ID_PATTERN.test(module.moduleId) || typeof module.title !== 'string' || (module.summary !== null && typeof module.summary !== 'string') || typeof module.sceneCount !== 'number' || !Number.isSafeInteger(module.sceneCount) || module.sceneCount < 0 || typeof module.contentHash !== 'string' || !SHA256_PATTERN.test(module.contentHash) ))))) { throw new LearningCourseLibraryError('LEARNING_COURSE_INVALID', '课程信息不完整'); } return payload as LearningCourse; } function parseInstalled(payload: unknown): InstalledLearningCourse | null { if (!isRecord(payload) || payload.schemaVersion !== 1 || typeof payload.archivePath !== 'string' || typeof payload.installedAt !== 'string') return null; try { return { ...payload, course: parseCourse(payload.course) } as InstalledLearningCourse; } catch { return null; } } async function safeJson(response: Response): Promise { return response.json().catch(() => null); } function upstreamError(payload: unknown, status: number): LearningCourseLibraryError { const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null; return new LearningCourseLibraryError( detail && typeof detail.code === 'string' ? detail.code : `LEARNING_HTTP_${status}`, detail && typeof detail.message === 'string' ? detail.message : '学习服务暂时不可用', ); } async function streamVerifiedArchive( response: Response, temporaryPath: string, course: LearningCourse, ): Promise { if (!response.body) { throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_EMPTY', '课程包内容为空'); } const handle = await open(temporaryPath, 'wx'); const hash = createHash('sha256'); let bytes = 0; const signature: number[] = []; const reader = response.body.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) break; bytes += value.byteLength; if (bytes > course.archiveBytes) { throw new LearningCourseLibraryError('LEARNING_ARCHIVE_SIZE_MISMATCH', '课程包大小校验失败'); } for (const byte of value.subarray(0, Math.max(0, 4 - signature.length))) signature.push(byte); hash.update(value); await handle.write(value); } } finally { reader.releaseLock(); await handle.close(); } if (bytes !== course.archiveBytes) { throw new LearningCourseLibraryError('LEARNING_ARCHIVE_SIZE_MISMATCH', '课程包大小校验失败'); } if (signature.length < 4 || signature[0] !== 0x50 || signature[1] !== 0x4b || !((signature[2] === 0x03 && signature[3] === 0x04) || (signature[2] === 0x05 && signature[3] === 0x06) || (signature[2] === 0x07 && signature[3] === 0x08))) { throw new LearningCourseLibraryError('LEARNING_ARCHIVE_INVALID', '课程包不是有效的 ZIP 文件'); } if (hash.digest('hex') !== course.archiveSha256) { throw new LearningCourseLibraryError('LEARNING_ARCHIVE_HASH_MISMATCH', '课程包完整性校验失败'); } } async function fileSha256(path: string): Promise { const hash = createHash('sha256'); for await (const chunk of createReadStream(path)) hash.update(chunk); return hash.digest('hex'); } export function createLearningCourseLibrary(dependencies: Dependencies) { const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); const now = dependencies.now ?? (() => new Date()); const activeDownloads = new Map>(); async function authorizedFetch(path: string): Promise { const token = await getAccessToken({ fetchImpl }); if (!token) throw new LearningCourseLibraryError('LEARNING_AUTH_REQUIRED', '请先登录'); const request = (accessToken: string) => fetchImpl(`${apiBaseUrl}${path}`, { headers: { Accept: path.endsWith('/download') ? 'application/zip' : 'application/json', Authorization: `Bearer ${accessToken}` }, redirect: 'manual', }); let response = await request(token); if (response.status === 401) { await response.body?.cancel().catch(() => undefined); const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); if (refreshed) response = await request(refreshed); } return response; } async function followDownloadRedirect(response: Response): Promise { if (![301, 302, 303, 307, 308].includes(response.status)) return response; const location = response.headers.get('location'); await response.body?.cancel().catch(() => undefined); if (!location) throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_INVALID', '课程下载地址无效'); const target = new URL(location, apiBaseUrl); const base = new URL(apiBaseUrl); if (target.protocol !== 'https:' && !(target.protocol === 'http:' && target.origin === base.origin)) { throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_INVALID', '课程下载地址不安全'); } return fetchImpl(target, { headers: { Accept: 'application/zip' }, redirect: 'follow' }); } async function install(courseId: string): Promise { assertCourseId(courseId); const metadataResponse = await authorizedFetch(`/api/learning/courses/${encodeURIComponent(courseId)}`); const metadataPayload = await safeJson(metadataResponse); if (!metadataResponse.ok) throw upstreamError(metadataPayload, metadataResponse.status); const course = parseCourse(metadataPayload); if (course.id !== courseId) { throw new LearningCourseLibraryError('LEARNING_COURSE_ID_MISMATCH', '课程信息与请求不匹配'); } const targetDirectory = courseDirectory(dependencies.rootDirectory, course); const archivePath = join(targetDirectory, 'course.makelore-course.zip'); const descriptorPath = join(targetDirectory, 'installed.json'); try { const existing = parseInstalled(JSON.parse(await readFile(descriptorPath, 'utf8'))); if (existing && existing.course.archiveSha256 === course.archiveSha256 && (await stat(archivePath)).size === course.archiveBytes && await fileSha256(archivePath) === course.archiveSha256) return existing; } catch { // Missing or incomplete installs are replaced atomically below. } await mkdir(targetDirectory, { recursive: true }); const temporaryArchive = join(targetDirectory, `.course-${randomUUID()}.partial`); const temporaryDescriptor = join(targetDirectory, `.installed-${randomUUID()}.partial`); try { let downloadResponse = await authorizedFetch(`/api/learning/courses/${encodeURIComponent(courseId)}/download`); downloadResponse = await followDownloadRedirect(downloadResponse); if (!downloadResponse.ok) { const payload = await safeJson(downloadResponse); throw upstreamError(payload, downloadResponse.status); } await streamVerifiedArchive(downloadResponse, temporaryArchive, course); const installed: InstalledLearningCourse = { schemaVersion: 1, course, archivePath, installedAt: now().toISOString(), }; await writeFile(temporaryDescriptor, JSON.stringify(installed, null, 2), { encoding: 'utf8', flag: 'wx' }); await rename(temporaryArchive, archivePath); await rename(temporaryDescriptor, descriptorPath); return installed; } catch (error) { await Promise.all([ rm(temporaryArchive, { force: true }), rm(temporaryDescriptor, { force: true }), ]); throw error; } } return { download(courseId: string): Promise { assertCourseId(courseId); const existing = activeDownloads.get(courseId); if (existing) return existing; const pending = install(courseId).finally(() => activeDownloads.delete(courseId)); activeDownloads.set(courseId, pending); return pending; }, async listInstalled(): Promise { const coursesRoot = join(dependencies.rootDirectory, 'learning', 'courses'); let courseDirectories; try { courseDirectories = await readdir(coursesRoot, { withFileTypes: true }); } catch { return []; } const records: InstalledLearningCourse[] = []; for (const courseEntry of courseDirectories) { if (!courseEntry.isDirectory() || !COURSE_ID_PATTERN.test(courseEntry.name)) continue; const hashRoot = join(coursesRoot, courseEntry.name); const hashDirectories = await readdir(hashRoot, { withFileTypes: true }).catch(() => []); for (const hashEntry of hashDirectories) { if (!hashEntry.isDirectory() || !SHA256_PATTERN.test(hashEntry.name)) continue; const descriptorPath = join(hashRoot, hashEntry.name, 'installed.json'); try { const record = parseInstalled(JSON.parse(await readFile(descriptorPath, 'utf8'))); if (!record || record.course.id !== courseEntry.name || record.course.contentHash !== hashEntry.name || dirname(record.archivePath) !== join(hashRoot, hashEntry.name) || (await stat(record.archivePath)).size !== record.course.archiveBytes) continue; records.push(record); } catch { // Ignore incomplete or corrupted entries; they can be downloaded again. } } } return records.sort((left, right) => right.installedAt.localeCompare(left.installedAt)); }, async readClassroom(courseId: string, moduleId?: string): Promise { assertCourseId(courseId); const installed = (await this.listInstalled()).find((record) => record.course.id === courseId); if (!installed) { throw new LearningCourseLibraryError('LEARNING_COURSE_NOT_INSTALLED', '课程尚未下载到本机'); } if (await fileSha256(installed.archivePath) !== installed.course.archiveSha256) { throw new LearningCourseLibraryError('LEARNING_ARCHIVE_HASH_MISMATCH', '本地课程包完整性校验失败,请重新下载'); } const zip = new AdmZip(installed.archivePath); for (const entry of zip.getEntries()) { const parts = entry.entryName.replace(/\\/g, '/').split('/'); if (entry.entryName.startsWith('/') || parts.includes('..')) { throw new LearningCourseLibraryError('LEARNING_ARCHIVE_INVALID', '课程包包含不安全路径'); } } registerLearningCoursePackage( installed.course.id, installed.course.contentHash, installed.archivePath, ); try { return await readLearningPackageClassroom(zip, installed.course, moduleId); } catch (error) { throw new LearningCourseLibraryError( 'LEARNING_CLASSROOM_INVALID', `课程播放数据无效${error instanceof Error ? `:${error.message}` : ''}`, ); } }, }; }