You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/pdf.ts

49 lines
2.1 KiB
TypeScript

import puppeteer, { type Browser } from 'puppeteer'
/**
* HTML → PDF via puppeteer. The browser is a lazy module-level singleton:
* launch costs ~1s, so it starts on first render and is reused after.
* Guarding with a promise (not the Browser) makes concurrent first calls
* share one launch instead of racing two Chromes.
*/
let browserPromise: Promise<Browser> | null = null
function getBrowser(): Promise<Browser> {
// --no-sandbox + --disable-dev-shm-usage let Chromium launch inside a container
// (root user, small /dev/shm); both are inert on a normal dev machine. We only ever
// render our own self-contained letterhead HTML (no external fetches), so dropping
// the sandbox here is not exposing us to third-party page content.
browserPromise ??= puppeteer.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] })
return browserPromise
}
export async function renderPdf(html: string): Promise<Buffer> {
const page = await (await getBrowser()).newPage()
try {
// D24 (red-team): defense-in-depth for the public-share render. Block every network
// request at the browser layer — the letterhead is fully self-contained (inline CSS,
// data:-URI logo), so any http(s)/file fetch would only come from an injected payload
// that slipped past esc(). Combined with the template CSP, an escaping miss becomes a
// harmless rendering glitch instead of an SSRF/exfiltration vector.
await page.setRequestInterception(true)
page.on('request', (req) => {
const url = req.url()
if (url.startsWith('data:') || url === 'about:blank') void req.continue()
else void req.abort()
})
await page.setContent(html, { waitUntil: 'load' })
const bytes = await page.pdf({ format: 'A4', printBackground: true })
return Buffer.from(bytes)
} finally {
await page.close()
}
}
/** For tests/scripts that must let the process exit; the server never calls it. */
export async function closePdfBrowser(): Promise<void> {
if (browserPromise === null) return
const b = await browserPromise
browserPromise = null
await b.close()
}