Files
openmaic/OpenMAIC/lib/server/classroom-courseware-publish.ts

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 };
}