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}
←→ navigateF fullscreenEsc 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/"),
);
}