// PDFRenderer: the single shared HTML -> PDF entry point.
//
// Both reports (OPL-2598) and invoices (OPL-2602) render their HTML for
// on-screen display first and only call render() on an explicit download
// request — the PDF is produced lazily from the SAME HTML the user already saw,
// so what prints matches what was on screen.
//
// SECURITY: the HTML is populated with user/org-controlled data (client names,
// invoice line items, report text), so it is UNTRUSTED. To prevent server-side
// SSRF/LFI via a malicious field (remote //fetch, or file:// reads):
// - JavaScript is DISABLED by default (templates are pure HTML/CSS).
// - Every subresource request is intercepted and ABORTED unless its scheme is
// in the allow-list (data:/blob: only by default — exactly what our
// templates use for embedded logos/backgrounds/fonts).
// Nothing is fetched over the network during a render under the defaults.
import type { Browser, BrowserContext } from "playwright-core";
import { type PDFRenderOptions, toPdfOptions } from "./options";
import { launchBrowser, type PdfRuntimeKind } from "./runtime";
/** URL schemes permitted for subresource loading by default. */
const DEFAULT_ALLOWED_URL_SCHEMES = ["data", "blob"] as const;
export interface PDFRendererConfig {
/**
* Force a runtime instead of autodetecting from the environment. Leave unset
* in app code; primarily for tests and explicit deployment overrides.
*/
runtime?: PdfRuntimeKind;
}
export interface PDFRenderSecurityOptions {
/**
* Enable JavaScript in the rendering context. Defaults to `false` — our print
* templates are pure HTML/CSS. Only enable for a template that genuinely
* needs it, and never for untrusted HTML.
*/
allowJavaScript?: boolean;
/**
* URL schemes a subresource request may use; everything else is aborted
* before it leaves the process. Defaults to `["data", "blob"]`. Schemes are
* matched case-insensitively against the URL's scheme without the trailing
* colon (e.g. "https").
*/
allowedUrlSchemes?: string[];
}
export type PDFRenderArgs = PDFRenderOptions & PDFRenderSecurityOptions;
export class PDFRenderer {
private readonly runtime?: PdfRuntimeKind;
constructor(config: PDFRendererConfig = {}) {
this.runtime = config.runtime;
}
/**
* Render a complete HTML document to a PDF byte array. The HTML should
* include its own print CSS (see `buildPrintCss` / `PRINT_BASE_CSS`).
*
* The HTML is treated as untrusted: JavaScript is off and only data:/blob:
* subresources load unless explicitly widened via the security options.
*/
async render(html: string, options: PDFRenderArgs = {}): Promise {
const allowJavaScript = options.allowJavaScript ?? false;
const allowedSchemes = new Set(
(options.allowedUrlSchemes ?? DEFAULT_ALLOWED_URL_SCHEMES).map((scheme) =>
scheme.toLowerCase(),
),
);
const browser: Browser = await launchBrowser(this.runtime);
let context: BrowserContext | undefined;
try {
context = await browser.newContext({
javaScriptEnabled: allowJavaScript,
});
// Block every subresource whose scheme is not allow-listed. A regexp
// matcher (not a glob) is used so non-http schemes like file:// and ftp://
// are also intercepted. The inline document set via setContent() is not
// itself a routed request.
await context.route(/.*/, (route) => {
const url = route.request().url();
const scheme = url
.slice(0, Math.max(0, url.indexOf(":")))
.toLowerCase();
if (allowedSchemes.has(scheme)) {
route.continue();
} else {
route.abort("blockedbyclient");
}
});
const page = await context.newPage();
// External requests are blocked, so only data:/blob: resources can
// resolve — "load" fires without waiting on network that will never come.
await page.setContent(html, { waitUntil: "load" });
// Ensure web fonts are applied before capture; otherwise the PDF embeds
// system fallback glyphs.
await page.evaluate(() => document.fonts.ready);
await page.emulateMedia({ media: "print" });
const buffer = await page.pdf(toPdfOptions(options));
return new Uint8Array(buffer);
} finally {
// Close the context first (best-effort), then always close the browser so
// a render error never leaks a Chromium process.
if (context) {
await context.close().catch(() => {});
}
await browser.close();
}
}
}
/**
* Convenience one-shot render. Constructs a {@link PDFRenderer} and renders a
* single document. For repeated renders, construct a {@link PDFRenderer} once.
*/
export async function renderPdf(
html: string,
options: PDFRenderArgs = {},
): Promise {
return new PDFRenderer().render(html, options);
}