Repository navigation
Add POST /convert/html-to-pdf — the missing conversion direction #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6a551af
858aab4
5538886
17afb2e
f05225a
ef7d809
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,15 +4,41 @@ import { config } from '../config.js'; | |
| import type { PdfOptions } from '../types.js'; | ||
|
|
||
| /** | ||
| * Render a complete HTML document to PDF bytes using the shared headless | ||
| * browser. Each call gets its own page, which is always closed. | ||
| * Render an HTML document to PDF bytes using the shared headless browser. Each | ||
| * call gets its own page, which is always closed. | ||
| * | ||
| * Security hardening: JavaScript is disabled (the document is static print | ||
| * content), and unless PDF_ALLOW_REMOTE is set, every non-inline request is | ||
| * aborted — only `data:` and the initial `about:blank`/document load proceed. | ||
| * That blocks `file://` reads and remote (SSRF) fetches via crafted Markdown. | ||
| * | ||
| * `extraCss` is applied to the PARSED DOCUMENT, after `setContent`, never by | ||
| * concatenating it to the source string. The Markdown path does not use it — | ||
| * `markdownToHtml` bakes `options.css` into the document it builds — so it is | ||
| * only the HTML path, where the source is the caller's and cannot be edited | ||
| * safely, that passes it. | ||
| * | ||
| * Why it has to be the DOM. Deciding where a `<style>` belongs in someone | ||
| * else's markup means knowing the tokenizer's state, and every string-level | ||
| * answer has a document that defeats it: a `</head>` that is really text | ||
| * (inside a comment, raw text, RCDATA, or a quoted attribute value) sends the | ||
| * block somewhere it is parsed as content, and appending at the end is | ||
| * swallowed whole by a document ending in an open context (`<textarea>text`, | ||
| * `<!-- comment`). Every one of those failures is SILENT — a 200, a well-formed | ||
| * PDF, and no styles applied. Adding a node to the parsed document skips the | ||
| * question: the browser has already resolved the markup, and the node lands in | ||
| * `<head>` after the document's own styles, which is the cascade position | ||
| * `css` is documented to have. | ||
| * | ||
| * It also retires the `</style>` neutralization that the string form needed: | ||
| * DOM text content cannot close an element, so caller CSS containing `</style>` | ||
| * is now just CSS. | ||
| */ | ||
| export async function htmlToPdf(html: string, options: PdfOptions): Promise<Buffer> { | ||
| export async function htmlToPdf( | ||
| html: string, | ||
| options: PdfOptions, | ||
| extraCss?: string, | ||
| ): Promise<Buffer> { | ||
| const browser = await getBrowser(); | ||
| const page = await browser.newPage(); | ||
| try { | ||
|
|
@@ -34,7 +60,37 @@ export async function htmlToPdf(html: string, options: PdfOptions): Promise<Buff | |
| void req.abort(); | ||
| }); | ||
|
|
||
| // A caller document carrying its own Content-Security-Policy would | ||
| // otherwise block the style node added below, and block it SILENTLY — the | ||
| // injection call still succeeds and the PDF comes back unstyled. Safe here | ||
| // because the two things CSP would be protecting against are already gone: | ||
| // page scripts are disabled, and every non-`data:` request is aborted | ||
| // regardless of what any policy permits. | ||
| await page.setBypassCSP(true); | ||
|
|
||
| await page.setContent(html, { waitUntil: 'load', timeout: 30_000 }); | ||
|
|
||
| // Applied to the PARSED document, after setContent, so there is no | ||
| // tokenizer state left to guess at. | ||
| // | ||
| // Appended to the END of the body rather than to <head>: a caller may carry | ||
| // a <style> or a stylesheet link in the body, which is later in document | ||
| // order than <head> and would therefore win at equal specificity. `css` is | ||
| // documented to come after ALL the caller's styles, so it has to be last in | ||
| // the document, not merely present. | ||
| // | ||
| // This works despite setJavaScriptEnabled(false): that disables scripts | ||
| // belonging to the PAGE, while this runs over the DevTools protocol, which | ||
| // is not page script. Pinned by end-to-end tests rather than trusted — a | ||
| // silent failure here looks exactly like success. | ||
| if (extraCss) { | ||
| await page.evaluate((cssText: string) => { | ||
| const style = document.createElement('style'); | ||
| style.textContent = cssText; | ||
| (document.body ?? document.documentElement).appendChild(style); | ||
|
Comment on lines
+88
to
+90
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| }, extraCss); | ||
| } | ||
|
|
||
| await page.emulateMediaType('print'); | ||
|
|
||
| const pdf = await page.pdf({ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because CSP bypass is enabled for every render, even requests without
options.cssno longer render the supplied document verbatim: for example, a document withstyle-src 'none'will have its otherwise-blocked inline styles applied, and withPDF_ALLOW_REMOTE=trueCSP-blocked HTTP resources may also load. Enable the bypass only when an override actually needs it, or inject that override through a mechanism that does not disable the caller's policy globally.Useful? React with 👍 / 👎.