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:
12
.env.example
12
.env.example
@@ -68,6 +68,11 @@ SMTP_PASS=
|
|||||||
SMTP_FROM=
|
SMTP_FROM=
|
||||||
|
|
||||||
# --- S3 / storage de objetos (compatível MinIO ou AWS S3) ---
|
# --- 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_ENDPOINT=
|
||||||
S3_REGION=us-east-1
|
S3_REGION=us-east-1
|
||||||
S3_FORCE_PATH_STYLE=true
|
S3_FORCE_PATH_STYLE=true
|
||||||
@@ -78,6 +83,13 @@ S3_BUCKET=
|
|||||||
# --- URL pública base (usada para montar links absolutos em e-mails) ---
|
# --- URL pública base (usada para montar links absolutos em e-mails) ---
|
||||||
PUBLIC_BASE_URL=http://localhost:3001
|
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) ---
|
# --- Integrações externas (preenchidas quando cada adapter for implementado) ---
|
||||||
FOCUS_NFE_API_TOKEN=
|
FOCUS_NFE_API_TOKEN=
|
||||||
FOCUS_NFE_BASE_URL=
|
FOCUS_NFE_BASE_URL=
|
||||||
|
|||||||
@@ -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 { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
import { DocumentsService } from "./documents.service";
|
import { DocumentsService } from "./documents.service";
|
||||||
import { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
|
import { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
|
||||||
@@ -65,6 +66,15 @@ export class DocumentsController {
|
|||||||
return this.documents.createGeneration(dto, user);
|
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")
|
@Get("generations")
|
||||||
@RequirePermission("documents", "view")
|
@RequirePermission("documents", "view")
|
||||||
listGenerations(
|
listGenerations(
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
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 { 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 type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard";
|
||||||
import { mergeVariables } from "./merge-engine";
|
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 { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
|
||||||
import type { UpdateDraftDto } from "./dto/update-draft.dto";
|
import type { UpdateDraftDto } from "./dto/update-draft.dto";
|
||||||
import type { CreateGenerationDto } from "./dto/create-generation.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");
|
throw new NotFoundException("Este template ainda não tem uma versão publicada");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fluxo simplificado desta leva (eden.md §4 descreve o pipeline
|
// eden.md §4 descreve o pipeline completo com sanitizeDocumentHtml —
|
||||||
// completo com renderToHtml/sanitizeDocumentHtml/Chromium/S3): roda o
|
// não reimplementado nesta leva (editor_content ainda não valida a
|
||||||
// merge engine sobre o texto extraído de editor_content.plainText da
|
// árvore Tiptap real, ver editor-content-renderer.ts); mas a
|
||||||
// versão publicada — sem renderização real de PDF (ver comentário na
|
// renderização de PDF em si (Chromium) e o upload para S3 já são
|
||||||
// migration e no repositório documents.ts).
|
// reais a partir daqui.
|
||||||
const sourceText = resolveSourceText(published.version.editor_content);
|
const { resolved, missingVariables } = mergeVariables(published.version.html_content, dto.context ?? {});
|
||||||
const { resolved, missingVariables } = mergeVariables(sourceText, 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({
|
const { generation, deduped } = await documentsRepo.createGenerationIdempotent({
|
||||||
templateId: dto.templateId,
|
templateId: dto.templateId,
|
||||||
templateVersionId: published.version.id,
|
templateVersionId: published.version.id,
|
||||||
clientRegistrationId: dto.clientRegistrationId ?? null,
|
clientRegistrationId: origin.clientRegistrationId,
|
||||||
resellerId: dto.resellerId ?? null,
|
resellerId: origin.resellerId,
|
||||||
renderContext: dto.context ?? {},
|
renderContext: dto.context ?? {},
|
||||||
renderedHtml: resolved,
|
renderedHtml: resolved,
|
||||||
generatedBy: user.id,
|
generatedBy: user.id,
|
||||||
|
pdfS3Key,
|
||||||
|
pdfSha256,
|
||||||
|
pdfSizeBytes: pdfBuffer.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!deduped) {
|
if (!deduped) {
|
||||||
@@ -180,6 +201,14 @@ export class DocumentsService {
|
|||||||
return { ...generation, missingVariables, deduped };
|
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 }) {
|
async listGenerations(filter: { templateId?: string; clientRegistrationId?: string; resellerId?: string }) {
|
||||||
return documentsRepo.listGenerations(filter);
|
return documentsRepo.listGenerations(filter);
|
||||||
}
|
}
|
||||||
|
|||||||
132
apps/api/test/documents-pdf.spec.ts
Normal file
132
apps/api/test/documents-pdf.spec.ts
Normal file
@@ -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}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
compose.yaml
51
compose.yaml
@@ -34,6 +34,52 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
networks: [eden_net]
|
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:
|
eden-api:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -63,11 +109,15 @@ services:
|
|||||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
|
||||||
S3_BUCKET: ${S3_BUCKET}
|
S3_BUCKET: ${S3_BUCKET}
|
||||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
|
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
|
||||||
|
PDF_MAX_CONCURRENCY: ${PDF_MAX_CONCURRENCY:-3}
|
||||||
|
PDF_RENDER_TIMEOUT_SECONDS: ${PDF_RENDER_TIMEOUT_SECONDS:-15}
|
||||||
ports:
|
ports:
|
||||||
- "${EDEN_API_PORT:-8080}:${EDEN_API_PORT:-8080}"
|
- "${EDEN_API_PORT:-8080}:${EDEN_API_PORT:-8080}"
|
||||||
depends_on:
|
depends_on:
|
||||||
eden-postgres:
|
eden-postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
eden-minio-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
healthcheck:
|
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))\""]
|
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
|
interval: 10s
|
||||||
@@ -174,3 +224,4 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
eden_pgdata:
|
eden_pgdata:
|
||||||
|
eden_minio_data:
|
||||||
|
|||||||
@@ -453,3 +453,34 @@ Teste de integração novo (`test/password-reset.spec.ts`, 3 testes) — usa `vi
|
|||||||
|
|
||||||
### Pendências
|
### 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).
|
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).
|
||||||
|
|||||||
@@ -23,4 +23,12 @@ COPY --from=build /repo /repo
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ARG APP_NAME
|
ARG APP_NAME
|
||||||
ENV EDEN_APP_NAME=${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"]
|
CMD ["sh", "-c", "pnpm --filter @eden/${EDEN_APP_NAME} start"]
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ export const envSchema = z.object({
|
|||||||
* CSRF-adjacent misconfiguration.
|
* CSRF-adjacent misconfiguration.
|
||||||
*/
|
*/
|
||||||
EDEN_CORS_ORIGINS: z.string().optional(),
|
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<typeof envSchema>;
|
export type EdenEnv = z.infer<typeof envSchema>;
|
||||||
|
|||||||
@@ -390,9 +390,12 @@ export interface CreateGenerationInput {
|
|||||||
renderContext: Record<string, unknown>;
|
renderContext: Record<string, unknown>;
|
||||||
renderedHtml: string;
|
renderedHtml: string;
|
||||||
generatedBy?: string | null;
|
generatedBy?: string | null;
|
||||||
|
pdfS3Key?: string | null;
|
||||||
|
pdfSha256?: string | null;
|
||||||
|
pdfSizeBytes?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findFinalGeneration(
|
export async function findFinalGeneration(
|
||||||
templateVersionId: string,
|
templateVersionId: string,
|
||||||
source: { clientRegistrationId?: string | null; resellerId?: string | null },
|
source: { clientRegistrationId?: string | null; resellerId?: string | null },
|
||||||
): Promise<DocumentGeneration | null> {
|
): Promise<DocumentGeneration | null> {
|
||||||
@@ -418,8 +421,8 @@ async function insertGeneration(input: CreateGenerationInput): Promise<DocumentG
|
|||||||
const { rows } = await query<DocumentGeneration>(
|
const { rows } = await query<DocumentGeneration>(
|
||||||
`INSERT INTO document_generations
|
`INSERT INTO document_generations
|
||||||
(template_id, template_version_id, client_registration_id, reseller_id,
|
(template_id, template_version_id, client_registration_id, reseller_id,
|
||||||
render_context, rendered_html, status, generated_by)
|
render_context, rendered_html, pdf_s3_key, pdf_sha256, pdf_size_bytes, status, generated_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 'final', $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'final', $10)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
input.templateId,
|
input.templateId,
|
||||||
@@ -428,6 +431,9 @@ async function insertGeneration(input: CreateGenerationInput): Promise<DocumentG
|
|||||||
input.resellerId ?? null,
|
input.resellerId ?? null,
|
||||||
JSON.stringify(input.renderContext),
|
JSON.stringify(input.renderContext),
|
||||||
input.renderedHtml,
|
input.renderedHtml,
|
||||||
|
input.pdfS3Key ?? null,
|
||||||
|
input.pdfSha256 ?? null,
|
||||||
|
input.pdfSizeBytes ?? null,
|
||||||
input.generatedBy ?? null,
|
input.generatedBy ?? null,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -471,6 +477,11 @@ export interface ListGenerationsFilter {
|
|||||||
resellerId?: string;
|
resellerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getGenerationById(id: string): Promise<DocumentGeneration | null> {
|
||||||
|
const { rows } = await query<DocumentGeneration>("SELECT * FROM document_generations WHERE id = $1", [id]);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listGenerations(filter: ListGenerationsFilter = {}): Promise<DocumentGeneration[]> {
|
export async function listGenerations(filter: ListGenerationsFilter = {}): Promise<DocumentGeneration[]> {
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1096.0",
|
"@aws-sdk/client-s3": "^3.1096.0",
|
||||||
"@eden/config": "workspace:*",
|
"@eden/config": "workspace:*",
|
||||||
"nodemailer": "^9.1.1"
|
"nodemailer": "^9.1.1",
|
||||||
|
"playwright": "^1.62.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
|||||||
@@ -6,3 +6,4 @@
|
|||||||
*/
|
*/
|
||||||
export * from "./s3/client.js";
|
export * from "./s3/client.js";
|
||||||
export * from "./smtp/mailer.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -300,6 +300,9 @@ importers:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^9.1.1
|
specifier: ^9.1.1
|
||||||
version: 9.1.1
|
version: 9.1.1
|
||||||
|
playwright:
|
||||||
|
specifier: ^1.62.1
|
||||||
|
version: 1.62.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/nodemailer':
|
'@types/nodemailer':
|
||||||
specifier: ^8.0.1
|
specifier: ^8.0.1
|
||||||
@@ -2008,6 +2011,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
||||||
engines: {node: '>= 0.6'}
|
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:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
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==}
|
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||||
engines: {node: '>= 6'}
|
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:
|
postcss-import@15.1.0:
|
||||||
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@@ -4603,6 +4621,9 @@ snapshots:
|
|||||||
|
|
||||||
fresh@0.5.2: {}
|
fresh@0.5.2: {}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4951,6 +4972,14 @@ snapshots:
|
|||||||
|
|
||||||
pirates@4.0.7: {}
|
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):
|
postcss-import@15.1.0(postcss@8.5.26):
|
||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.5.26
|
postcss: 8.5.26
|
||||||
|
|||||||
Reference in New Issue
Block a user