feat(documents): renderização real de PDF via Chromium headless

Substitui a geração de PDF simulada (pdf_s3_key/pdf_sha256/pdf_size_bytes
sempre nulos) por Chromium real (Playwright), portado do
chromiumPdfRenderer.js do legado: SSRF impossível por construção
(setContent + bloqueio de toda requisição de rede da página), browser
único reaproveitado com relançamento automático, semáforo de
concorrência + timeout duro.

Infra: MinIO (compose.yaml, primeiro consumidor real do adapter S3) e
instalação condicional do Chromium só na imagem da API
(Dockerfile.node). DocumentsService.createGeneration agora renderiza,
faz upload e persiste hash/tamanho de verdade; novo endpoint de
download GET /v1/documents/generations/:id/pdf.

Verificado ao vivo através do container real: PDF gerado, baixado, e
SHA-256 conferido byte a byte contra o valor persistido no Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
This commit is contained in:
2026-09-04 09:26:31 -03:00
parent 4d23af3b23
commit 29430d0d96
13 changed files with 486 additions and 15 deletions

View File

@@ -13,7 +13,8 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1096.0",
"@eden/config": "workspace:*",
"nodemailer": "^9.1.1"
"nodemailer": "^9.1.1",
"playwright": "^1.62.1"
},
"devDependencies": {
"@types/nodemailer": "^8.0.1",

View File

@@ -6,3 +6,4 @@
*/
export * from "./s3/client.js";
export * from "./smtp/mailer.js";
export * from "./pdf/renderer.js";

View File

@@ -0,0 +1,147 @@
/**
* Chromium PDF renderer — port of the legacy `chromiumPdfRenderer.js`
* (eden.md módulo 4 §4). Used for document generation (Documents module),
* the ANATEL-compliant offer PDF (Quotes), and the e-signature
* certificate — one engine for all three, unlike the legacy's split
* pipeline (Chromium server-side for rich templates, `html2canvas`+`jsPDF`
* client-side for the ANATEL PDF and the signature certificate). EDEN
* unifies on Chromium for every case: more consistent, testable
* server-side, and never depends on the caller's browser.
*
* SSRF is impossible by construction, not by filtering: the page's
* content is always injected via `page.setContent()`, NEVER `page.goto()`
* (so there is no URL for a caller to control), AND every network request
* the page itself might attempt (an <img src>, a @font-face, anything) is
* aborted via `page.route('**\/*', ...)`. This means all assets (images,
* fonts, logos) used in a rendered document MUST already be inlined as
* `data:` URIs before the HTML reaches this module — there is no fallback
* fetch path. That inlining is the caller's responsibility (mirrors the
* legacy's `inlineAssets.js`).
*/
import { Buffer } from "node:buffer";
import { chromium, type Browser, type Page } from "playwright";
import { loadEnv } from "@eden/config";
export interface PdfMargin {
top?: string;
right?: string;
bottom?: string;
left?: string;
}
export interface RenderPdfInput {
/** Full HTML document (or fragment — wrapped in a minimal shell if it isn't a full <html> doc). All assets must already be data: URIs. */
html: string;
/** Extra CSS injected into a <style> tag ahead of `html`. */
css?: string;
margin?: PdfMargin;
/**
* KNOWN GOTCHA, ported verbatim from the legacy's own comment: a `@page
* { margin: 0 }` rule inside the document's own CSS silently overrides
* whatever `margin` is passed to `page.pdf()` below — Chromium's print
* engine gives CSS `@page` precedence over the Puppeteer/Playwright API
* option. If a template needs a specific margin, it must NOT declare its
* own `@page` margin and rely on this `margin` option instead (or set
* both to the same values deliberately).
*/
}
const DEFAULT_MARGIN: Required<PdfMargin> = { top: "15mm", right: "15mm", bottom: "15mm", left: "15mm" };
let browserPromise: Promise<Browser> | undefined;
let activeRenders = 0;
const waitQueue: Array<() => void> = [];
async function getBrowser(): Promise<Browser> {
if (browserPromise) {
const existing = await browserPromise;
if (existing.isConnected()) return existing;
// Crashed/disconnected since last use — relaunch below.
}
browserPromise = chromium.launch({
headless: true,
// Running as root inside the API container (Dockerfile.node has no
// USER directive) requires --no-sandbox for Chromium's own sandbox to
// start at all. The residual risk this normally guards against
// (a compromised renderer process escaping via a malicious page) is
// already mitigated here by construction: every render is our own
// generated HTML, never third-party content, and all outbound
// requests from the page are aborted regardless (see module header).
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
return browserPromise;
}
/** Simple counting semaphore — never more than PDF_MAX_CONCURRENCY renders run at once; a headless Chromium page is expensive enough that unbounded concurrency could exhaust the container's memory under load. */
async function acquireSlot(): Promise<void> {
const { PDF_MAX_CONCURRENCY } = loadEnv();
if (activeRenders < PDF_MAX_CONCURRENCY) {
activeRenders += 1;
return;
}
await new Promise<void>((resolve) => waitQueue.push(resolve));
activeRenders += 1;
}
function releaseSlot(): void {
activeRenders -= 1;
const next = waitQueue.shift();
if (next) next();
}
function wrapAsFullDocument(html: string, css?: string): string {
const looksLikeFullDocument = /<html[\s>]/i.test(html);
const styleTag = css ? `<style>${css}</style>` : "";
if (looksLikeFullDocument) {
// Inject extra CSS right after <head> if present, else leave as-is.
return css ? html.replace(/<head[^>]*>/i, (tag) => `${tag}${styleTag}`) : html;
}
return `<!doctype html><html><head><meta charset="utf-8" />${styleTag}</head><body>${html}</body></html>`;
}
async function renderInPage(page: Page, input: RenderPdfInput): Promise<Buffer> {
// Reinforcement, not the primary defense (see module header): even
// though page.setContent() never fetches a caller-controlled URL, block
// every request the page's own markup might still attempt (an <img
// src="https://...">, a font, anything) so nothing ever leaves the
// container mid-render.
await page.route("**/*", (route) => route.abort());
const fullHtml = wrapAsFullDocument(input.html, input.css);
await page.setContent(fullHtml, { waitUntil: "load" });
const margin = { ...DEFAULT_MARGIN, ...input.margin };
const pdfBuffer = await page.pdf({
format: "A4",
margin,
printBackground: true,
});
return Buffer.from(pdfBuffer);
}
/**
* Renders HTML to a PDF buffer. Bounded by PDF_MAX_CONCURRENCY (queues
* beyond that) and PDF_RENDER_TIMEOUT_SECONDS (rejects, never hangs). A
* fresh page is opened per render and always closed in `finally`, even on
* timeout/error — only the browser process itself is reused across calls.
*/
export async function renderPdf(input: RenderPdfInput): Promise<Buffer> {
const { PDF_RENDER_TIMEOUT_SECONDS } = loadEnv();
await acquireSlot();
let page: Page | undefined;
try {
const browser = await getBrowser();
page = await browser.newPage();
const timeoutMs = PDF_RENDER_TIMEOUT_SECONDS * 1000;
return await Promise.race([
renderInPage(page, input),
new Promise<Buffer>((_, reject) =>
setTimeout(() => reject(new Error(`Renderização de PDF excedeu ${PDF_RENDER_TIMEOUT_SECONDS}s`)), timeoutMs),
),
]);
} finally {
if (page) await page.close().catch(() => undefined);
releaseSlot();
}
}