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:
@@ -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",
|
||||
|
||||
@@ -6,3 +6,4 @@
|
||||
*/
|
||||
export * from "./s3/client.js";
|
||||
export * from "./smtp/mailer.js";
|
||||
export * from "./pdf/renderer.js";
|
||||
|
||||
147
packages/integrations/src/pdf/renderer.ts
Normal file
147
packages/integrations/src/pdf/renderer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user