import fs from "fs"; import path from "path"; import { defineAction } from "@agent-native/core"; import { getRequestUserEmail } from "@agent-native/core/server/request-context"; import { resolveAccess } from "@agent-native/core/sharing"; import { z } from "zod"; import "../server/db/index.js"; // ensure registerShareableResource runs import { safeGeneratedFilename, tenantExportDir, } from "../server/lib/tenant-files.js"; import { type AspectRatio, getAspectRatioDims, ASPECT_RATIO_VALUES, } from "../shared/aspect-ratios.js"; /** * Minimal server-side HTML sanitizer for exported slide content. * DOMParser is not available in Node/Nitro, so we use a regex pass to strip * scripts, event handlers, and dangerous URL schemes before embedding slide * HTML into the standalone export file. */ function sanitizeSlideContent(html: string): string { return html .replace( /<(script|iframe|object|embed|form|meta|base|link)\b[\s\S]*?<\/\1>/gi, "", ) .replace( /<(script|iframe|object|embed|form|meta|base|link)\b[^>]*\/?>/gi, "", ) .replace(/\s+on[a-z][\w:-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "") .replace(/\s+srcdoc\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ""); } function buildStandaloneHtml( title: string, slides: Array<{ id: string; content: string; notes?: string }>, aspectRatio?: AspectRatio, ): string { const dims = getAspectRatioDims(aspectRatio); const slideHtmlSections = slides .map( (slide, i) => `
${sanitizeSlideContent(slide.content)}
`, ) .join("\n"); return ` ${escapeHtml(title)}
${slideHtmlSections}
1 / ${slides.length}
navigate F fullscreen Esc exit
`; } function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } export default defineAction({ description: "Export a deck as a standalone HTML file with built-in keyboard navigation. Returns a download URL for the generated file.", schema: z.object({ deckId: z.string().describe("Deck ID to export"), }), run: async ({ deckId }) => { const userEmail = getRequestUserEmail(); if (!userEmail) throw new Error("no authenticated user"); const access = await resolveAccess("deck", deckId); if (!access) throw new Error(`Deck not found: ${deckId}`); const row = access.resource; const deckData = JSON.parse(row.data); const slides = deckData.slides || []; const rawAspectRatio = deckData.aspectRatio; const aspectRatio: AspectRatio | undefined = ASPECT_RATIO_VALUES.includes( rawAspectRatio, ) ? rawAspectRatio : undefined; if (slides.length === 0) { return { error: "Cannot export empty deck" }; } const html = buildStandaloneHtml(row.title, slides, aspectRatio); const filename = safeGeneratedFilename(row.title, ".html"); // Disk write is only useful when the same process can later serve the // file. On serverless (Netlify / Vercel / Lambda), the function filesystem // vanishes between invocations, so `/api/exports/:filename` requests land // on a different container that doesn't have the file — the user sees // "file doesn't exist on site". Skip the disk write entirely on those // hosts; the route handler streams `html` directly. CLI and local-dev // still get a real file path. let filePath: string | undefined; if (!isServerless()) { const exportDir = tenantExportDir(userEmail); fs.mkdirSync(exportDir, { recursive: true }); filePath = path.join(exportDir, filename); fs.writeFileSync(filePath, html); } return { html, filePath, filename, slideCount: slides.length }; }, }); function isServerless(): boolean { return Boolean( process.env.NETLIFY || process.env.VERCEL || process.env.AWS_LAMBDA_FUNCTION_NAME || process.cwd() === "/var/task" || process.cwd().startsWith("/var/task/"), ); }