374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { resolve } from 'node:path';
|
|
import JSZip from 'jszip';
|
|
import { extractKnowledgePack, extractQuizPack } from '@/lib/bundle/extract';
|
|
import type { FrozenBundleDocument, KnowledgePack, QuizPack } from '@/lib/bundle/types';
|
|
import type { ClassroomManifest } from '@/lib/export/classroom-zip-types';
|
|
import {
|
|
readFrozenLearningPackageRecord,
|
|
readUniqueMakeloreCoursePackage,
|
|
type FrozenLearningPackageRecord,
|
|
} from '@/lib/makelore-course/package';
|
|
import type { CoursewareKnowledge } from '@/lib/qa/knowledge';
|
|
import type { Scene, Stage } from '@/lib/types/stage';
|
|
|
|
const SAFE_ID = /^[A-Za-z0-9_-]{1,96}$/;
|
|
const SAFE_COURSE_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
const MAX_JSON_CHARS = 64 * 1024 * 1024;
|
|
|
|
export interface RuntimeCourseModuleDescriptor {
|
|
moduleId: string;
|
|
index: number;
|
|
title: string;
|
|
summary: string;
|
|
contentHash: string;
|
|
sceneCount: number;
|
|
root: string;
|
|
}
|
|
|
|
export interface MakeloreCourseRecord {
|
|
schemaVersion: 1 | 2;
|
|
courseId: string;
|
|
contentHash: string;
|
|
title: string;
|
|
language?: string;
|
|
mode: 'single' | 'large';
|
|
sceneCount: number;
|
|
moduleCount: number;
|
|
modules: RuntimeCourseModuleDescriptor[];
|
|
/** Only retained for pre-frozen compatibility records. */
|
|
classroom?: { stage: Stage; scenes: Scene[] };
|
|
frozenRecord?: FrozenLearningPackageRecord;
|
|
archive?: JSZip;
|
|
}
|
|
|
|
export interface RuntimeCourseModule {
|
|
course: MakeloreCourseRecord;
|
|
descriptor: RuntimeCourseModuleDescriptor;
|
|
manifest?: ClassroomManifest;
|
|
knowledge: KnowledgePack;
|
|
quiz: QuizPack;
|
|
bundle?: FrozenBundleDocument;
|
|
/** Compatibility path for legacy classroom.json packages. */
|
|
classroom?: { stage: Stage; scenes: Scene[] };
|
|
}
|
|
|
|
function courseStoreRoot(): string {
|
|
const dataRoot = process.env.LEARNING_DATA_DIR || resolve(process.cwd(), 'data');
|
|
return resolve(
|
|
process.env.LEARNING_COURSE_STORE_DIR ||
|
|
process.env.MAKELORE_COURSE_STORE_DIR ||
|
|
resolve(dataRoot, 'makelore-courses'),
|
|
);
|
|
}
|
|
|
|
function sha256(bytes: Uint8Array): string {
|
|
return createHash('sha256').update(bytes).digest('hex');
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
function positiveInteger(value: unknown): number | null {
|
|
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null;
|
|
}
|
|
|
|
function safeRoot(value: unknown, moduleId: string): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
const normalized = value.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
if (!normalized || normalized.split('/').some((part) => part === '.' || part === '..')) return null;
|
|
const expected = `modules/${moduleId}`;
|
|
return normalized === expected ? `${expected}/` : null;
|
|
}
|
|
|
|
async function zipJson<T>(zip: JSZip, path: string): Promise<T> {
|
|
const entry = zip.file(path);
|
|
if (!entry) throw new Error(`Frozen course is missing ${path}`);
|
|
const text = await entry.async('string');
|
|
if (text.length > MAX_JSON_CHARS) throw new Error(`Frozen course document is too large: ${path}`);
|
|
return JSON.parse(text) as T;
|
|
}
|
|
|
|
function singleDescriptor(
|
|
courseId: string,
|
|
contentHash: string,
|
|
title: string,
|
|
sceneCount: number,
|
|
moduleId = courseId,
|
|
): RuntimeCourseModuleDescriptor {
|
|
return { moduleId, index: 1, title, summary: '', contentHash, sceneCount, root: '' };
|
|
}
|
|
|
|
async function loadFrozenCourse(
|
|
courseId: string,
|
|
contentHash: string,
|
|
): Promise<MakeloreCourseRecord | null> {
|
|
const frozen = await readFrozenLearningPackageRecord(courseId);
|
|
if (!frozen || frozen.contentHash !== contentHash) return null;
|
|
const bytes = await readUniqueMakeloreCoursePackage(courseId);
|
|
if (
|
|
!bytes ||
|
|
bytes.byteLength !== frozen.archiveBytes ||
|
|
sha256(bytes) !== frozen.archiveSha256
|
|
) {
|
|
return null;
|
|
}
|
|
let archive: JSZip;
|
|
try {
|
|
archive = await JSZip.loadAsync(bytes, { checkCRC32: true });
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
if (frozen.mode === 'single') {
|
|
let bundle: FrozenBundleDocument;
|
|
try {
|
|
bundle = await zipJson<FrozenBundleDocument>(archive, 'bundle.json');
|
|
} catch {
|
|
return null;
|
|
}
|
|
const moduleId = bundle.meta?.coursewareId;
|
|
if (
|
|
typeof moduleId !== 'string' ||
|
|
!SAFE_ID.test(moduleId) ||
|
|
bundle.meta.contentHash !== contentHash ||
|
|
!archive.file('manifest.json') ||
|
|
!archive.file('knowledge/knowledge.json') ||
|
|
!archive.file('quiz/quiz.json')
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
schemaVersion: frozen.schemaVersion,
|
|
courseId,
|
|
contentHash,
|
|
title: frozen.title,
|
|
language: frozen.language,
|
|
mode: 'single',
|
|
sceneCount: frozen.sceneCount,
|
|
moduleCount: 1,
|
|
modules: [singleDescriptor(courseId, contentHash, frozen.title, frozen.sceneCount, moduleId)],
|
|
frozenRecord: frozen,
|
|
archive,
|
|
};
|
|
}
|
|
|
|
const structure = record(frozen.courseStructure);
|
|
const structureModules = Array.isArray(structure?.modules)
|
|
? structure.modules.map(record).filter((item): item is Record<string, unknown> => item !== null)
|
|
: [];
|
|
let courseDocument: Record<string, unknown>;
|
|
try {
|
|
courseDocument = record(await zipJson<unknown>(archive, 'course.json')) ?? {};
|
|
} catch {
|
|
return null;
|
|
}
|
|
const courseModules = Array.isArray(courseDocument.modules)
|
|
? courseDocument.modules.map(record).filter((item): item is Record<string, unknown> => item !== null)
|
|
: [];
|
|
if (
|
|
structure?.kind !== 'learning-course-aggregate' ||
|
|
structure.contentHash !== contentHash ||
|
|
courseDocument.kind !== 'learning-course-aggregate' ||
|
|
courseDocument.courseId !== courseId ||
|
|
courseDocument.contentHash !== contentHash ||
|
|
structureModules.length === 0 ||
|
|
courseModules.length !== structureModules.length
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const modules: RuntimeCourseModuleDescriptor[] = [];
|
|
for (const [position, source] of structureModules.entries()) {
|
|
const moduleId = source.coursewareId;
|
|
const moduleHash = source.contentHash;
|
|
const index = positiveInteger(source.index);
|
|
const declared = courseModules.find((candidate) => candidate.moduleId === moduleId);
|
|
const root = safeRoot(declared?.path, typeof moduleId === 'string' ? moduleId : '');
|
|
if (
|
|
typeof moduleId !== 'string' ||
|
|
!SAFE_ID.test(moduleId) ||
|
|
typeof moduleHash !== 'string' ||
|
|
!SHA256.test(moduleHash) ||
|
|
index === null ||
|
|
!declared ||
|
|
declared.contentHash !== moduleHash ||
|
|
declared.index !== index ||
|
|
!root ||
|
|
!archive.file(`${root}manifest.json`) ||
|
|
!archive.file(`${root}bundle.json`) ||
|
|
!archive.file(`${root}knowledge/knowledge.json`) ||
|
|
!archive.file(`${root}quiz/quiz.json`)
|
|
) {
|
|
return null;
|
|
}
|
|
const sceneCount = positiveInteger(source.sceneCount);
|
|
modules.push({
|
|
moduleId,
|
|
index,
|
|
title: typeof source.title === 'string' ? source.title : `Module ${position + 1}`,
|
|
summary: typeof source.description === 'string' ? source.description : '',
|
|
contentHash: moduleHash,
|
|
sceneCount: sceneCount ?? 0,
|
|
root,
|
|
});
|
|
}
|
|
if (
|
|
new Set(modules.map((module) => module.moduleId)).size !== modules.length ||
|
|
modules.some((module, index) => index > 0 && module.index <= modules[index - 1]!.index)
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
schemaVersion: frozen.schemaVersion,
|
|
courseId,
|
|
contentHash,
|
|
title: frozen.title,
|
|
language: frozen.language,
|
|
mode: 'large',
|
|
sceneCount: frozen.sceneCount,
|
|
moduleCount: modules.length,
|
|
modules,
|
|
frozenRecord: frozen,
|
|
archive,
|
|
};
|
|
}
|
|
|
|
async function loadLegacyCourse(
|
|
courseId: string,
|
|
contentHash: string,
|
|
): Promise<MakeloreCourseRecord | null> {
|
|
let value: unknown;
|
|
try {
|
|
value = JSON.parse(await readFile(resolve(courseStoreRoot(), `${courseId}.json`), 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
const legacy = record(value);
|
|
const classroom = record(legacy?.classroom);
|
|
if (
|
|
legacy?.schemaVersion !== 1 ||
|
|
legacy.courseId !== courseId ||
|
|
legacy.contentHash !== contentHash ||
|
|
typeof legacy.title !== 'string' ||
|
|
!classroom ||
|
|
!record(classroom.stage) ||
|
|
!Array.isArray(classroom.scenes)
|
|
) {
|
|
return null;
|
|
}
|
|
const typedClassroom = classroom as unknown as { stage: Stage; scenes: Scene[] };
|
|
return {
|
|
schemaVersion: 1,
|
|
courseId,
|
|
contentHash,
|
|
title: legacy.title,
|
|
...(typeof legacy.language === 'string' ? { language: legacy.language } : {}),
|
|
mode: 'single',
|
|
sceneCount: typedClassroom.scenes.length,
|
|
moduleCount: 1,
|
|
modules: [singleDescriptor(courseId, contentHash, legacy.title, typedClassroom.scenes.length)],
|
|
classroom: typedClassroom,
|
|
};
|
|
}
|
|
|
|
export function parseLearningBinding(key: string): { courseId: string; contentHash: string } | null {
|
|
const split = key.lastIndexOf(':');
|
|
if (split <= 0) return null;
|
|
const courseId = key.slice(0, split);
|
|
const contentHash = key.slice(split + 1);
|
|
return SAFE_COURSE_ID.test(courseId) && SHA256.test(contentHash)
|
|
? { courseId, contentHash }
|
|
: null;
|
|
}
|
|
|
|
/** Resolve only an exact aggregate course ID + content hash. */
|
|
export async function loadMakeloreCourse(
|
|
courseId: string,
|
|
contentHash: string,
|
|
): Promise<MakeloreCourseRecord | null> {
|
|
if (!SAFE_COURSE_ID.test(courseId) || !SHA256.test(contentHash)) return null;
|
|
return (await loadFrozenCourse(courseId, contentHash)) ?? loadLegacyCourse(courseId, contentHash);
|
|
}
|
|
|
|
export async function resolveMakeloreCourseModule(
|
|
course: MakeloreCourseRecord,
|
|
selection: { moduleId?: string | null; moduleContentHash?: string | null } = {},
|
|
): Promise<RuntimeCourseModule | null> {
|
|
let descriptor: RuntimeCourseModuleDescriptor | undefined;
|
|
if (course.mode === 'large') {
|
|
if (!selection.moduleId || !selection.moduleContentHash) return null;
|
|
descriptor = course.modules.find((module) => module.moduleId === selection.moduleId);
|
|
if (!descriptor || descriptor.contentHash !== selection.moduleContentHash) return null;
|
|
} else {
|
|
descriptor = course.modules[0];
|
|
if (!descriptor) return null;
|
|
if (
|
|
selection.moduleId &&
|
|
selection.moduleId !== descriptor.moduleId &&
|
|
selection.moduleId !== course.courseId
|
|
) {
|
|
return null;
|
|
}
|
|
if (selection.moduleContentHash && selection.moduleContentHash !== course.contentHash) return null;
|
|
}
|
|
|
|
if (!course.archive) {
|
|
const classroom = course.classroom;
|
|
if (!classroom) return null;
|
|
return {
|
|
course,
|
|
descriptor,
|
|
classroom,
|
|
knowledge: extractKnowledgePack(descriptor.moduleId, classroom.stage, classroom.scenes),
|
|
quiz: extractQuizPack(classroom.scenes),
|
|
};
|
|
}
|
|
|
|
try {
|
|
const [bundle, manifest, knowledge, quiz] = await Promise.all([
|
|
zipJson<FrozenBundleDocument>(course.archive, `${descriptor.root}bundle.json`),
|
|
zipJson<ClassroomManifest>(course.archive, `${descriptor.root}manifest.json`),
|
|
zipJson<KnowledgePack>(course.archive, `${descriptor.root}knowledge/knowledge.json`),
|
|
zipJson<QuizPack>(course.archive, `${descriptor.root}quiz/quiz.json`),
|
|
]);
|
|
if (
|
|
bundle.meta?.coursewareId !== descriptor.moduleId ||
|
|
bundle.meta.contentHash !== descriptor.contentHash ||
|
|
!Array.isArray(manifest.scenes) ||
|
|
!Array.isArray(knowledge.scenes) ||
|
|
!Array.isArray(quiz.scenes) ||
|
|
manifest.scenes.length !== descriptor.sceneCount
|
|
) {
|
|
return null;
|
|
}
|
|
return { course, descriptor, manifest, knowledge, quiz, bundle };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function courseKnowledge(module: RuntimeCourseModule): CoursewareKnowledge {
|
|
return {
|
|
coursewareId: module.descriptor.moduleId,
|
|
title: module.descriptor.title,
|
|
language: module.bundle?.meta.language ?? module.course.language,
|
|
knowledge: module.knowledge,
|
|
quiz: module.quiz,
|
|
};
|
|
}
|
|
|
|
/** Synthetic scene identity used by Makelore when materializing a frozen manifest. */
|
|
export function runtimeSceneId(
|
|
course: MakeloreCourseRecord,
|
|
module: RuntimeCourseModuleDescriptor,
|
|
sceneIndex: number,
|
|
): string {
|
|
return `learning_${course.courseId}_${module.moduleId}_s${sceneIndex}`;
|
|
}
|