526 lines
21 KiB
TypeScript
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;
|
|
});
|
|
},
|
|
};
|
|
}
|