Files
fusion/plugins/fusion-plugin-reports/src/render/standalone-html.ts
Fusion e9c4c6e4f1 feat(FN-3785): add standalone HTML rendering and export routes for reports
Adds a complete HTML rendering and export pipeline to the fusion-plugin-reports plugin, including a standalone HTML renderer with template and stylesheet support, new export routes (`/api/reports/:id/export/html`) that persist rendered HTML to the store, and plugin-type support for non-JSON route re

Fusion-Task-Id: FN-3785
2026-05-10 15:33:24 -07:00

76 lines
4.2 KiB
TypeScript

import { readFileSync } from "node:fs";
import { extname } from "node:path";
import type { Report } from "../store/report-types.js";
import { escapeAttr } from "./escape.js";
import { buildBrandingCss, REPORT_STYLESHEET } from "./html-styles.js";
import { renderReportHtml, type ReportRecord, type ReportRenderOptions } from "./html-template.js";
const ALLOWLISTED_LINK_PREFIXES = ["https://runfusion.ai"];
function inferMime(path: string): string {
const ext = extname(path).toLowerCase();
if (ext === ".png") return "image/png";
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".gif") return "image/gif";
if (ext === ".webp") return "image/webp";
if (ext === ".svg") return "image/svg+xml";
return "application/octet-stream";
}
function toDataUri(path: string): string {
const mime = inferMime(path);
const bytes = readFileSync(path);
return `data:${mime};base64,${bytes.toString("base64")}`;
}
function removeScripts(html: string): string {
return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
}
function sanitizeExternalImages(html: string): string {
return html.replace(/<img\b([^>]*?)\ssrc=["'](https?:[^"']+)["']([^>]*)>/gi, "<!-- stripped external image -->");
}
function assertNoExternalRefs(html: string): string {
const matches = [...html.matchAll(/(href|src)\s*=\s*["'](https?:[^"']+)["']/gi)];
const disallowed = matches.filter((m) => !ALLOWLISTED_LINK_PREFIXES.some((prefix) => m[2]?.startsWith(prefix)));
if (disallowed.length === 0) return html;
if (process.env.NODE_ENV !== "production") {
throw new Error(`Standalone HTML contains external refs: ${disallowed.map((m) => m[2]).join(", ")}`);
}
return `${html}\n<!-- WARNING: stripped/retained external refs detected -->`;
}
function resolveBrandLogo(record: ReportRecord): string | undefined {
const metadata = record.metadata && typeof record.metadata === "object" ? record.metadata as Record<string, unknown> : {};
const settings = metadata.settings && typeof metadata.settings === "object" ? metadata.settings as Record<string, unknown> : {};
const branding = settings.branding && typeof settings.branding === "object" ? settings.branding as Record<string, unknown> : {};
const logoDataUri = typeof branding.logoDataUri === "string" ? branding.logoDataUri : undefined;
const logoPath = typeof branding.logoPath === "string" ? branding.logoPath : undefined;
if (logoDataUri?.startsWith("data:")) return logoDataUri;
if (logoPath && !/^https?:/i.test(logoPath)) return toDataUri(logoPath);
return undefined;
}
export function slugifyReportFilename(record: Pick<Report, "title" | "periodStart" | "periodEnd">): string {
const base = `${record.title}-${record.periodStart}-${record.periodEnd}`.toLowerCase();
const slug = base.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120);
return `${slug || "fusion-report"}.html`;
}
export function renderStandaloneReportHtml(record: ReportRecord, options: ReportRenderOptions = {}): string {
const logoDataUri = resolveBrandLogo(record);
const metadata = record.metadata && typeof record.metadata === "object" ? { ...(record.metadata as Record<string, unknown>) } : {};
const settings = metadata.settings && typeof metadata.settings === "object" ? { ...(metadata.settings as Record<string, unknown>) } : {};
const branding = settings.branding && typeof settings.branding === "object" ? { ...(settings.branding as Record<string, unknown>) } : {};
if (logoDataUri) branding.logoDataUri = logoDataUri;
settings.branding = branding;
metadata.settings = settings;
const html = renderReportHtml({ ...record, metadata }, { ...options, includeChrome: true });
const styleBlock = `<style>${REPORT_STYLESHEET}\n${buildBrandingCss({ accentColor: typeof branding.accentColor === "string" ? branding.accentColor : undefined, logoDataUri: typeof branding.logoDataUri === "string" ? branding.logoDataUri : undefined, logoTextColor: typeof branding.logoTextColor === "string" ? branding.logoTextColor : undefined })}</style>`;
const withSingleStyle = html.replace(/<style>[\s\S]*?<\/style>/i, styleBlock);
const sanitized = sanitizeExternalImages(removeScripts(withSingleStyle));
return assertNoExternalRefs(sanitized);
}