341 lines
15 KiB
TypeScript
341 lines
15 KiB
TypeScript
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<string, unknown> {
|
||
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<unknown> {
|
||
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<void> {
|
||
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<string> {
|
||
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<string, Promise<InstalledLearningCourse>>();
|
||
|
||
async function authorizedFetch(path: string): Promise<Response> {
|
||
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<Response> {
|
||
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<InstalledLearningCourse> {
|
||
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<InstalledLearningCourse> {
|
||
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<InstalledLearningCourse[]> {
|
||
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<LearningClassroomPayload> {
|
||
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}` : ''}`,
|
||
);
|
||
}
|
||
},
|
||
};
|
||
}
|