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

@@ -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(

View File

@@ -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);
}