diff --git a/.env.example b/.env.example index 7aa75f3..f3f5105 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,11 @@ SMTP_PASS= SMTP_FROM= # --- S3 / storage de objetos (compatível MinIO ou AWS S3) --- +# Usando o serviço eden-minio do compose.yaml (bundled, sobe junto com o +# resto): endpoint é http://eden-minio:9000, e as credenciais abaixo viram +# MINIO_ROOT_USER/MINIO_ROOT_PASSWORD desse container — gerar com +# openssl rand -hex 12 / openssl rand -hex 24. Usando AWS S3 real: apagar +# S3_ENDPOINT (deixar em branco) e usar as credenciais IAM normais. S3_ENDPOINT= S3_REGION=us-east-1 S3_FORCE_PATH_STYLE=true @@ -78,6 +83,13 @@ S3_BUCKET= # --- URL pública base (usada para montar links absolutos em e-mails) --- PUBLIC_BASE_URL=http://localhost:3001 +# --- Renderização de PDF (Chromium headless via Playwright) --- +# Usado por documentos, PDF de oferta/orçamento (ANATEL) e certificado de +# assinatura — packages/integrations/src/pdf/renderer.ts. Concorrência +# limitada (cada aba do Chromium consome memória real) e timeout obrigatório. +PDF_MAX_CONCURRENCY=3 +PDF_RENDER_TIMEOUT_SECONDS=15 + # --- Integrações externas (preenchidas quando cada adapter for implementado) --- FOCUS_NFE_API_TOKEN= FOCUS_NFE_BASE_URL= diff --git a/apps/api/src/documents/documents.controller.ts b/apps/api/src/documents/documents.controller.ts index e7c3386..e138e7d 100644 --- a/apps/api/src/documents/documents.controller.ts +++ b/apps/api/src/documents/documents.controller.ts @@ -1,4 +1,5 @@ -import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common"; +import { Body, Controller, Get, Header, Param, Patch, Post, Query, Res, StreamableFile, UseGuards } from "@nestjs/common"; +import type { Response } from "express"; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import { DocumentsService } from "./documents.service"; import { CreateDocumentTemplateDto } from "./dto/create-document-template.dto"; @@ -65,6 +66,15 @@ export class DocumentsController { return this.documents.createGeneration(dto, user); } + @Get("generations/:id/pdf") + @RequirePermission("documents", "view") + @Header("Content-Type", "application/pdf") + async downloadGenerationPdf(@Param("id") id: string, @Res({ passthrough: true }) res: Response) { + const { generation, stream } = await this.documents.getGenerationPdf(id); + res.set({ "Content-Disposition": `inline; filename="${generation.id}.pdf"` }); + return new StreamableFile(stream); + } + @Get("generations") @RequirePermission("documents", "view") listGenerations( diff --git a/apps/api/src/documents/documents.service.ts b/apps/api/src/documents/documents.service.ts index 0c76013..6b40775 100644 --- a/apps/api/src/documents/documents.service.ts +++ b/apps/api/src/documents/documents.service.ts @@ -1,8 +1,12 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; +import { createHash, randomUUID } from "node:crypto"; +import type { Readable } from "node:stream"; import { auditRepo, documentsRepo } from "@eden/database"; +import type { DocumentGeneration } from "@eden/database"; +import { getObjectStream, renderPdf, uploadObject } from "@eden/integrations"; import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard"; import { mergeVariables } from "./merge-engine"; -import { renderEditorContentToHtml, resolveSourceText } from "./editor-content-renderer"; +import { renderEditorContentToHtml } from "./editor-content-renderer"; import type { CreateDocumentTemplateDto } from "./dto/create-document-template.dto"; import type { UpdateDraftDto } from "./dto/update-draft.dto"; import type { CreateGenerationDto } from "./dto/create-generation.dto"; @@ -148,22 +152,39 @@ export class DocumentsService { throw new NotFoundException("Este template ainda não tem uma versão publicada"); } - // Fluxo simplificado desta leva (eden.md §4 descreve o pipeline - // completo com renderToHtml/sanitizeDocumentHtml/Chromium/S3): roda o - // merge engine sobre o texto extraído de editor_content.plainText da - // versão publicada — sem renderização real de PDF (ver comentário na - // migration e no repositório documents.ts). - const sourceText = resolveSourceText(published.version.editor_content); - const { resolved, missingVariables } = mergeVariables(sourceText, dto.context ?? {}); + // eden.md §4 descreve o pipeline completo com sanitizeDocumentHtml — + // não reimplementado nesta leva (editor_content ainda não valida a + // árvore Tiptap real, ver editor-content-renderer.ts); mas a + // renderização de PDF em si (Chromium) e o upload para S3 já são + // reais a partir daqui. + const { resolved, missingVariables } = mergeVariables(published.version.html_content, dto.context ?? {}); + + const origin = { clientRegistrationId: dto.clientRegistrationId ?? null, resellerId: dto.resellerId ?? null }; + + // Evita renderizar/subir um PDF à toa quando o resultado vai ser + // descartado de qualquer forma pela idempotência (mesmo par + // template_version + origem já finalizado). + const existing = await documentsRepo.findFinalGeneration(published.version.id, origin); + if (existing) { + return { ...existing, missingVariables, deduped: true }; + } + + const pdfBuffer = await renderPdf({ html: resolved }); + const pdfSha256 = createHash("sha256").update(pdfBuffer).digest("hex"); + const pdfS3Key = `documents/generations/${randomUUID()}.pdf`; + await uploadObject(pdfS3Key, pdfBuffer, "application/pdf"); const { generation, deduped } = await documentsRepo.createGenerationIdempotent({ templateId: dto.templateId, templateVersionId: published.version.id, - clientRegistrationId: dto.clientRegistrationId ?? null, - resellerId: dto.resellerId ?? null, + clientRegistrationId: origin.clientRegistrationId, + resellerId: origin.resellerId, renderContext: dto.context ?? {}, renderedHtml: resolved, generatedBy: user.id, + pdfS3Key, + pdfSha256, + pdfSizeBytes: pdfBuffer.length, }); if (!deduped) { @@ -180,6 +201,14 @@ export class DocumentsService { return { ...generation, missingVariables, deduped }; } + async getGenerationPdf(id: string): Promise<{ generation: DocumentGeneration; stream: Readable }> { + const generation = await documentsRepo.getGenerationById(id); + if (!generation) throw new NotFoundException("Geração de documento não encontrada"); + if (!generation.pdf_s3_key) throw new NotFoundException("Esta geração ainda não tem um PDF disponível"); + const stream = await getObjectStream(generation.pdf_s3_key); + return { generation, stream }; + } + async listGenerations(filter: { templateId?: string; clientRegistrationId?: string; resellerId?: string }) { return documentsRepo.listGenerations(filter); } diff --git a/apps/api/test/documents-pdf.spec.ts b/apps/api/test/documents-pdf.spec.ts new file mode 100644 index 0000000..e815028 --- /dev/null +++ b/apps/api/test/documents-pdf.spec.ts @@ -0,0 +1,132 @@ +import { INestApplication } from "@nestjs/common"; +import { Readable } from "node:stream"; +import { createHash } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type AuthedTokens } from "./setup.js"; + +/** + * `renderPdf`/`uploadObject`/`getObjectStream` are mocked (the real call + * path through documents.service.ts still runs) — a real Chromium render + + * a real MinIO round trip isn't worth the flakiness/runtime cost in this + * suite; renderer.ts itself is exercised by the manual smoke test noted in + * docs/progress.md. What's under test here is documents.service.ts's own + * logic: it must actually call the renderer with the merged HTML, hash and + * persist the result, skip re-rendering on dedup, and stream the right S3 + * key back out on download. + */ +const renderPdfMock = vi.fn(async (_input: { html: string }) => Buffer.from("%PDF-1.4 fake pdf bytes")); +const uploadObjectMock = vi.fn(async (_key: string, _buffer: Buffer, _mimeType: string) => undefined); +const getObjectStreamMock = vi.fn(async (key: string) => Readable.from([Buffer.from(`content-of:${key}`)])); +vi.mock("@eden/integrations", () => ({ + renderPdf: (input: { html: string }) => renderPdfMock(input), + uploadObject: (key: string, buffer: Buffer, mimeType: string) => uploadObjectMock(key, buffer, mimeType), + getObjectStream: (key: string) => getObjectStreamMock(key), +})); + +describe("documents — PDF generation + download", () => { + let app: INestApplication; + let admin: AuthedTokens; + let templateId: string; + let resellerId: string; + + beforeAll(async () => { + app = await createTestApp(); + admin = await loginSuperAdmin(app); + + const template = await agent(app) + .post("/api/v1/documents/templates") + .set(...bearer(admin.accessToken)) + .send({ key: `qa_test_pdf_${Date.now()}`, name: "qa-test- Template PDF", docGroup: "revenda" }) + .expect(201); + templateId = template.body.id; + + await agent(app) + .post(`/api/v1/documents/templates/${templateId}/draft`) + .set(...bearer(admin.accessToken)) + .expect(201); + await agent(app) + .patch(`/api/v1/documents/templates/${templateId}/draft`) + .set(...bearer(admin.accessToken)) + .send({ editorContent: { type: "doc", plainText: "Revenda: {{revenda.nome}}" } }) + .expect(200); + await agent(app) + .post(`/api/v1/documents/templates/${templateId}/publish`) + .set(...bearer(admin.accessToken)) + .expect(201); + + const reseller = await agent(app) + .post("/api/v1/resellers") + .set(...bearer(admin.accessToken)) + .send({ name: "qa-test- Revenda PDF" }) + .expect(201); + resellerId = reseller.body.id; + }); + + afterAll(async () => { + await agent(app) + .patch(`/api/v1/resellers/${resellerId}`) + .set(...bearer(admin.accessToken)) + .send({ active: false }) + .expect(200); + await closeTestApp(app); + }); + + it("renders a real PDF, uploads it, and persists the hash/size/key", async () => { + const res = await agent(app) + .post("/api/v1/documents/generations") + .set(...bearer(admin.accessToken)) + .send({ templateId, resellerId, context: { "revenda.nome": "ACME" } }) + .expect(201); + + expect(res.body.deduped).toBe(false); + expect(res.body.missingVariables).toEqual([]); + expect(res.body.pdf_s3_key).toMatch(/^documents\/generations\/.+\.pdf$/); + expect(res.body.pdf_size_bytes).toBeGreaterThan(0); + + // The HTML actually handed to the renderer had the variable resolved. + expect(renderPdfMock).toHaveBeenCalledTimes(1); + const renderedHtml = renderPdfMock.mock.calls[0]![0].html; + expect(renderedHtml).toContain("Revenda: ACME"); + expect(renderedHtml).not.toContain("{{revenda.nome}}"); + + // Uploaded buffer's hash matches the mock's output and what got persisted. + const uploadCall = uploadObjectMock.mock.calls[0]!; + expect(uploadCall[0]).toBe(res.body.pdf_s3_key); + expect(uploadCall[2]).toBe("application/pdf"); + expect(createHash("sha256").update(uploadCall[1]).digest("hex")).toBe(res.body.pdf_sha256); + }); + + it("dedupes an identical (template version, origin) request without re-rendering", async () => { + renderPdfMock.mockClear(); + uploadObjectMock.mockClear(); + + const res = await agent(app) + .post("/api/v1/documents/generations") + .set(...bearer(admin.accessToken)) + .send({ templateId, resellerId, context: { "revenda.nome": "ACME" } }) + .expect(201); + + expect(res.body.deduped).toBe(true); + expect(renderPdfMock).not.toHaveBeenCalled(); + expect(uploadObjectMock).not.toHaveBeenCalled(); + }); + + it("streams the stored PDF back from the download endpoint", async () => { + const listRes = await agent(app) + .get("/api/v1/documents/generations") + .set(...bearer(admin.accessToken)) + .query({ resellerId }) + .expect(200); + const generation = listRes.body.find((g: { reseller_id: string | null }) => g.reseller_id === resellerId); + expect(generation).toBeTruthy(); + + const download = await agent(app) + .get(`/api/v1/documents/generations/${generation.id}/pdf`) + .set(...bearer(admin.accessToken)) + .expect(200); + + expect(download.headers["content-type"]).toBe("application/pdf"); + expect(getObjectStreamMock).toHaveBeenCalledWith(generation.pdf_s3_key); + expect(Buffer.isBuffer(download.body) && download.body.toString()).toBe(`content-of:${generation.pdf_s3_key}`); + }); +}); diff --git a/compose.yaml b/compose.yaml index 92d6637..be9df26 100644 --- a/compose.yaml +++ b/compose.yaml @@ -34,6 +34,52 @@ services: retries: 10 networks: [eden_net] + # S3-compatible object storage (packages/integrations/src/s3/client.ts) — + # entra agora porque a geração de PDF (Documents/Quotes) é o primeiro + # consumidor real de storage de objeto (mesma regra do Redis acima: + # container próprio só quando o primeiro uso real precisar dele). Imagem + # oficial `minio/minio` (a variante Bitnami parou de publicar tag + # `latest` gratuita) — sem criação automática de bucket, por isso o + # serviço `eden-minio-init` abaixo faz isso uma vez via `mc mb`. + eden-minio: + image: minio/minio:latest + container_name: eden-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:?defina S3_ACCESS_KEY_ID no .env} + MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:?defina S3_SECRET_ACCESS_KEY no .env} + volumes: + - eden_minio_data:/data + # Console (9001) mapeado ao host só para inspeção manual em + # desenvolvimento — mesmo racional do EDEN_POSTGRES_DEV_PORT acima. + # Remover em produção. + ports: + - "9000:9000" + - "9001:9001" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://127.0.0.1:9000/minio/health/live || exit 1"] + interval: 5s + timeout: 5s + retries: 10 + networks: [eden_net] + + # One-shot: garante que o bucket usado por S3_BUCKET existe. Roda e sai + # (`restart: "no"`) — eden-api só sobe depois dele terminar com sucesso. + eden-minio-init: + image: minio/mc:latest + container_name: eden-minio-init + restart: "no" + depends_on: + eden-minio: + condition: service_healthy + entrypoint: > + sh -c " + mc alias set local http://eden-minio:9000 ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY} && + mc mb --ignore-existing local/${S3_BUCKET:-eden} + " + networks: [eden_net] + eden-api: build: context: . @@ -63,11 +109,15 @@ services: S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY} S3_BUCKET: ${S3_BUCKET} PUBLIC_BASE_URL: ${PUBLIC_BASE_URL} + PDF_MAX_CONCURRENCY: ${PDF_MAX_CONCURRENCY:-3} + PDF_RENDER_TIMEOUT_SECONDS: ${PDF_RENDER_TIMEOUT_SECONDS:-15} ports: - "${EDEN_API_PORT:-8080}:${EDEN_API_PORT:-8080}" depends_on: eden-postgres: condition: service_healthy + eden-minio-init: + condition: service_completed_successfully healthcheck: test: ["CMD-SHELL", "node -e \"fetch('http://localhost:'+ (process.env.EDEN_API_PORT||8080) +'/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] interval: 10s @@ -174,3 +224,4 @@ networks: volumes: eden_pgdata: + eden_minio_data: diff --git a/docs/progress.md b/docs/progress.md index 39288ba..c74511a 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -453,3 +453,34 @@ Teste de integração novo (`test/password-reset.spec.ts`, 3 testes) — usa `vi ### Pendências SMTP ainda não configurado no `.env` real (aguardando credenciais do usuário) — o fluxo todo já funciona corretamente sem SMTP configurado (fail-soft), só não envia e-mail de verdade ainda. Sem rate-limiting no `forgot-password` (mesma lacuna geral já registrada para outros endpoints de auth). + +--- + +## Renderização real de PDF (Chromium) — geração de Documentos ligada de ponta a ponta + +Data: 2026-09-04. Usuário pediu para clonar o legado OrçaFácil (referência read-only em `.tmp/orcafacil-reference/`, fora do git — caminho pedido originalmente (`/opt/orcafacil`) foi bloqueado pelo guard-hook de segurança do projeto por ficar fora de `/opt/eden`; substituído por um local equivalente dentro do projeto em vez de contornar o guard) para estudar como o legado implementa edição de contrato, assinatura eletrônica e o PDF de oferta compatível com ANATEL. Achado principal (via subagente `fork`, sem poluir o contexto principal com código bruto): o legado usa Chromium real (Playwright) server-side só para os templates ricos de `document_templates`; o PDF de oferta ANATEL e o certificado de assinatura usam um pipeline client-side separado (`html2canvas`+`jsPDF`) — não recomendado para portar. Direção confirmada pelo usuário: unificar tudo em Chromium server-side, começando pela geração de Documentos (o "PDF do backoffice") que ainda não produzia PDF real nenhum. + +### `packages/integrations/src/pdf/renderer.ts` (novo) + +Port do `chromiumPdfRenderer.js` do legado, mesma disciplina de segurança/confiabilidade: +- SSRF impossível por construção: conteúdo sempre via `page.setContent()` (nunca `page.goto()`, não existe URL para o chamador controlar) + `page.route('**/*', route => route.abort())` como reforço — todo asset (imagem, fonte) precisa já vir como `data:` URI, não há fallback de rede. +- Browser único reaparoveitado entre chamadas, com relançamento automático se cair (`isConnected()` checado a cada uso). +- Semáforo de concorrência (`PDF_MAX_CONCURRENCY`, default 3) e timeout duro (`PDF_RENDER_TIMEOUT_SECONDS`, default 15s) — novas variáveis em `packages/config/src/env-schema.ts`. +- `--no-sandbox` no lançamento do Chromium (necessário rodando como root no container, sem `USER` no Dockerfile) — risco residual mitigado pelo bloqueio de rede acima (conteúdo é sempre nosso, nunca de terceiro). +- Gotcha documentado no código (igual ao legado): `@page { margin: 0 }` no CSS do próprio documento sobrescreve silenciosamente a opção `margin` passada para `page.pdf()`. + +### Infraestrutura + +- `infra/docker/Dockerfile.node`: novo passo condicional (`if [ "$APP_NAME" = "api" ]`) que roda `playwright install --with-deps chromium` só na imagem da API — o worker não renderiza PDF, fica sem o peso extra. +- `compose.yaml`: novo serviço `eden-minio` (imagem oficial `minio/minio` — a variante Bitnami parou de publicar `latest` gratuita, tentativa inicial falhou no pull) + `eden-minio-init` (one-shot `minio/mc` que cria o bucket via `mc mb --ignore-existing`, já que a imagem oficial não tem criação automática de bucket por env var). Mesmo racional do Redis já documentado no compose: container próprio de storage de objeto só entra agora porque a geração de PDF é o primeiro consumidor real (S3 existia como adapter desde a Fase 1, nunca tinha um caller). Credenciais MinIO geradas com `openssl rand` e adicionadas ao `.env` local. + +### `apps/api/src/documents/` — geração agora produz PDF real + +`DocumentsService.createGeneration()`: mescla variáveis no `html_content` da versão publicada (antes mesclava só o texto puro de `editor_content.plainText`, sem gerar PDF nenhum — `pdf_s3_key`/`pdf_sha256`/`pdf_size_bytes` sempre nulos), renderiza via `renderPdf`, calcula SHA-256, sobe pro S3 (`documents/generations/.pdf`) e persiste os três campos. Checagem de idempotência (`findFinalGeneration`, exportado do repositório) roda ANTES de renderizar — uma geração deduplicada nunca aciona Chromium/S3 à toa. Novo endpoint `GET /v1/documents/generations/:id/pdf` (`StreamableFile`, `Content-Type: application/pdf`) para baixar o PDF gerado. + +### Verificação + +Teste de integração novo (`test/documents-pdf.spec.ts`, 3 testes, `@eden/integrations` mockado como no `password-reset.spec.ts` — evita depender de um Chromium/MinIO real rodando na suíte): confirma que a geração chama o renderer com a variável já resolvida, que o hash/tamanho persistidos batem com o buffer enviado ao S3, que uma geração duplicada não re-renderiza, e que o download stream a chave certa. Suíte completa: 36/36 verdes (33→36). `pnpm turbo run typecheck build --force` (28/28, forçado por precaução após editar `env-schema.ts`/pacotes compartilhados). Testado ao vivo através do container `eden-api` real (não só via proxy): Chromium instalado de fato na imagem (build ~4min, download de ~300MB), bucket criado no MinIO, geração de PDF de ponta a ponta via curl — SHA-256 do arquivo baixado bate byte a byte com o `pdf_sha256` gravado no Postgres. + +### Pendências +PDF de Oferta/Orçamento compatível com ANATEL (Resolução 765/2023) ainda não construído — próximo item natural desta sequência, usando o mesmo `renderPdf`. Módulo de Assinatura Eletrônica completo (envelopes/signers/OTP/hash-chain) ainda não iniciado — item maior, sensível o suficiente para merecer leva própria dedicada, não apressada. Regras de negócio do legado em torno de edição/trava de Orçamento-como-Contrato (lock ao vincular `client_registration_id`, override só por `super_admin`) ainda não avaliadas contra o modelo de Contrato do EDEN (ADR-0006). diff --git a/infra/docker/Dockerfile.node b/infra/docker/Dockerfile.node index 4f2f408..850b4de 100644 --- a/infra/docker/Dockerfile.node +++ b/infra/docker/Dockerfile.node @@ -23,4 +23,12 @@ COPY --from=build /repo /repo ENV NODE_ENV=production ARG APP_NAME ENV EDEN_APP_NAME=${APP_NAME} +# The API renders PDFs (documents, quote/offer, e-signature certificate) +# via a real headless Chromium (packages/integrations/src/pdf/renderer.ts). +# Playwright's own installer pulls both the browser binary and the OS +# libraries it needs (fonts, libnss, etc.) that node:22-slim doesn't ship. +# Skipped for other services (worker) that don't render PDFs, to keep +# their image lean — matches this Dockerfile's existing "not yet +# size-optimized, simplify for Fase 1" stance above. +RUN if [ "$APP_NAME" = "api" ]; then pnpm --filter @eden/integrations exec playwright install --with-deps chromium; fi CMD ["sh", "-c", "pnpm --filter @eden/${EDEN_APP_NAME} start"] diff --git a/packages/config/src/env-schema.ts b/packages/config/src/env-schema.ts index 685aa14..9fb5205 100644 --- a/packages/config/src/env-schema.ts +++ b/packages/config/src/env-schema.ts @@ -49,6 +49,15 @@ export const envSchema = z.object({ * CSRF-adjacent misconfiguration. */ EDEN_CORS_ORIGINS: z.string().optional(), + + /** + * Chromium PDF rendering (packages/integrations/src/pdf/renderer.ts) — + * ported from the legacy's chromiumPdfRenderer.js. Concurrency is capped + * (a headless Chromium tab is expensive) and every render has a hard + * timeout so a pathological document can never hang a request forever. + */ + PDF_MAX_CONCURRENCY: z.coerce.number().int().positive().default(3), + PDF_RENDER_TIMEOUT_SECONDS: z.coerce.number().int().positive().default(15), }); export type EdenEnv = z.infer; diff --git a/packages/database/src/repositories/documents.ts b/packages/database/src/repositories/documents.ts index 7a856e2..6c15153 100644 --- a/packages/database/src/repositories/documents.ts +++ b/packages/database/src/repositories/documents.ts @@ -390,9 +390,12 @@ export interface CreateGenerationInput { renderContext: Record; renderedHtml: string; generatedBy?: string | null; + pdfS3Key?: string | null; + pdfSha256?: string | null; + pdfSizeBytes?: number | null; } -async function findFinalGeneration( +export async function findFinalGeneration( templateVersionId: string, source: { clientRegistrationId?: string | null; resellerId?: string | null }, ): Promise { @@ -418,8 +421,8 @@ async function insertGeneration(input: CreateGenerationInput): Promise( `INSERT INTO document_generations (template_id, template_version_id, client_registration_id, reseller_id, - render_context, rendered_html, status, generated_by) - VALUES ($1, $2, $3, $4, $5, $6, 'final', $7) + render_context, rendered_html, pdf_s3_key, pdf_sha256, pdf_size_bytes, status, generated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'final', $10) RETURNING *`, [ input.templateId, @@ -428,6 +431,9 @@ async function insertGeneration(input: CreateGenerationInput): Promise { + const { rows } = await query("SELECT * FROM document_generations WHERE id = $1", [id]); + return rows[0] ?? null; +} + export async function listGenerations(filter: ListGenerationsFilter = {}): Promise { const conditions: string[] = []; const params: unknown[] = []; diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 55d26b9..6e20ad9 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -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", diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 01d7896..9267427 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -6,3 +6,4 @@ */ export * from "./s3/client.js"; export * from "./smtp/mailer.js"; +export * from "./pdf/renderer.js"; diff --git a/packages/integrations/src/pdf/renderer.ts b/packages/integrations/src/pdf/renderer.ts new file mode 100644 index 0000000..108651d --- /dev/null +++ b/packages/integrations/src/pdf/renderer.ts @@ -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 , 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 doc). All assets must already be data: URIs. */ + html: string; + /** Extra CSS injected into a ` : ""; + if (looksLikeFullDocument) { + // Inject extra CSS right after if present, else leave as-is. + return css ? html.replace(/]*>/i, (tag) => `${tag}${styleTag}`) : html; + } + return `${styleTag}${html}`; +} + +async function renderInPage(page: Page, input: RenderPdfInput): Promise { + // 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 , 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 { + 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((_, 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(); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42c8d22..1415c14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,6 +300,9 @@ importers: nodemailer: specifier: ^9.1.1 version: 9.1.1 + playwright: + specifier: ^1.62.1 + version: 1.62.1 devDependencies: '@types/nodemailer': specifier: ^8.0.1 @@ -2008,6 +2011,11 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2425,6 +2433,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -4603,6 +4621,9 @@ snapshots: fresh@0.5.2: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4951,6 +4972,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + postcss-import@15.1.0(postcss@8.5.26): dependencies: postcss: 8.5.26