466 lines
16 KiB
TypeScript
466 lines
16 KiB
TypeScript
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<string, string> = {
|
|
'.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<Response> {
|
|
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<Response> {
|
|
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<Pick<Slide, 'background' | 'elements'>> {
|
|
const slides: Array<Pick<Slide, 'background' | 'elements'>> = [...(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<Blob | null>;
|
|
}> {
|
|
const sourceToStable = new Map<string, string>();
|
|
const stableRefs = new Set<string>();
|
|
const records = new Map<string, MediaFileRecord>();
|
|
const blobs = new Map<string, Blob>();
|
|
|
|
const freezeReference = async (
|
|
source: string,
|
|
expectedType: 'image' | 'video',
|
|
): Promise<string> => {
|
|
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<Blob | null>> {
|
|
const audioDir = path.join(options.classroomsDir, options.classroomId, 'audio');
|
|
const entries = await fs.readdir(audioDir).catch(() => [] as string[]);
|
|
const fileById = new Map<string, string>();
|
|
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<string, Blob>();
|
|
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<PackageResult> {
|
|
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<PublishedPersistedClassroom> {
|
|
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 };
|
|
}
|