Files
makelore/electron/services/learning-course-library.ts
brother7 f7171a471a
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
merge: integrate remote learning module safely
2026-08-17 01:05:49 +08:00

526 lines
21 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 {
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<string, unknown> {
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<unknown> {
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<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;
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<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 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<string, Promise<InstalledLearningCourse>>();
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<T>(operation: () => Promise<T>): Promise<T> {
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<Response> {
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<Response> {
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<InstalledLearningCourse> {
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<InstalledLearningCourseDescriptor[]> {
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<InstalledLearningCourse> {
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<InstalledLearningCourse[]> {
const binding = requireAccountBinding();
return withFixedErrors(async () => (
(await listInstalledDescriptors(binding)).map(toPublicRecord)
));
},
async resolveClassroom(courseId: string, moduleId?: string): Promise<LearningClassroomPayload> {
assertCourseId(courseId);
const binding = requireAccountBinding();
return withFixedErrors(async () => {
const { classroom } = await resolveClassroomForBinding(courseId, moduleId, binding);
return classroom;
});
},
async readClassroom(courseId: string, moduleId?: string): Promise<LearningClassroomPayload> {
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;
});
},
};
}