diff --git a/app/api/classroom/route.ts b/app/api/classroom/route.ts index 1c83ad2b..8f86cd33 100644 --- a/app/api/classroom/route.ts +++ b/app/api/classroom/route.ts @@ -21,7 +21,8 @@ export async function POST(request: NextRequest) { ); } - const id = stage.id || randomUUID(); + const rawId = stage.id || randomUUID(); + const id = isValidClassroomId(rawId) ? rawId : randomUUID(); const baseUrl = buildRequestOrigin(request); const persisted = await persistClassroom({ id, stage: { ...stage, id }, scenes }, baseUrl); diff --git a/app/classroom/[id]/page.tsx b/app/classroom/[id]/page.tsx index 523e2321..9fafe38f 100644 --- a/app/classroom/[id]/page.tsx +++ b/app/classroom/[id]/page.tsx @@ -4,6 +4,8 @@ import { Stage } from '@/components/stage'; import { ThemeProvider } from '@/lib/hooks/use-theme'; import { useStageStore } from '@/lib/store'; import { loadImageMapping } from '@/lib/utils/image-storage'; +import { db } from '@/lib/utils/database'; +import type { GenerationParamsData } from '@/lib/utils/database'; import { useEffect, useRef, useState, useCallback } from 'react'; import { useParams } from 'next/navigation'; import { useSceneGenerator } from '@/lib/hooks/use-scene-generator'; @@ -114,16 +116,28 @@ export default function ClassroomDetailPage() { if (hasPending && stage) { generationStartedRef.current = true; - // Load generation params from sessionStorage (stored by generation-preview before navigating) - const genParamsStr = sessionStorage.getItem('generationParams'); - const params = genParamsStr ? JSON.parse(genParamsStr) : {}; + void (async () => { + let params: GenerationParamsData = {}; + try { + // Load generation params from IndexedDB (persisted by generation-preview) + const record = await db.stageOutlines.get(stage.id); + params = record?.generationParams || {}; + } catch (err) { + log.warn('[Classroom] Failed to load persisted generation params:', err); + } + + // Reconstruct imageMapping from IndexedDB using pdfImages storageIds + const storageIds = (params.pdfImages || []) + .map((img: { storageId?: string }) => img.storageId) + .filter(Boolean) as string[]; - // Reconstruct imageMapping from IndexedDB using pdfImages storageIds - const storageIds = (params.pdfImages || []) - .map((img: { storageId?: string }) => img.storageId) - .filter(Boolean); + let imageMapping: Record = {}; + try { + imageMapping = await loadImageMapping(storageIds); + } catch (err) { + log.warn('[Classroom] Failed to rebuild PDF image mapping for resume:', err); + } - loadImageMapping(storageIds).then((imageMapping) => { generateRemaining({ pdfImages: params.pdfImages, imageMapping, @@ -135,8 +149,10 @@ export default function ClassroomDetailPage() { }, agents: params.agents, userProfile: params.userProfile, + }).catch((err) => { + log.warn('[Classroom] Scene generation resume error:', err); }); - }); + })(); } else if (outlines.length > 0 && stage) { // All scenes are generated, but some media may not have finished. // Resume media generation for any tasks not yet in IndexedDB. diff --git a/app/generation-preview/page.tsx b/app/generation-preview/page.tsx index 213a5140..ea4cba3a 100644 --- a/app/generation-preview/page.tsx +++ b/app/generation-preview/page.tsx @@ -711,15 +711,18 @@ function GenerationPreviewContent() { const remaining = outlines.filter((o) => o.order !== data.scene.order); store.setGeneratingOutlines(remaining); - // Store generation params for classroom to continue generation - sessionStorage.setItem( - 'generationParams', - JSON.stringify({ - pdfImages: currentSession.pdfImages, - agents, - userProfile, - }), - ); + // Persist generation params to IndexedDB so generation can resume + // even if the tab is closed and reopened (sessionStorage is ephemeral) + const genParams = { + pdfImages: currentSession.pdfImages, + agents, + userProfile, + }; + + await db.stageOutlines.update(stage.id, { + generationParams: genParams, + updatedAt: Date.now(), + }); sessionStorage.removeItem('generationSession'); await store.saveToStorage(); diff --git a/components/scene-renderers/interactive-renderer.tsx b/components/scene-renderers/interactive-renderer.tsx index db8b01dd..4394f019 100644 --- a/components/scene-renderers/interactive-renderer.tsx +++ b/components/scene-renderers/interactive-renderer.tsx @@ -22,7 +22,7 @@ export function InteractiveRenderer({ content, mode: _mode, sceneId }: Interacti src={patchedHtml ? undefined : content.url} className="absolute inset-0 w-full h-full border-0" title={`Interactive Scene ${sceneId}`} - sandbox="allow-scripts allow-same-origin allow-forms allow-popups" + sandbox="allow-scripts allow-forms allow-popups" /> ); diff --git a/components/slide-renderer/Editor/Canvas/LinkDialog.tsx b/components/slide-renderer/Editor/Canvas/LinkDialog.tsx new file mode 100644 index 00000000..05c073da --- /dev/null +++ b/components/slide-renderer/Editor/Canvas/LinkDialog.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import type { PPTElementLink } from '@/lib/types/slides'; + +interface LinkDialogProps { + currentLink?: PPTElementLink; + onConfirm: (link: PPTElementLink) => void; + onClose: () => void; +} + +export function LinkDialog({ currentLink, onConfirm, onClose }: LinkDialogProps) { + const [url, setUrl] = useState(currentLink?.target || ''); + + useEffect(() => { + setUrl(currentLink?.target || ''); + }, [currentLink]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = url.trim(); + if (!trimmed) return; + onConfirm({ type: 'web', target: trimmed }); + }; + + return ( +
+
e.stopPropagation()} + onSubmit={handleSubmit} + > +

+ {currentLink ? 'Edit Link' : 'Add Link'} +

+ setUrl(e.target.value)} + className="w-full rounded border border-gray-300 dark:border-gray-600 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + autoFocus + /> +
+ + +
+
+
+ ); +} diff --git a/components/slide-renderer/Editor/Canvas/hooks/useDrop.ts b/components/slide-renderer/Editor/Canvas/hooks/useDrop.ts index de12a6f9..df072755 100644 --- a/components/slide-renderer/Editor/Canvas/hooks/useDrop.ts +++ b/components/slide-renderer/Editor/Canvas/hooks/useDrop.ts @@ -1,8 +1,14 @@ import { useEffect, type RefObject } from 'react'; import { useCanvasStore } from '@/lib/store'; +import { useCanvasOperations } from '@/lib/hooks/use-canvas-operations'; +import { escapeHtml } from '@/lib/utils/sanitize-html'; +import type { PPTTextElement } from '@/lib/types/slides'; +import { nanoid } from 'nanoid'; export function useDrop(elementRef: RefObject) { const disableHotkeys = useCanvasStore.use.disableHotkeys(); + const canvasScale = useCanvasStore.use.canvasScale(); + const { addElement } = useCanvasOperations(); useEffect(() => { const element = elementRef.current; @@ -13,9 +19,26 @@ export function useDrop(elementRef: RefObject) { const firstItem = e.dataTransfer.items[0]; if (firstItem && firstItem.kind === 'string' && firstItem.type === 'text/plain') { - firstItem.getAsString((_text) => { - if (disableHotkeys) return; - // TODO: implement createTextElement + firstItem.getAsString((text) => { + if (disableHotkeys || !text.trim() || !element) return; + + const rect = element.getBoundingClientRect(); + const left = (e.clientX - rect.left) / canvasScale; + const top = (e.clientY - rect.top) / canvasScale; + + const newElement: PPTTextElement = { + id: nanoid(10), + type: 'text', + left, + top, + width: 300, + height: 50, + rotate: 0, + content: `

${escapeHtml(text)}

`, + defaultFontName: 'Microsoft YaHei', + defaultColor: '#333333', + }; + addElement(newElement); }); } }; @@ -41,5 +64,5 @@ export function useDrop(elementRef: RefObject) { document.removeEventListener('dragenter', preventDefault); document.removeEventListener('dragover', preventDefault); }; - }, [elementRef, disableHotkeys]); + }, [elementRef, disableHotkeys, canvasScale, addElement]); } diff --git a/components/slide-renderer/Editor/Canvas/hooks/useInsertFromCreateSelection.ts b/components/slide-renderer/Editor/Canvas/hooks/useInsertFromCreateSelection.ts index fb309ca6..c56cf2b9 100644 --- a/components/slide-renderer/Editor/Canvas/hooks/useInsertFromCreateSelection.ts +++ b/components/slide-renderer/Editor/Canvas/hooks/useInsertFromCreateSelection.ts @@ -1,11 +1,15 @@ import { useCallback, type RefObject } from 'react'; import { useCanvasStore } from '@/lib/store'; -import type { CreateElementSelectionData } from '@/lib/types/edit'; +import { useCanvasOperations } from '@/lib/hooks/use-canvas-operations'; +import type { CreateElementSelectionData, CreatingTextElement, CreatingShapeElement, CreatingLineElement } from '@/lib/types/edit'; +import type { PPTTextElement, PPTShapeElement, PPTLineElement } from '@/lib/types/slides'; +import { nanoid } from 'nanoid'; export function useInsertFromCreateSelection(viewportRef: RefObject) { const canvasScale = useCanvasStore.use.canvasScale(); const creatingElement = useCanvasStore.use.creatingElement(); const setCreatingElement = useCanvasStore.use.setCreatingElement(); + const { addElement } = useCanvasOperations(); // Calculate selection position and size from the start and end points of mouse drag selection const formatCreateSelection = useCallback( @@ -65,6 +69,69 @@ export function useInsertFromCreateSelection(viewportRef: RefObject { + const element: PPTTextElement = { + id: nanoid(10), + type: 'text', + left: position.left, + top: position.top, + width: Math.max(position.width, 50), + height: Math.max(position.height, 30), + rotate: 0, + content: '


', + defaultFontName: 'Microsoft YaHei', + defaultColor: '#333333', + vertical: creating.vertical, + }; + addElement(element); + }, + [addElement], + ); + + const createShapeElement = useCallback( + (position: { left: number; top: number; width: number; height: number }, creating: CreatingShapeElement) => { + const { data } = creating; + const element: PPTShapeElement = { + id: nanoid(10), + type: 'shape', + left: position.left, + top: position.top, + width: Math.max(position.width, 30), + height: Math.max(position.height, 30), + rotate: 0, + viewBox: data.viewBox, + path: data.path, + fill: '#5b9bd5', + fixedRatio: false, + special: data.special, + pathFormula: data.pathFormula, + }; + addElement(element); + }, + [addElement], + ); + + const createLineElement = useCallback( + (position: { left: number; top: number; start: [number, number]; end: [number, number] }, creating: CreatingLineElement) => { + const { data } = creating; + const element: PPTLineElement = { + id: nanoid(10), + type: 'line', + left: position.left, + top: position.top, + width: 2, + start: position.start, + end: position.end, + style: data.style, + color: '#333333', + points: data.points, + }; + addElement(element); + }, + [addElement], + ); + // Insert element based on mouse selection position and size const insertElementFromCreateSelection = useCallback( (selectionData: CreateElementSelectionData) => { @@ -74,22 +141,22 @@ export function useInsertFromCreateSelection(viewportRef: RefObject { + const handleDblClick = (e: React.MouseEvent) => { if (activeElementIdList.length || creatingElement || creatingCustomShape) return; if (!viewportRef.current) return; - const _viewportRect = viewportRef.current.getBoundingClientRect(); - // TODO: implement createTextElement (use _viewportRect + e.pageX/Y + canvasScale) + const viewportRect = viewportRef.current.getBoundingClientRect(); + const left = (e.pageX - viewportRect.left) / canvasScale; + const top = (e.pageY - viewportRect.top) / canvasScale; + + const textElement: PPTTextElement = { + id: nanoid(10), + type: 'text', + left, + top, + width: 300, + height: 50, + rotate: 0, + content: '


', + defaultFontName: 'Microsoft YaHei', + defaultColor: '#333333', + }; + addNewElement(textElement); }; const openLinkDialog = () => { setLinkDialogVisible(true); }; - const { pasteElement, selectAllElements, deleteAllElements } = useCanvasOperations(); - const contextmenus = (): ContextmenuItem[] => { return [ { @@ -240,8 +263,28 @@ export function Canvas(_props: CanvasProps) { {/* Custom shape creation canvas */} {creatingCustomShape && ( { - // TODO: implement insertCustomShape + onCreated={(data) => { + const left = Math.min(data.start[0], data.end[0]) / canvasScale; + const top = Math.min(data.start[1], data.end[1]) / canvasScale; + const width = Math.abs(data.end[0] - data.start[0]) / canvasScale; + const height = Math.abs(data.end[1] - data.start[1]) / canvasScale; + + const shapeElement: PPTShapeElement = { + id: nanoid(10), + type: 'shape', + left, + top, + width: Math.max(width, 30), + height: Math.max(height, 30), + rotate: 0, + viewBox: data.viewBox, + path: data.path, + fill: data.fill || '#5b9bd5', + fixedRatio: false, + outline: data.outline, + special: true, + }; + addNewElement(shapeElement); }} /> )} @@ -347,8 +390,21 @@ export function Canvas(_props: CanvasProps) { {/* Drag mask when space key is pressed */} {spaceKeyState &&
} - {/* TODO: Add LinkDialog modal */} - {linkDialogVisible &&
LinkDialog placeholder
} + {linkDialogVisible && (() => { + const targetEl = elementList.find((el) => el.id === handleElementId); + return ( + { + if (handleElementId) { + updateElementProps({ id: handleElementId, props: { link } }); + } + setLinkDialogVisible(false); + }} + onClose={() => setLinkDialogVisible(false)} + /> + ); + })()}
@@ -372,7 +428,7 @@ export function Canvas(_props: CanvasProps) { ) : ( { + onClick={(e: React.MouseEvent) => { e.stopPropagation(); child.handler?.(); }} @@ -395,7 +451,7 @@ export function Canvas(_props: CanvasProps) { return ( { + onClick={(e: React.MouseEvent) => { e.stopPropagation(); item.handler?.(); }} diff --git a/components/slide-renderer/components/element/ShapeElement/BaseShapeElement.tsx b/components/slide-renderer/components/element/ShapeElement/BaseShapeElement.tsx index 66cde759..df8c7120 100644 --- a/components/slide-renderer/components/element/ShapeElement/BaseShapeElement.tsx +++ b/components/slide-renderer/components/element/ShapeElement/BaseShapeElement.tsx @@ -1,6 +1,8 @@ 'use client'; +import { useMemo } from 'react'; import type { PPTShapeElement, ShapeText } from '@/lib/types/slides'; +import { sanitizeHtml } from '@/lib/utils/sanitize-html'; import { useElementOutline } from '../hooks/useElementOutline'; import { useElementShadow } from '../hooks/useElementShadow'; import { useElementFlip } from '../hooks/useElementFlip'; @@ -27,6 +29,7 @@ export function BaseShapeElement({ elementInfo }: BaseShapeElementProps) { defaultFontName: 'Microsoft YaHei', defaultColor: '#333333', }; + const safeContent = useMemo(() => sanitizeHtml(text.content), [text.content]); return (
diff --git a/components/slide-renderer/components/element/TableElement/tableUtils.ts b/components/slide-renderer/components/element/TableElement/tableUtils.ts index af5aa7db..ec378e00 100644 --- a/components/slide-renderer/components/element/TableElement/tableUtils.ts +++ b/components/slide-renderer/components/element/TableElement/tableUtils.ts @@ -1,5 +1,6 @@ import type { CSSProperties } from 'react'; import type { TableCell, TableCellStyle } from '@/lib/types/slides'; +import { escapeHtml } from '@/lib/utils/sanitize-html'; /** * Convert TableCellStyle to CSS properties @@ -25,10 +26,11 @@ export function getTextStyle(style?: TableCellStyle): CSSProperties { } /** - * Format text: convert \n to
and spaces to   + * Format text for safe HTML display: escape HTML entities first, + * then convert newlines to
and spaces to  . */ export function formatText(text: string): string { - return text.replace(/\n/g, '
').replace(/ /g, ' '); + return escapeHtml(text).replace(/\n/g, '
').replace(/ /g, ' '); } /** diff --git a/components/slide-renderer/components/element/TextElement/BaseTextElement.tsx b/components/slide-renderer/components/element/TextElement/BaseTextElement.tsx index 84ba299a..f300cdae 100644 --- a/components/slide-renderer/components/element/TextElement/BaseTextElement.tsx +++ b/components/slide-renderer/components/element/TextElement/BaseTextElement.tsx @@ -1,6 +1,8 @@ 'use client'; +import { useMemo } from 'react'; import type { PPTTextElement } from '@/lib/types/slides'; +import { sanitizeHtml } from '@/lib/utils/sanitize-html'; import { useElementShadow } from '../hooks/useElementShadow'; import { ElementOutline } from '../ElementOutline'; @@ -15,6 +17,7 @@ export interface BaseTextElementProps { */ export function BaseTextElement({ elementInfo, target }: BaseTextElementProps) { const { shadowStyle } = useElementShadow(elementInfo.shadow); + const safeContent = useMemo(() => sanitizeHtml(elementInfo.content), [elementInfo.content]); return (
diff --git a/lib/action/engine.ts b/lib/action/engine.ts index d2d38316..e968a136 100644 --- a/lib/action/engine.ts +++ b/lib/action/engine.ts @@ -32,6 +32,7 @@ import type { } from '@/lib/types/action'; import katex from 'katex'; import { createLogger } from '@/lib/logger'; +import { sanitizeHtml, escapeHtml } from '@/lib/utils/sanitize-html'; const log = createLogger('ActionEngine'); @@ -286,7 +287,9 @@ export class ActionEngine { const fontSize = action.fontSize ?? 18; let htmlContent = action.content; if (!htmlContent.startsWith('<')) { - htmlContent = `

${htmlContent}

`; + htmlContent = `

${escapeHtml(htmlContent)}

`; + } else { + htmlContent = sanitizeHtml(htmlContent); } this.stageAPI.whiteboard.addElement( diff --git a/lib/generation/scene-generator.ts b/lib/generation/scene-generator.ts index 1dc22937..e082f5b4 100644 --- a/lib/generation/scene-generator.ts +++ b/lib/generation/scene-generator.ts @@ -1079,7 +1079,13 @@ function formatElementsForPrompt(elements: PPTElement[]): string { function formatQuestionsForPrompt(questions: QuizQuestion[]): string { return questions .map((q, i) => { - const optionsText = q.options ? `Options: ${q.options.join(', ')}` : ''; + let optionsText = ''; + if (q.options && Array.isArray(q.options)) { + const labels = q.options.map((opt) => + typeof opt === 'string' ? opt : `${opt.value}. ${opt.label}`, + ); + optionsText = `Options: ${labels.join(', ')}`; + } return `Q${i + 1} (${q.type}): ${q.question}\n${optionsText}`; }) .join('\n\n'); diff --git a/lib/hooks/use-canvas-operations.ts b/lib/hooks/use-canvas-operations.ts index 92c6d0ca..f909aeb9 100644 --- a/lib/hooks/use-canvas-operations.ts +++ b/lib/hooks/use-canvas-operations.ts @@ -180,32 +180,53 @@ export function useCanvasOperations() { // Copy selected element data to clipboard const copyElement = () => { - // if (!activeElementIdList.length) return + if (!activeElementIdList.length) return; - // const text = JSON.stringify({ - // type: 'elements', - // data: activeElementList, - // }) + const payload = JSON.stringify({ + type: 'elements', + data: activeElementList, + }); - // copyText(text).then(() => { - // setEditorareaFocus(true) - // }) - toast.warning('Not implemented'); + navigator.clipboard + .writeText(payload) + .catch(() => toast.warning('Failed to copy to clipboard')); }; // Copy and delete selected elements (cut) const cutElement = () => { - // copyElement() - // deleteElement() - toast.warning('Not implemented'); + if (!activeElementIdList.length) return; + copyElement(); + deleteElement(); }; // Attempt to paste element data from clipboard const pasteElement = () => { - // readClipboard().then(text => { - // pasteTextClipboardData(text) - // }).catch(err => toast.warning(err)) - toast.warning('Not implemented'); + navigator.clipboard + .readText() + .then((text) => { + try { + const parsed = JSON.parse(text); + if (parsed?.type !== 'elements' || !Array.isArray(parsed.data)) return; + + const elements = parsed.data as PPTElement[]; + const PASTE_OFFSET = 20; + + const newElements = elements.map((el) => ({ + ...el, + id: nanoid(10), + left: (el.left ?? 0) + PASTE_OFFSET, + top: (el.top ?? 0) + PASTE_OFFSET, + })); + + const newSlideElements = [...currentSlide.elements, ...newElements]; + updateSlide({ elements: newSlideElements }); + setActiveElementIdList(newElements.map((el) => el.id)); + addHistorySnapshot(); + } catch { + // Clipboard content is not valid element JSON — ignore silently + } + }) + .catch(() => toast.warning('Failed to read clipboard')); }; // Copy and immediately paste selected elements diff --git a/lib/server/classroom-job-store.ts b/lib/server/classroom-job-store.ts index bf73f33c..5202c879 100644 --- a/lib/server/classroom-job-store.ts +++ b/lib/server/classroom-job-store.ts @@ -79,15 +79,19 @@ async function withJobLock(jobId: string, fn: () => Promise): Promise { const STALE_JOB_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes function markStaleIfNeeded(job: ClassroomGenerationJob): ClassroomGenerationJob { - if (job.status !== 'running') return job; + if (job.status !== 'running' && job.status !== 'queued') return job; const updatedAt = new Date(job.updatedAt).getTime(); if (Date.now() - updatedAt > STALE_JOB_TIMEOUT_MS) { + const reason = + job.status === 'queued' + ? 'Stale job: queued but never started (worker may have crashed)' + : 'Stale job: process may have restarted during generation'; return { ...job, status: 'failed', step: 'failed', - message: 'Job appears stale (no progress update for 30 minutes)', - error: 'Stale job: process may have restarted during generation', + message: `Job appears stale (no progress update for 30 minutes)`, + error: reason, completedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; diff --git a/lib/server/classroom-storage.ts b/lib/server/classroom-storage.ts index 41e3e8c9..91c0d364 100644 --- a/lib/server/classroom-storage.ts +++ b/lib/server/classroom-storage.ts @@ -28,10 +28,31 @@ export async function writeJsonFileAtomic(filePath: string, data: unknown) { await fs.rename(tempFilePath, filePath); } +/** + * Derive the public-facing origin for URL construction. + * + * Prefers the explicit NEXT_PUBLIC_BASE_URL env var (always safe). + * Falls back to x-forwarded-host/proto only when running behind a + * trusted reverse proxy (Vercel sets these automatically). + * As a last resort, uses the request's own origin. + */ export function buildRequestOrigin(req: NextRequest): string { - return req.headers.get('x-forwarded-host') - ? `${req.headers.get('x-forwarded-proto') || 'http'}://${req.headers.get('x-forwarded-host')}` - : req.nextUrl.origin; + if (process.env.NEXT_PUBLIC_BASE_URL) { + return process.env.NEXT_PUBLIC_BASE_URL.replace(/\/+$/, ''); + } + + const forwardedHost = req.headers.get('x-forwarded-host'); + if (forwardedHost) { + // Basic sanity: host must look like a hostname (no path, no whitespace) + if (/^[\w.:-]+$/.test(forwardedHost)) { + const proto = req.headers.get('x-forwarded-proto') || 'https'; + if (proto === 'http' || proto === 'https') { + return `${proto}://${forwardedHost}`; + } + } + } + + return req.nextUrl.origin; } export interface PersistedClassroomData { @@ -46,7 +67,13 @@ export function isValidClassroomId(id: string): boolean { } export async function readClassroom(id: string): Promise { - const filePath = path.join(CLASSROOMS_DIR, `${id}.json`); + if (!isValidClassroomId(id)) return null; + + const filePath = path.resolve(CLASSROOMS_DIR, `${id}.json`); + if (!filePath.startsWith(path.resolve(CLASSROOMS_DIR) + path.sep)) { + return null; + } + try { const content = await fs.readFile(filePath, 'utf-8'); return JSON.parse(content) as PersistedClassroomData; @@ -66,6 +93,10 @@ export async function persistClassroom( }, baseUrl: string, ): Promise { + if (!isValidClassroomId(data.id)) { + throw new Error(`Invalid classroom id: ${data.id}`); + } + const classroomData: PersistedClassroomData = { id: data.id, stage: data.stage, @@ -74,7 +105,13 @@ export async function persistClassroom( }; await ensureClassroomsDir(); - const filePath = path.join(CLASSROOMS_DIR, `${data.id}.json`); + const filePath = path.resolve(CLASSROOMS_DIR, `${data.id}.json`); + + // Defense-in-depth: ensure resolved path stays under the classrooms directory + if (!filePath.startsWith(path.resolve(CLASSROOMS_DIR) + path.sep)) { + throw new Error('Path traversal detected'); + } + await writeJsonFileAtomic(filePath, classroomData); return { diff --git a/lib/utils/database.ts b/lib/utils/database.ts index 62420bad..40b7b053 100644 --- a/lib/utils/database.ts +++ b/lib/utils/database.ts @@ -124,9 +124,16 @@ export interface PlaybackStateRecord { /** * StageOutlines table - Persisted outlines for resume-on-refresh */ +export interface GenerationParamsData { + pdfImages?: Array<{ storageId?: string; [key: string]: unknown }>; + agents?: unknown[]; + userProfile?: unknown; +} + export interface StageOutlinesRecord { stageId: string; // Primary key (FK -> stages.id) outlines: SceneOutline[]; + generationParams?: GenerationParamsData; createdAt: number; updatedAt: number; } diff --git a/lib/utils/sanitize-html.ts b/lib/utils/sanitize-html.ts new file mode 100644 index 00000000..4f1e9b01 --- /dev/null +++ b/lib/utils/sanitize-html.ts @@ -0,0 +1,157 @@ +/** + * Lightweight HTML sanitizer for LLM-generated slide/whiteboard content. + * + * Strips all tags except a strict allowlist and removes dangerous + * attributes (event handlers, javascript: URIs, data: URIs in non-image + * contexts). No external dependency required. + */ + +const ALLOWED_TAGS = new Set([ + 'p', + 'span', + 'strong', + 'b', + 'em', + 'i', + 'u', + 's', + 'del', + 'sub', + 'sup', + 'br', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'li', + 'a', + 'div', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + 'colgroup', + 'col', + 'blockquote', + 'pre', + 'code', + 'hr', + 'img', +]); + +const ALLOWED_ATTRS = new Set([ + 'style', + 'class', + 'id', + 'href', + 'target', + 'rel', + 'src', + 'alt', + 'width', + 'height', + 'colspan', + 'rowspan', + 'align', + 'valign', +]); + +const EVENT_ATTR_RE = /^on/i; +const DANGEROUS_URI_RE = /^\s*(javascript|vbscript|data):/i; + +/** + * Sanitize an HTML string by stripping disallowed tags and attributes. + * + * Uses regex-based rewriting which is sufficient for the ProseMirror-subset + * HTML that the generation pipeline produces. For arbitrary internet HTML + * a DOM-based sanitizer (DOMPurify) would be needed, but that dependency + * is unnecessary here because the input is always server-generated. + */ +export function sanitizeHtml(html: string): string { + if (!html) return ''; + + // Strip