import { createHash } from 'crypto'; import { promises as fs } from 'fs'; import path from 'path'; import type { Slide } from '@openmaic/dsl'; import { packageCourseware, type PackageResult } from '@/lib/bundle/packager'; import { publishBuiltCourseware, type PublishCoursewareOptions, type PublishCoursewareResult, } from '@/lib/courseware-repo'; import { getDefaultGeneratedAgentConfigs } from '@/lib/orchestration/registry/store'; import { slideMediaReferenceSlots } from '@/lib/media/slide-media-slots'; import { isGeneratedMediaPlaceholder } from '@/lib/media/media-ref'; import { proxyFetch } from '@/lib/server/proxy-fetch'; import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; import { CLASSROOMS_DIR, type PersistedClassroomData } from '@/lib/server/classroom-storage'; import type { GeneratedAgentConfig, Scene, Stage } from '@/lib/types/stage'; import type { SpeechAction } from '@/lib/types/action'; import type { MediaFileRecord } from '@/lib/utils/database'; import { resolvePBLContent } from '@/lib/pbl/legacy/read'; import { stripToDesignTemplate } from '@/lib/pbl/v2/runtime/learner-state'; const REMOTE_ASSET_LIMIT = 25 * 1024 * 1024; const REMOTE_ASSET_TIMEOUT_MS = 60_000; const MAX_REDIRECTS = 5; const MIME_BY_EXTENSION: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.aac': 'audio/aac', }; export interface PackagePersistedClassroomOptions { coursewareId?: string; version: number; baseUrl: string; classroomsDir?: string; /** Test seam. Production uses the SSRF-guarded server fetch below. */ fetchImpl?: typeof fetch; publishedAt?: string; /** Require interactive HTML only when the product request asked for it. */ requireInteractiveHtml?: boolean; /** Strict when generation requested TTS; false allows intentional silent narration. */ requireNarrationAudio?: boolean; } export interface PublishPersistedClassroomOptions extends Omit< PackagePersistedClassroomOptions, 'version' > { /** Private large-course ownership used by learner visibility checks. */ courseId?: string; courseModuleIndex?: number; repos?: PublishCoursewareOptions['repos']; /** Stage large-course modules as unpublished until the course commit point. */ status?: PublishCoursewareOptions['status']; } export interface PublishedPersistedClassroom extends PublishCoursewareResult { package: PackageResult; } function mimeForPath(filePath: string, fallback: string): string { return MIME_BY_EXTENSION[path.extname(filePath).toLowerCase()] ?? fallback; } function stableMediaRef(source: string): string { return `frozen_${createHash('sha256').update(source).digest('hex').slice(0, 24)}`; } function inputUrl(input: RequestInfo | URL): string { if (typeof input === 'string') return input; if (input instanceof URL) return input.href; return input.url; } function localClassroomAsset( value: string, classroomId: string, baseUrl: string, ): { kind: 'media' | 'audio'; filename: string } | null { let parsed: URL; try { parsed = new URL(value, baseUrl); } catch { return null; } const segments = parsed.pathname .split('/') .filter(Boolean) .map((segment) => decodeURIComponent(segment)); if ( segments.length !== 5 || segments[0] !== 'api' || segments[1] !== 'classroom-media' || segments[2] !== classroomId || (segments[3] !== 'media' && segments[3] !== 'audio') ) { return null; } const filename = segments[4]; if (!filename || path.basename(filename) !== filename || filename.includes('\0')) return null; return { kind: segments[3], filename }; } async function readLocalAsset( root: string, classroomId: string, asset: { kind: 'media' | 'audio'; filename: string }, ): Promise { const filePath = path.join(root, classroomId, asset.kind, asset.filename); const bytes = await fs.readFile(filePath); if (bytes.byteLength === 0) throw new Error(`Classroom asset is empty: ${asset.filename}`); return new Response(bytes, { headers: { 'Content-Type': mimeForPath(filePath, 'application/octet-stream'), 'Content-Length': String(bytes.byteLength), }, }); } async function guardedRemoteFetch(url: string): Promise { let current = url; for (let hop = 0; ; hop += 1) { const unsafe = await validateUrlForSSRF(current); if (unsafe) throw new Error(`Unsafe frozen asset URL: ${unsafe}`); const response = await proxyFetch(current, { redirect: 'manual', signal: AbortSignal.timeout(REMOTE_ASSET_TIMEOUT_MS), }); if (response.status < 300 || response.status >= 400) { if (!response.ok) throw new Error(`Frozen asset download failed: HTTP ${response.status}`); const contentLength = Number(response.headers.get('content-length') ?? ''); if (Number.isFinite(contentLength) && contentLength > REMOTE_ASSET_LIMIT) { throw new Error(`Frozen asset exceeds ${REMOTE_ASSET_LIMIT} bytes`); } const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.byteLength > REMOTE_ASSET_LIMIT) { throw new Error(`Frozen asset exceeds ${REMOTE_ASSET_LIMIT} bytes`); } return new Response(bytes, { headers: response.headers }); } if (hop >= MAX_REDIRECTS) throw new Error('Frozen asset has too many redirects'); const location = response.headers.get('location'); if (!location) throw new Error('Frozen asset redirect has no Location header'); current = new URL(location, current).href; } } function createFrozenFetch(options: { classroomId: string; baseUrl: string; classroomsDir: string; fetchImpl?: typeof fetch; }): typeof fetch { return (async (input: RequestInfo | URL) => { const rawUrl = inputUrl(input); const local = localClassroomAsset(rawUrl, options.classroomId, options.baseUrl); if (local) return readLocalAsset(options.classroomsDir, options.classroomId, local); if (options.fetchImpl) return options.fetchImpl(input); const absolute = new URL(rawUrl, options.baseUrl).href; return guardedRemoteFetch(absolute); }) as typeof fetch; } function stripLearnerPBLRuntime(scenes: readonly Scene[]): Scene[] { return scenes.map((scene) => { if (scene.content.type !== 'pbl') return scene; const resolved = resolvePBLContent(scene.content); if (resolved.kind !== 'v2') return scene; return { ...scene, content: { ...scene.content, projectV2: stripToDesignTemplate(resolved.projectV2), }, } as Scene; }); } function portableRoster(stage: Stage): GeneratedAgentConfig[] { if (stage.generatedAgentConfigs?.length) return stage.generatedAgentConfigs; const requestedIds = stage.agentIds ?? []; const defaults = getDefaultGeneratedAgentConfigs(requestedIds.length ? requestedIds : undefined); if (requestedIds.length > 0 && defaults.length !== requestedIds.length) { const resolved = new Set(defaults.map((agent) => agent.id)); const missing = requestedIds.filter((id) => !resolved.has(id)); throw new Error(`Agent roster cannot be frozen; missing configs: ${missing.join(', ')}`); } if (!defaults.some((agent) => agent.role === 'teacher')) { throw new Error('Agent roster cannot be frozen without a teacher'); } return defaults; } function classroomSlides( stage: Stage, scenes: readonly Scene[], ): Array> { const slides: Array> = [...(stage.whiteboard ?? [])]; for (const scene of scenes) { if (scene.content.type === 'slide') slides.push(scene.content.canvas); slides.push(...(scene.whiteboards ?? [])); } return slides; } async function prepareMedia(options: { classroomId: string; stage: Stage; scenes: Scene[]; baseUrl: string; classroomsDir: string; fetchImpl: typeof fetch; }): Promise<{ mediaRecords: MediaFileRecord[]; resolveMediaBytes: (record: MediaFileRecord) => Promise; }> { const sourceToStable = new Map(); const stableRefs = new Set(); const records = new Map(); const blobs = new Map(); const freezeReference = async ( source: string, expectedType: 'image' | 'video', ): Promise => { if (source.startsWith('data:')) return source; if (source.startsWith('blob:')) { throw new Error(`Browser-only media URL cannot be frozen: ${source.slice(0, 80)}`); } const known = sourceToStable.get(source); if (known) return known; if (stableRefs.has(source)) return source; if (isGeneratedMediaPlaceholder(source)) { throw new Error(`Generated media is still unresolved: ${source}`); } let absolute: string; try { absolute = new URL(source, options.baseUrl).href; } catch { throw new Error(`Opaque media ref has no server bytes: ${source}`); } const response = await options.fetchImpl(absolute); if (!response.ok) throw new Error(`Media could not be frozen: ${source}`); const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.byteLength === 0) throw new Error(`Media is empty: ${source}`); const responseType = response.headers.get('content-type')?.split(';')[0]?.trim(); const mimeType = responseType && responseType !== 'application/octet-stream' ? responseType : mimeForPath( new URL(absolute).pathname, expectedType === 'video' ? 'video/mp4' : 'image/png', ); const stable = stableMediaRef(absolute); const blob = new Blob([bytes as unknown as BlobPart], { type: mimeType }); const record: MediaFileRecord = { id: `${options.classroomId}:${stable}`, stageId: options.classroomId, type: expectedType, blob, mimeType, size: bytes.byteLength, prompt: '', params: '', createdAt: options.stage.createdAt, }; sourceToStable.set(source, stable); sourceToStable.set(absolute, stable); stableRefs.add(stable); records.set(record.id, record); blobs.set(record.id, blob); return stable; }; for (const slide of classroomSlides(options.stage, options.scenes)) { for (const slot of slideMediaReferenceSlots(slide)) { const source = slot.read(); if (!source) continue; const expectedType = slot.kind === 'video-src' || slot.kind === 'video-media-ref' ? 'video' : 'image'; if ( slot.kind === 'video-media-ref' && isGeneratedMediaPlaceholder(source) && slot.element?.type === 'video' && slot.element.src && (stableRefs.has(slot.element.src) || slot.element.src.startsWith('data:')) ) { sourceToStable.set(source, slot.element.src); slot.write(slot.element.src); continue; } slot.write(await freezeReference(source, expectedType)); } } if (options.stage.videoManifest) { options.stage.videoManifest = Object.fromEntries( Object.entries(options.stage.videoManifest).flatMap(([source, entry]) => { const frozen = sourceToStable.get(source); return frozen ? [[frozen, entry] as const] : []; }), ); } return { mediaRecords: [...records.values()], resolveMediaBytes: async (record) => blobs.get(record.id) ?? null, }; } async function prepareAudio(options: { classroomId: string; scenes: readonly Scene[]; baseUrl: string; classroomsDir: string; fetchImpl: typeof fetch; }): Promise<(audioId: string) => Promise> { const audioDir = path.join(options.classroomsDir, options.classroomId, 'audio'); const entries = await fs.readdir(audioDir).catch(() => [] as string[]); const fileById = new Map(); for (const filename of entries) { const extension = path.extname(filename); if (!extension) continue; const id = filename.slice(0, -extension.length); if (fileById.has(id)) throw new Error(`Duplicate narration files for audioId ${id}`); fileById.set(id, filename); } const blobs = new Map(); for (const scene of options.scenes) { for (const action of scene.actions ?? []) { if (action.type !== 'speech') continue; const speech = action as SpeechAction; if (!speech.audioId || blobs.has(speech.audioId)) continue; const filename = fileById.get(speech.audioId); let response: Response | null = null; if (filename) { response = await readLocalAsset(options.classroomsDir, options.classroomId, { kind: 'audio', filename, }); } else if (speech.audioUrl) { response = await options.fetchImpl(new URL(speech.audioUrl, options.baseUrl).href); } if (!response?.ok) continue; const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.byteLength === 0) continue; const mimeType = response.headers.get('content-type')?.split(';')[0]?.trim() || 'audio/mpeg'; blobs.set(speech.audioId, new Blob([bytes as unknown as BlobPart], { type: mimeType })); } } return async (audioId) => blobs.get(audioId) ?? null; } function validateAgentReferences( scenes: readonly Scene[], agentConfigs: readonly GeneratedAgentConfig[], ): void { const agentIds = new Set(agentConfigs.map((agent) => agent.id)); for (const scene of scenes) { for (const action of scene.actions ?? []) { if (action.type === 'discussion' && action.agentId && !agentIds.has(action.agentId)) { throw new Error(`Scene ${scene.id} references unknown discussion agent ${action.agentId}`); } } for (const agentId of scene.multiAgent?.agentIds ?? []) { if (!agentIds.has(agentId)) { throw new Error(`Scene ${scene.id} references unknown multi-agent member ${agentId}`); } } } } export async function packagePersistedClassroom( classroom: PersistedClassroomData, options: PackagePersistedClassroomOptions, ): Promise { const cloned = structuredClone(classroom); cloned.scenes = stripLearnerPBLRuntime(cloned.scenes); const classroomsDir = options.classroomsDir ?? CLASSROOMS_DIR; const frozenFetch = createFrozenFetch({ classroomId: cloned.id, baseUrl: options.baseUrl, classroomsDir, fetchImpl: options.fetchImpl, }); const agentConfigs = portableRoster(cloned.stage); cloned.stage.agentIds = agentConfigs.map((agent) => agent.id); cloned.stage.generatedAgentConfigs = agentConfigs; validateAgentReferences(cloned.scenes, agentConfigs); const media = await prepareMedia({ classroomId: cloned.id, stage: cloned.stage, scenes: cloned.scenes, baseUrl: options.baseUrl, classroomsDir, fetchImpl: frozenFetch, }); const resolveAudioBytes = await prepareAudio({ classroomId: cloned.id, scenes: cloned.scenes, baseUrl: options.baseUrl, classroomsDir, fetchImpl: frozenFetch, }); return packageCourseware({ coursewareId: options.coursewareId ?? cloned.id, version: options.version, stage: cloned.stage, scenes: cloned.scenes, agentConfigs, mediaRecords: media.mediaRecords, resolveMediaBytes: media.resolveMediaBytes, resolveAudioBytes, fetchImpl: frozenFetch, publishedAt: options.publishedAt ?? new Date().toISOString(), appVersion: process.env.npm_package_version ?? '0.0.0', requireAgentRoster: true, requireInteractiveHtml: options.requireInteractiveHtml ?? true, requireNarrationAudio: options.requireNarrationAudio ?? true, strictInteractiveAssets: true, requireComplete: true, }); } export async function publishPersistedClassroom( classroom: PersistedClassroomData, options: PublishPersistedClassroomOptions, ): Promise { const coursewareId = options.coursewareId ?? classroom.id; let packaged: PackageResult | undefined; const published = await publishBuiltCourseware({ coursewareId, courseId: options.courseId, courseModuleIndex: options.courseModuleIndex, sourceClassroomId: classroom.id, baseUrl: options.baseUrl, status: options.status, repos: options.repos, build: async (version) => { packaged = await packagePersistedClassroom(classroom, { ...options, coursewareId, version, }); return new Uint8Array(await packaged.zip.arrayBuffer()); }, }); if (!packaged) throw new Error(`Classroom ${classroom.id} was not packaged`); return { ...published, package: packaged }; }