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 { LEARNING_ARCHIVE_MAX_BYTES, type InstalledLearningCourse, type LearningClassroomPayload, type LearningCourse, } from '../../shared/learning'; import { WORKS_SQUARE_CONFIG } from '../api/works-config'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getValidWorksSquareAccessToken, getWorksSquareAccountBinding, isCurrentWorksSquareAccountBinding, type WorksSquareAccountBinding, } 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}$/; const ACCOUNT_KEY_PATTERN = /^[0-9a-f]{64}$/; export const LEARNING_DOWNLOAD_MAX_REDIRECTS = 5; 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; getAccountBinding?: () => WorksSquareAccountBinding | null; isCurrentAccountBinding?: (binding: WorksSquareAccountBinding) => boolean; }; type InstalledLearningCourseDescriptor = InstalledLearningCourse & { archivePath: string; }; function accountCoursesDirectory(root: string, accountKey: string): string { return join(root, 'learning', 'accounts', accountKey, 'courses'); } function courseDirectory(root: string, accountKey: string, course: LearningCourse): string { return join(accountCoursesDirectory(root, accountKey), 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 { if (isRecord(payload) && typeof payload.archiveBytes === 'number' && Number.isSafeInteger(payload.archiveBytes) && payload.archiveBytes > LEARNING_ARCHIVE_MAX_BYTES) { throw new LearningCourseLibraryError('LEARNING_ARCHIVE_TOO_LARGE', '课程包超过本机允许的大小'); } 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): InstalledLearningCourseDescriptor | 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 InstalledLearningCourseDescriptor; } catch { return null; } } async function safeJson(response: Response): Promise { return response.json().catch(() => null); } function upstreamError(payload: unknown, status: number): LearningCourseLibraryError { void payload; void status; return new LearningCourseLibraryError( 'LEARNING_SERVICE_UNAVAILABLE', '学习服务暂时不可用', ); } async function streamVerifiedArchive( response: Response, temporaryPath: string, course: LearningCourse, assertCurrentAccount: () => void, ): 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; assertCurrentAccount(); 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 getAccountBinding = dependencies.getAccountBinding ?? getWorksSquareAccountBinding; const isCurrentAccountBinding = dependencies.isCurrentAccountBinding ?? isCurrentWorksSquareAccountBinding; const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); const apiOrigin = new URL(apiBaseUrl).origin; const now = dependencies.now ?? (() => new Date()); const activeDownloads = new Map>(); function requireAccountBinding(): WorksSquareAccountBinding { const binding = getAccountBinding(); if (!binding || !ACCOUNT_KEY_PATTERN.test(binding.accountKey)) { throw new LearningCourseLibraryError('LEARNING_AUTH_REQUIRED', '请先登录'); } return binding; } function assertCurrentAccount(binding: WorksSquareAccountBinding): void { if (!isCurrentAccountBinding(binding)) { throw new LearningCourseLibraryError('LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重试'); } } function toPublicRecord(record: InstalledLearningCourseDescriptor): InstalledLearningCourse { return { schemaVersion: record.schemaVersion, course: record.course, installedAt: record.installedAt, }; } async function withFixedErrors(operation: () => Promise): Promise { try { return await operation(); } catch (error) { if (error instanceof LearningCourseLibraryError) throw error; throw new LearningCourseLibraryError( 'LEARNING_LOCAL_LIBRARY_FAILED', '本地课程库操作失败,请重试', ); } } async function authorizedFetch( path: string, binding: WorksSquareAccountBinding, ): Promise { assertCurrentAccount(binding); const token = await getAccessToken({ fetchImpl }); assertCurrentAccount(binding); 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); assertCurrentAccount(binding); if (response.status === 401) { await response.body?.cancel().catch(() => undefined); assertCurrentAccount(binding); const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); assertCurrentAccount(binding); if (refreshed) { response = await request(refreshed); assertCurrentAccount(binding); } } return response; } async function followDownloadRedirect( initialResponse: Response, initialUrl: string, binding: WorksSquareAccountBinding, ): Promise { let response = initialResponse; let currentUrl = new URL(initialUrl).href; const visited = new Set([currentUrl]); for (let hop = 0; [301, 302, 303, 307, 308].includes(response.status); hop += 1) { if (hop >= LEARNING_DOWNLOAD_MAX_REDIRECTS) { await response.body?.cancel().catch(() => undefined); throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_LIMIT', '课程下载重定向次数过多'); } const location = response.headers.get('location'); await response.body?.cancel().catch(() => undefined); if (!location) { throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_INVALID', '课程下载地址无效'); } let target: URL; try { target = new URL(location, currentUrl); } catch { throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_INVALID', '课程下载地址无效'); } if ((target.protocol !== 'http:' && target.protocol !== 'https:') || target.origin !== apiOrigin || Boolean(target.username || target.password)) { throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_INVALID', '课程下载地址不安全'); } if (visited.has(target.href)) { throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_LOOP', '课程下载地址存在重定向循环'); } visited.add(target.href); assertCurrentAccount(binding); response = await fetchImpl(target, { headers: { Accept: 'application/zip' }, redirect: 'manual', }); assertCurrentAccount(binding); currentUrl = target.href; } return response; } async function install( courseId: string, binding: WorksSquareAccountBinding, ): Promise { assertCourseId(courseId); const metadataResponse = await authorizedFetch( `/api/learning/courses/${encodeURIComponent(courseId)}`, binding, ); const metadataPayload = await safeJson(metadataResponse); assertCurrentAccount(binding); 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, binding.accountKey, 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) { assertCurrentAccount(binding); return toPublicRecord(existing); } } catch { assertCurrentAccount(binding); // Missing or incomplete installs are replaced atomically below. } await mkdir(targetDirectory, { recursive: true }); assertCurrentAccount(binding); const temporaryArchive = join(targetDirectory, `.course-${randomUUID()}.partial`); const temporaryDescriptor = join(targetDirectory, `.installed-${randomUUID()}.partial`); try { const downloadPath = `/api/learning/courses/${encodeURIComponent(courseId)}/download`; let downloadResponse = await authorizedFetch(downloadPath, binding); downloadResponse = await followDownloadRedirect( downloadResponse, `${apiBaseUrl}${downloadPath}`, binding, ); if (!downloadResponse.ok) { const payload = await safeJson(downloadResponse); assertCurrentAccount(binding); throw upstreamError(payload, downloadResponse.status); } await streamVerifiedArchive( downloadResponse, temporaryArchive, course, () => assertCurrentAccount(binding), ); assertCurrentAccount(binding); const installed: InstalledLearningCourseDescriptor = { schemaVersion: 1, course, archivePath, installedAt: now().toISOString(), }; await writeFile(temporaryDescriptor, JSON.stringify(installed, null, 2), { encoding: 'utf8', flag: 'wx' }); assertCurrentAccount(binding); await rename(temporaryArchive, archivePath); await rename(temporaryDescriptor, descriptorPath); assertCurrentAccount(binding); return toPublicRecord(installed); } catch (error) { await Promise.all([ rm(temporaryArchive, { force: true }), rm(temporaryDescriptor, { force: true }), ]); throw error; } } async function listInstalledDescriptors( binding: WorksSquareAccountBinding, ): Promise { assertCurrentAccount(binding); const coursesRoot = accountCoursesDirectory(dependencies.rootDirectory, binding.accountKey); let courseDirectories; try { courseDirectories = await readdir(coursesRoot, { withFileTypes: true }); assertCurrentAccount(binding); } catch { assertCurrentAccount(binding); return []; } const records: InstalledLearningCourseDescriptor[] = []; 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(() => []); assertCurrentAccount(binding); 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; assertCurrentAccount(binding); records.push(record); } catch { assertCurrentAccount(binding); // Ignore incomplete or corrupted entries; they can be downloaded again. } } } assertCurrentAccount(binding); return records.sort((left, right) => right.installedAt.localeCompare(left.installedAt)); } async function resolveClassroomForBinding( courseId: string, moduleId: string | undefined, binding: WorksSquareAccountBinding, ): Promise<{ classroom: LearningClassroomPayload; installed: InstalledLearningCourseDescriptor }> { const installed = (await listInstalledDescriptors(binding)) .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', '本地课程包完整性校验失败,请重新下载'); } assertCurrentAccount(binding); 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', '课程包包含不安全路径'); } } try { const classroom = await readLearningPackageClassroom(zip, installed.course, moduleId); assertCurrentAccount(binding); return { classroom, installed }; } catch { assertCurrentAccount(binding); throw new LearningCourseLibraryError( 'LEARNING_CLASSROOM_INVALID', '课程播放数据无效', ); } } return { download(courseId: string): Promise { assertCourseId(courseId); const binding = requireAccountBinding(); const downloadKey = `${binding.accountKey}:${binding.epoch}:${courseId}`; const existing = activeDownloads.get(downloadKey); if (existing) return existing; const pending = withFixedErrors(() => install(courseId, binding)) .finally(() => activeDownloads.delete(downloadKey)); activeDownloads.set(downloadKey, pending); return pending; }, async listInstalled(): Promise { const binding = requireAccountBinding(); return withFixedErrors(async () => ( (await listInstalledDescriptors(binding)).map(toPublicRecord) )); }, async resolveClassroom(courseId: string, moduleId?: string): Promise { assertCourseId(courseId); const binding = requireAccountBinding(); return withFixedErrors(async () => { const { classroom } = await resolveClassroomForBinding(courseId, moduleId, binding); return classroom; }); }, async readClassroom(courseId: string, moduleId?: string): Promise { assertCourseId(courseId); const binding = requireAccountBinding(); return withFixedErrors(async () => { const { classroom, installed } = await resolveClassroomForBinding(courseId, moduleId, binding); assertCurrentAccount(binding); registerLearningCoursePackage( binding.accountKey, classroom.courseId, classroom.courseContentHash, installed.archivePath, ); assertCurrentAccount(binding); return classroom; }); }, }; }