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

@@ -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/<uuid>.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).