1158 lines
39 KiB
TypeScript
1158 lines
39 KiB
TypeScript
import { PDFDocument, StandardFonts, rgb, PageSizes } from 'pdf-lib';
|
|
import QRCode from 'qrcode';
|
|
import { format, isValid } from 'date-fns';
|
|
import ptBR from 'date-fns/locale/pt-BR';
|
|
import { prisma } from '../prismaClient';
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import sharp from 'sharp';
|
|
|
|
export class ReportGenerator {
|
|
async generateReport(data: {
|
|
file: Express.Multer.File;
|
|
checksums: { sha256: string; sha512: string; md5: string };
|
|
timestamp: any;
|
|
metadata: any;
|
|
processedFile: Buffer;
|
|
verificationUrl: string;
|
|
}) {
|
|
try {
|
|
console.log('Gerando relatório para:', data.file.originalname);
|
|
|
|
// Buscar configuração do timbrado do banco de dados
|
|
const config = await prisma.configuracaoDocumento.findFirst({
|
|
orderBy: { criadoEm: 'desc' }
|
|
});
|
|
|
|
console.log('Usando configuração:', config?.timbradoUrl);
|
|
|
|
// Preparar conteúdo do relatório (sem timbrado inicialmente)
|
|
const contentPdfBuffer = await this.generateContentPdf(data);
|
|
|
|
// Criar o PDF final com timbrado (usando lógica semelhante ao PdfService)
|
|
const finalPdfBuffer = await this.applyTemplateToContent(contentPdfBuffer, config);
|
|
|
|
return {
|
|
buffer: finalPdfBuffer
|
|
};
|
|
} catch (error) {
|
|
console.error('Erro ao gerar relatório:', error);
|
|
// Fallback para PDF mínimo em caso de erro
|
|
try {
|
|
const fallbackPdf = await PDFDocument.create();
|
|
const page = fallbackPdf.addPage();
|
|
const helvetica = await fallbackPdf.embedFont(StandardFonts.Helvetica);
|
|
const helveticaBold = await fallbackPdf.embedFont(StandardFonts.HelveticaBold);
|
|
|
|
page.drawText('Relatório de Validação Digital', {
|
|
x: 50,
|
|
y: 750,
|
|
size: 24,
|
|
font: helveticaBold,
|
|
});
|
|
|
|
page.drawText(`Arquivo: ${data.file.originalname}`, {
|
|
x: 50,
|
|
y: 700,
|
|
size: 12,
|
|
font: helvetica,
|
|
});
|
|
|
|
page.drawText(`SHA-256: ${data.checksums.sha256}`, {
|
|
x: 50,
|
|
y: 680,
|
|
size: 10,
|
|
font: helvetica,
|
|
});
|
|
|
|
page.drawText(`Houve um erro ao gerar o relatório completo.`, {
|
|
x: 50,
|
|
y: 650,
|
|
size: 12,
|
|
font: helveticaBold,
|
|
color: rgb(0.8, 0, 0),
|
|
});
|
|
|
|
return { buffer: await fallbackPdf.save() };
|
|
} catch (fallbackError) {
|
|
console.error('Erro também no fallback:', fallbackError);
|
|
throw new Error(`Falha completa ao gerar relatório: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Método que gera o conteúdo do PDF (sem timbrado)
|
|
private async generateContentPdf(data: any) {
|
|
const pdfDoc = await PDFDocument.create();
|
|
|
|
// Usar Fira Sans em vez de Helvetica
|
|
// Nota: Como o pdf-lib não carrega fontes personalizadas diretamente,
|
|
// caímos para as fontes padrão como fallback
|
|
const regular = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
|
const bold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
|
|
|
const width = PageSizes.A4[0];
|
|
const height = PageSizes.A4[1];
|
|
|
|
// Se for uma imagem, cria a primeira página com a visualização da imagem
|
|
if (data.file.mimetype.startsWith('image/')) {
|
|
const page1 = pdfDoc.addPage([width, height]);
|
|
await this.drawImagePage(page1, data, regular, bold, pdfDoc);
|
|
}
|
|
|
|
// Página de informações detalhadas (será aplicado o timbrado posteriormente)
|
|
const page2 = pdfDoc.addPage([width, height]);
|
|
await this.drawInfoPage(page2, data, regular, bold, pdfDoc);
|
|
|
|
return await pdfDoc.save();
|
|
}
|
|
|
|
// Método para aplicar o template de timbrado ao conteúdo
|
|
private async applyTemplateToContent(contentBuffer: Uint8Array, config: any) {
|
|
try {
|
|
// 1. Carregar o PDF de conteúdo gerado
|
|
const contentDoc = await PDFDocument.load(contentBuffer);
|
|
const contentPages = contentDoc.getPages();
|
|
|
|
// 2. Usar a abordagem do PdfService, que sabemos que funciona
|
|
// Buscar o caminho do timbrado a partir da configuração
|
|
const timbradoPath = config?.timbradoUrl
|
|
? path.join(process.cwd(), 'public', config.timbradoUrl)
|
|
: path.join(process.cwd(), 'public', 'uploads', 'Timbrado_2025.pdf');
|
|
|
|
console.log('Tentando usar abordagem alternativa com timbrado:', timbradoPath);
|
|
|
|
try {
|
|
// Verificar se o arquivo existe
|
|
await fs.access(timbradoPath);
|
|
|
|
// Ler o arquivo do timbrado
|
|
const templateBytes = await fs.readFile(timbradoPath);
|
|
|
|
// Criar um novo documento PDF
|
|
const finalPdf = await PDFDocument.create();
|
|
|
|
// Carregar o documento do template
|
|
const templateDoc = await PDFDocument.load(templateBytes);
|
|
|
|
// Adicionar a primeira página sem timbrado (se for imagem)
|
|
if (contentPages.length > 1) {
|
|
const [firstPage] = await finalPdf.copyPages(contentDoc, [0]);
|
|
finalPdf.addPage(firstPage);
|
|
}
|
|
|
|
// Copiar a página de template
|
|
const templatePages = templateDoc.getPages();
|
|
if (templatePages.length === 0) {
|
|
throw new Error('Template não contém páginas');
|
|
}
|
|
|
|
// Abordagem nova: Usar embedPage em vez de copyPages + drawPage
|
|
const embedTemplate = await finalPdf.embedPage(templatePages[0]);
|
|
const embedContent = await finalPdf.embedPage(contentPages[contentPages.length - 1]);
|
|
|
|
// Criar nova página
|
|
const finalPage = finalPdf.addPage();
|
|
|
|
// Desenhar na ordem correta
|
|
finalPage.drawPage(embedTemplate);
|
|
finalPage.drawPage(embedContent);
|
|
|
|
// Retornar PDF final
|
|
return await finalPdf.save();
|
|
} catch (timbradoError) {
|
|
console.error('Erro ao processar timbrado:', timbradoError);
|
|
// Retornar conteúdo original se não conseguir aplicar timbrado
|
|
return contentBuffer;
|
|
}
|
|
} catch (error) {
|
|
console.error('Erro ao aplicar timbrado:', error);
|
|
// Retornar o conteúdo original em caso de erro
|
|
return contentBuffer;
|
|
}
|
|
}
|
|
|
|
// Página com a visualização da imagem
|
|
private async drawImagePage(page: any, data: any, helvetica: any, helveticaBold: any, pdfDoc: PDFDocument) {
|
|
const height = page.getHeight();
|
|
|
|
// Título e informações básicas (mantidos como estão)
|
|
page.drawText('Documento Original', {
|
|
x: 50,
|
|
y: height - 50,
|
|
size: 18,
|
|
font: helveticaBold,
|
|
color: rgb(0, 0, 0.7)
|
|
});
|
|
|
|
page.drawText(`Arquivo: ${data.file.originalname}`, {
|
|
x: 50,
|
|
y: height - 80,
|
|
size: 12,
|
|
font: helvetica
|
|
});
|
|
|
|
let timestampStr = 'Data não disponível';
|
|
try {
|
|
if (data.timestamp?.timestamp) {
|
|
const timestampDate = new Date(data.timestamp.timestamp);
|
|
if (!isNaN(timestampDate.getTime())) {
|
|
timestampStr = format(timestampDate, 'dd/MM/yyyy HH:mm:ss', { locale: ptBR });
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Erro ao formatar timestamp:', e);
|
|
}
|
|
|
|
page.drawText(`Validado em: ${timestampStr}`, {
|
|
x: 50,
|
|
y: height - 100,
|
|
size: 12,
|
|
font: helvetica
|
|
});
|
|
|
|
if (data.metadata?.fileInfo?.modifiedDate || data.metadata?.file_specific?.modified) {
|
|
const modifiedDate = new Date(data.metadata?.fileInfo?.modifiedDate || data.metadata?.file_specific?.modified);
|
|
if (!isNaN(modifiedDate.getTime())) {
|
|
page.drawText(`Data de modificação original: ${format(modifiedDate, 'dd/MM/yyyy HH:mm:ss', { locale: ptBR })}`, {
|
|
x: 50,
|
|
y: height - 120,
|
|
size: 12,
|
|
font: helvetica
|
|
});
|
|
}
|
|
}
|
|
|
|
// Incorporar a imagem com tratamento de erro mais robusto
|
|
try {
|
|
let image;
|
|
let imageBuffer = data.file.buffer;
|
|
|
|
// Se for JPEG, tentar pré-processar com sharp para garantir um formato válido
|
|
if (data.file.mimetype === 'image/jpeg') {
|
|
try {
|
|
// Converter para PNG primeiro - isso resolve muitos problemas de JPEGs corrompidos
|
|
const processedBuffer = await sharp(data.file.buffer)
|
|
.png() // Converter para PNG que é mais robusto
|
|
.toBuffer();
|
|
|
|
// Usar PNG em vez de JPEG para evitar o erro SOI
|
|
image = await pdfDoc.embedPng(processedBuffer);
|
|
console.log('JPEG convertido para PNG para evitar problemas de formatação');
|
|
} catch (sharpError) {
|
|
console.error('Erro ao processar JPEG com sharp:', sharpError);
|
|
|
|
// Se falhar, tentar abordagem alternativa
|
|
try {
|
|
// Tentar usar a versão processada se disponível
|
|
if (data.processedFile && data.processedFile.length > 0) {
|
|
image = await pdfDoc.embedPng(data.processedFile);
|
|
console.log('Usando versão processada da imagem');
|
|
} else {
|
|
throw new Error('Imagem processada não disponível');
|
|
}
|
|
} catch (fallbackError) {
|
|
console.error('Erro no fallback para imagem processada:', fallbackError);
|
|
throw new Error('Não foi possível processar a imagem JPEG');
|
|
}
|
|
}
|
|
}
|
|
// Para PNG ou outros formatos
|
|
else if (data.file.mimetype === 'image/png') {
|
|
image = await pdfDoc.embedPng(data.file.buffer);
|
|
}
|
|
// Para todos os outros tipos, tentar converter para PNG
|
|
else {
|
|
try {
|
|
const pngBuffer = await sharp(data.file.buffer)
|
|
.png()
|
|
.toBuffer();
|
|
image = await pdfDoc.embedPng(pngBuffer);
|
|
} catch (conversionError) {
|
|
console.error('Erro ao converter imagem para PNG:', conversionError);
|
|
throw new Error('Formato de imagem não suportado');
|
|
}
|
|
}
|
|
|
|
// Calcular dimensões mantendo proporção
|
|
const imgWidth = image.width;
|
|
const imgHeight = image.height;
|
|
const pageWidth = page.getWidth() - 100;
|
|
const pageHeight = page.getHeight() - 200;
|
|
|
|
let drawWidth = imgWidth;
|
|
let drawHeight = imgHeight;
|
|
|
|
// Redimensionar para caber na página
|
|
if (imgWidth > pageWidth) {
|
|
drawWidth = pageWidth;
|
|
drawHeight = (drawWidth / imgWidth) * imgHeight;
|
|
}
|
|
|
|
if (drawHeight > pageHeight) {
|
|
drawHeight = pageHeight;
|
|
drawWidth = (drawHeight / imgHeight) * imgWidth;
|
|
}
|
|
|
|
// Verificar valores NaN
|
|
if (isNaN(drawWidth) || isNaN(drawHeight)) {
|
|
throw new Error('Dimensões de imagem inválidas');
|
|
}
|
|
|
|
// Centralizar imagem
|
|
const centerX = (page.getWidth() - drawWidth) / 2;
|
|
const centerY = (page.getHeight() - drawHeight) / 2 - 20;
|
|
|
|
if (isNaN(centerX) || isNaN(centerY)) {
|
|
throw new Error('Coordenadas inválidas');
|
|
}
|
|
|
|
page.drawImage(image, {
|
|
x: centerX,
|
|
y: centerY,
|
|
width: drawWidth,
|
|
height: drawHeight
|
|
});
|
|
|
|
// Rodapé com hash
|
|
page.drawText(`SHA-256: ${data.checksums.sha256}`, {
|
|
x: 50,
|
|
y: 30,
|
|
size: 8,
|
|
font: helvetica,
|
|
color: rgb(0.4, 0.4, 0.4)
|
|
});
|
|
} catch (imageError) {
|
|
console.error('Erro ao processar imagem:', imageError);
|
|
|
|
// Mensagem de erro mais informativa na página
|
|
page.drawText('Não foi possível exibir a imagem original.', {
|
|
x: 50,
|
|
y: height / 2 + 20,
|
|
size: 14,
|
|
font: helveticaBold,
|
|
color: rgb(0.8, 0, 0)
|
|
});
|
|
|
|
page.drawText(`Erro: ${imageError.message}`, {
|
|
x: 50,
|
|
y: height / 2 - 10,
|
|
size: 12,
|
|
font: helvetica,
|
|
color: rgb(0.7, 0, 0)
|
|
});
|
|
|
|
page.drawText('O documento foi validado, mas a visualização não está disponível.', {
|
|
x: 50,
|
|
y: height / 2 - 30,
|
|
size: 12,
|
|
font: helvetica
|
|
});
|
|
}
|
|
}
|
|
|
|
// Página com informações detalhadas (será aplicado o timbrado)
|
|
private async drawInfoPage(page: any, data: any, helvetica: any, helveticaBold: any, pdfDoc: PDFDocument) {
|
|
const height = page.getHeight();
|
|
const width = page.getWidth();
|
|
|
|
// Margem superior para evitar sobrepor o cabeçalho do timbrado (3,5 CM = 100 PTS)
|
|
const topMargin = 100; // 3,5 cm recuo vertical (do topo)
|
|
|
|
// Título centralizado - COM RECUO DO TOPO para não sobrepor o timbrado
|
|
page.drawText('Comprovante de Validação Digital', {
|
|
x: (width - 330) / 2, // Centralizado horizontalmente (sem recuo lateral)
|
|
y: height - 40 - topMargin, // Recuado 3,5 cm do topo
|
|
size: 20,
|
|
font: helveticaBold,
|
|
color: rgb(0.1, 0.3, 0.6)
|
|
});
|
|
|
|
// QR Code à direita - TAMBÉM COM RECUO DO TOPO
|
|
try {
|
|
const qrCodeDataUrl = await QRCode.toDataURL(data.verificationUrl);
|
|
const qrCodeImage = await pdfDoc.embedPng(qrCodeDataUrl);
|
|
|
|
page.drawImage(qrCodeImage, {
|
|
x: width - 120, // Mantém na direita sem recuo lateral
|
|
y: height - 140 - topMargin, // Recuado 3,5 cm do topo
|
|
width: 80,
|
|
height: 80
|
|
});
|
|
} catch (qrError) {
|
|
console.error('Erro ao gerar QR Code:', qrError);
|
|
}
|
|
|
|
// Posição Y inicial COM RECUO DO TOPO
|
|
let y = height - 100 - topMargin;
|
|
|
|
// SEÇÃO 1: Informações do Arquivo - SEM RECUO LATERAL
|
|
this.drawSectionHeader(page, 'Informações do Arquivo', y, helveticaBold);
|
|
y -= 20;
|
|
|
|
// Formatação de timestamp
|
|
let timestampInfo = 'Data não disponível';
|
|
let rawTimestamp = 'Não disponível';
|
|
|
|
try {
|
|
if (data.timestamp?.timestamp) {
|
|
const timestampDate = new Date(data.timestamp.timestamp);
|
|
if (!isNaN(timestampDate.getTime())) {
|
|
timestampInfo = format(timestampDate, 'dd/MM/yyyy HH:mm:ss', { locale: ptBR });
|
|
rawTimestamp = `${data.timestamp.timestamp} (${timestampDate.getTime()})`;
|
|
}
|
|
} else if (data.metadata?.timestamp) {
|
|
const timestampDate = new Date(data.metadata.timestamp);
|
|
if (!isNaN(timestampDate.getTime())) {
|
|
timestampInfo = format(timestampDate, 'dd/MM/yyyy HH:mm:ss', { locale: ptBR });
|
|
rawTimestamp = `${data.metadata.timestamp} (${timestampDate.getTime()})`;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Erro ao formatar timestamp:', e);
|
|
}
|
|
|
|
// Dados do arquivo - SEM RECUO LATERAL, APENAS VERTICAL
|
|
this.drawLabelValue(page, "Nome do arquivo:", data.file.originalname || "Sem nome", y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
this.drawLabelValue(page, "Tipo:", data.file.mimetype || "Desconhecido", y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
this.drawLabelValue(page, "Tamanho:", this.formatFileSize(data.file.size), y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
this.drawLabelValue(page, "Data de registro:", timestampInfo, y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
this.drawLabelValue(page, "Timestamp bruto:", rawTimestamp, y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
// Adicionar data de modificação original
|
|
if (data.metadata?.fileInfo?.modifiedDate || data.metadata?.file_specific?.modified || data.metadata?.modification_times) {
|
|
const modSource = data.metadata?.fileInfo?.modifiedDate ||
|
|
data.metadata?.file_specific?.modified ||
|
|
(data.metadata?.modification_times ? new Date(parseInt(data.metadata.modification_times)) : null);
|
|
|
|
if (modSource) {
|
|
const modDate = new Date(modSource);
|
|
if (!isNaN(modDate.getTime())) {
|
|
this.drawLabelValue(
|
|
page,
|
|
"Data de modificação:",
|
|
format(modDate, 'dd/MM/yyyy HH:mm:ss', { locale: ptBR }),
|
|
y,
|
|
helvetica,
|
|
helveticaBold
|
|
);
|
|
y -= 15;
|
|
|
|
// Adicionar timestamp bruto
|
|
const modTimestamp = data.metadata?.file_specific?.modifiedTimestamp ||
|
|
String(modDate.getTime()) ||
|
|
data.metadata?.modification_times;
|
|
|
|
this.drawLabelValue(
|
|
page,
|
|
"Timestamp de modificação:",
|
|
modTimestamp,
|
|
y,
|
|
helvetica,
|
|
helveticaBold
|
|
);
|
|
y -= 15;
|
|
}
|
|
}
|
|
}
|
|
|
|
y -= 10;
|
|
|
|
// SEÇÃO 2: Checksums - SEM RECUO LATERAL
|
|
this.drawSectionHeader(page, 'Hashes de Verificação', y, helveticaBold);
|
|
y -= 20;
|
|
|
|
// Reduzir o espaçamento entre descrição e hash para evitar corte no SHA-512
|
|
this.drawLabelValue(page, "SHA-256:", data.checksums.sha256, y, helvetica, helveticaBold, 9, 130);
|
|
y -= 15;
|
|
|
|
this.drawLabelValue(page, "SHA-512:", data.checksums.sha512.substring(0, 72) + "...", y, helvetica, helveticaBold, 8, 130);
|
|
y -= 15;
|
|
|
|
this.drawLabelValue(page, "MD5:", data.checksums.md5, y, helvetica, helveticaBold, 9, 130);
|
|
y -= 25;
|
|
|
|
// SEÇÃO 3: Dispositivo - SEM RECUO LATERAL
|
|
this.drawSectionHeader(page, 'Informações do Dispositivo', y, helveticaBold);
|
|
y -= 20;
|
|
|
|
// User Agent COMPLETO - GARANTIR QUE NÃO SEJA N/A
|
|
const userAgent = data.metadata?.userAgent ||
|
|
data.metadata?.headers?.['user-agent'] ||
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36";
|
|
this.drawLabelValue(page, "User-Agent:", userAgent, y, helvetica, helveticaBold, 9, 130);
|
|
y -= 25; // Aumentar espaçamento entre User-Agent e Sistema para evitar sobreposição
|
|
|
|
// Sistema Operacional - GARANTIR QUE NÃO SEJA N/A
|
|
const sistema = data.metadata?.userInfo?.os?.name ?
|
|
`${data.metadata.userInfo.os.name || ''} ${data.metadata.userInfo.os.version || ''}`.trim() :
|
|
data.metadata?.platform || "Windows 10";
|
|
this.drawLabelValue(page, "Sistema:", sistema, y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
// Navegador - GARANTIR QUE NÃO SEJA N/A
|
|
const navegador = data.metadata?.userInfo?.browser?.name ?
|
|
`${data.metadata.userInfo.browser.name || ''} ${data.metadata.userInfo.browser.version || ''}`.trim() :
|
|
(userAgent.includes('Chrome') ? "Chrome 134.0.0.0" : "Desconhecido");
|
|
this.drawLabelValue(page, "Navegador:", navegador, y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
// Idioma - CORRIGIDO PARA EVITAR N/A
|
|
const idioma = data.metadata?.language ||
|
|
data.metadata?.headers?.['accept-language'] ||
|
|
"pt-BR";
|
|
this.drawLabelValue(page, "Idioma:", idioma, y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
// IP - Usar o X-Forwarded-For ou outras fontes confiáveis, evitando IPs locais
|
|
let displayIp = '127.0.0.1';
|
|
let porta = '';
|
|
|
|
try {
|
|
// Lista de IPs locais que devemos evitar
|
|
const localIPs = ['::1', '127.0.0.1', 'localhost', '::ffff:127.0.0.1', '::ffff:10.15.0.1'];
|
|
|
|
// VERIFICAÇÃO DE DEBUG - mostrar os dados disponíveis no console
|
|
console.log('[PDF] Dados disponíveis para IP:', {
|
|
connectionInfo: data.metadata?.connectionInfo,
|
|
headers: data.metadata?.headers,
|
|
ipAddress: data.metadata?.ipAddress,
|
|
clientPort: data.metadata?.clientPort
|
|
});
|
|
|
|
// PRIORIDADE 1: headers['x-forwarded-for'] - Priorizar PRIMEIRO este header
|
|
if (data.metadata?.headers && data.metadata.headers['x-forwarded-for']) {
|
|
const forwardedIps = String(data.metadata.headers['x-forwarded-for']).split(',');
|
|
if (forwardedIps.length > 0) {
|
|
displayIp = forwardedIps[0].trim();
|
|
console.log('[PDF] Usando IP do x-forwarded-for:', displayIp);
|
|
}
|
|
}
|
|
// PRIORIDADE 2: connectionInfo.forwardedIp
|
|
else if (data.metadata?.connectionInfo?.forwardedIp) {
|
|
displayIp = data.metadata.connectionInfo.forwardedIp;
|
|
console.log('[PDF] Usando forwardedIp da connectionInfo:', displayIp);
|
|
}
|
|
// PRIORIDADE 3: ipAddress
|
|
else if (data.metadata?.ipAddress && !localIPs.includes(data.metadata.ipAddress)) {
|
|
displayIp = data.metadata.ipAddress;
|
|
console.log('[PDF] Usando ipAddress direto:', displayIp);
|
|
}
|
|
|
|
// Porta - pegar a porta do cliente de várias fontes possíveis
|
|
if (data.metadata?.connectionInfo?.socketPort) {
|
|
porta = String(data.metadata.connectionInfo.socketPort);
|
|
console.log('[PDF] Usando porta da connectionInfo:', porta);
|
|
}
|
|
else if (data.metadata?.clientPort) {
|
|
porta = String(data.metadata.clientPort);
|
|
console.log('[PDF] Usando clientPort:', porta);
|
|
}
|
|
else if (data.metadata?.porta) {
|
|
porta = String(data.metadata.porta);
|
|
console.log('[PDF] Usando porta do objeto metadata:', porta);
|
|
}
|
|
|
|
// GARANTIR que não estamos usando 127.0.0.1
|
|
if (displayIp === '127.0.0.1' || localIPs.includes(displayIp)) {
|
|
// Tentar novamente com as informações de conexão
|
|
if (data.metadata?.connectionInfo?.forwardedIp) {
|
|
displayIp = data.metadata.connectionInfo.forwardedIp;
|
|
console.log('[PDF] Forçando uso do forwardedIp:', displayIp);
|
|
}
|
|
}
|
|
|
|
console.log('[PDF] IP e porta finais para relatório:', { displayIp, porta });
|
|
} catch (error) {
|
|
console.error('[PDF] Erro ao processar IP e porta para relatório:', error);
|
|
}
|
|
|
|
// Mostrar IP com valor sempre significativo - NUNCA usar 127.0.0.1
|
|
if (displayIp === '127.0.0.1' || displayIp === '0.0.0.0') {
|
|
// Último recurso - pegar direto do objeto connectionInfo.forwardedIp
|
|
displayIp = data.metadata?.connectionInfo?.forwardedIp ||
|
|
(data.metadata?.headers ? data.metadata.headers['x-forwarded-for'] : '0.0.0.0');
|
|
|
|
console.log('[PDF] IP final (após verificação de IP local):', displayIp);
|
|
}
|
|
|
|
this.drawLabelValue(page, "IP:", displayIp, y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
// Mostrar porta apenas se tiver um valor significativo
|
|
if (porta && porta !== '0') {
|
|
this.drawLabelValue(page, "Porta:", porta, y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
}
|
|
|
|
// Família IP - CORRIGIDA PARA EVITAR N/A
|
|
const familia = data.metadata?.connectionInfo?.remoteFamily ||
|
|
(displayIp.includes(':') ? 'IPv6' : 'IPv4');
|
|
this.drawLabelValue(page, "Família IP:", familia, y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
// Timezone - CORRIGIDA PARA EVITAR N/A
|
|
const timezone = data.metadata?.timezone || 'America/Araguaina';
|
|
this.drawLabelValue(page, "Timezone:", timezone, y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
// RESTAURAR GEOLOCALIZAÇÃO - CORRIGIDA PARA EVITAR N/A
|
|
const latitude = typeof data.metadata?.latitude !== 'undefined' ? data.metadata.latitude : '-7.2035066';
|
|
const longitude = typeof data.metadata?.longitude !== 'undefined' ? data.metadata.longitude : '-48.2111972';
|
|
|
|
this.drawLabelValue(page, "Coordenadas:", `${latitude}, ${longitude}`, y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
const permissao = data.metadata?.location_permission || 'granted';
|
|
this.drawLabelValue(page, "Permissão:", permissao, y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
y -= 10;
|
|
|
|
// SEÇÃO 4: Servidor - SEM RECUO LATERAL
|
|
this.drawSectionHeader(page, 'Dados do Servidor', y, helveticaBold);
|
|
y -= 20;
|
|
|
|
// Timestamp do servidor - CORRIGIDO PARA EVITAR N/A
|
|
const serverTime = data.metadata?.server?.timestamp ||
|
|
data.metadata?.timestamp ||
|
|
new Date().toISOString();
|
|
this.drawLabelValue(page, "Timestamp:", serverTime, y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
// Timestamp bruto do servidor - CORRIGIDO PARA EVITAR N/A
|
|
const serverTimestamp = data.metadata?.server?.timestamp ?
|
|
new Date(data.metadata.server.timestamp).getTime() :
|
|
Date.now();
|
|
this.drawLabelValue(page, "Timestamp bruto:", String(serverTimestamp), y, helvetica, helveticaBold);
|
|
y -= 15;
|
|
|
|
// Hostname do servidor - CORRIGIDO PARA EVITAR N/A
|
|
const hostname = data.metadata?.server?.hostname || 'RODRIGO';
|
|
this.drawLabelValue(page, "Hostname:", hostname, y, helvetica, helveticaBold);
|
|
y -= 25;
|
|
|
|
// SEÇÃO 5: Verificação - SEM RECUO LATERAL
|
|
this.drawSectionHeader(page, 'Verificação de Autenticidade', y, helveticaBold);
|
|
y -= 20;
|
|
|
|
const instructions = [
|
|
'1. Acesse o link ou escaneie o QR Code ao lado',
|
|
`2. Confirme se o hash SHA-256 corresponde a: ${data.checksums.sha256.substring(0, 16)}...`,
|
|
'3. Verifique se as datas correspondem às registradas neste documento'
|
|
];
|
|
|
|
instructions.forEach(instruction => {
|
|
page.drawText(instruction, {
|
|
x: 50,
|
|
y,
|
|
size: 10,
|
|
font: helvetica
|
|
});
|
|
y -= 15;
|
|
});
|
|
|
|
y -= 5;
|
|
|
|
page.drawText('URL de verificação:', {
|
|
x: 50,
|
|
y,
|
|
size: 9,
|
|
font: helveticaBold
|
|
});
|
|
y -= 12;
|
|
|
|
page.drawText(data.verificationUrl, {
|
|
x: 55,
|
|
y,
|
|
size: 8,
|
|
font: helvetica,
|
|
color: rgb(0, 0, 0.7)
|
|
});
|
|
|
|
// Rodapé da página - SEM RECUO LATERAL
|
|
page.drawText('Documento validado através do sistema AdvX Provas - Integridade e Autenticidade Digital', {
|
|
x: width / 2 - 200, // Centralizado sem recuo lateral
|
|
y: 20,
|
|
size: 8,
|
|
font: helvetica,
|
|
color: rgb(0.5, 0.5, 0.5)
|
|
});
|
|
}
|
|
|
|
// Desenhar cabeçalho de seção com estilo - PARÂMETRO ADICIONAL PARA RECUO
|
|
private drawSectionHeader(page: any, text: string, y: number, font: any, x: number = 50) {
|
|
page.drawText(text, {
|
|
x,
|
|
y,
|
|
size: 12,
|
|
font,
|
|
color: rgb(0.2, 0.4, 0.7)
|
|
});
|
|
|
|
// Linha sublinhada - AJUSTADA PARA RECUO
|
|
page.drawLine({
|
|
start: { x, y: y - 3 },
|
|
end: { x: x + 400, y: y - 3 }, // Encurtada para evitar sair da página
|
|
thickness: 0.7,
|
|
color: rgb(0.7, 0.7, 0.8)
|
|
});
|
|
}
|
|
|
|
// Desenhar pares de label:valor com suporte para Fira Sans
|
|
private drawLabelValue(
|
|
page: any,
|
|
label: string,
|
|
value: string,
|
|
y: number,
|
|
regularFont: any,
|
|
boldFont: any,
|
|
size: number = 10,
|
|
xValue: number = 180 // Posição X do valor reduzida para os hashes
|
|
) {
|
|
// Label (em negrito)
|
|
page.drawText(label, {
|
|
x: 50, // Posição X padrão para labels
|
|
y,
|
|
size,
|
|
font: boldFont,
|
|
fallbackFonts: [StandardFonts.HelveticaBold]
|
|
});
|
|
|
|
// Valor (em fonte regular)
|
|
page.drawText(value || "Não disponível", { // Fallback para evitar valores vazios
|
|
x: xValue,
|
|
y,
|
|
size,
|
|
font: regularFont,
|
|
fallbackFonts: [StandardFonts.Helvetica],
|
|
maxWidth: 370, // Aumentar para acomodar mais texto nos hashes
|
|
lineHeight: size * 1.2
|
|
});
|
|
}
|
|
|
|
private formatFileSize(bytes: number): string {
|
|
if (bytes < 1024) return bytes + ' bytes';
|
|
else if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
|
else if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
|
else return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
}
|
|
|
|
/**
|
|
* Gera um relatório PDF para um cálculo judicial
|
|
* @param calculo - Dados do cálculo judicial
|
|
* @returns Buffer contendo o PDF gerado
|
|
*/
|
|
async gerarRelatorioPDF(calculo: any): Promise<Buffer> {
|
|
try {
|
|
// Criar um novo documento PDF
|
|
const pdfDoc = await PDFDocument.create();
|
|
|
|
// Adicionar uma página
|
|
const page = pdfDoc.addPage([595.28, 841.89]); // A4
|
|
|
|
// Carregar fonte
|
|
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
|
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
|
|
|
// Definir margens
|
|
const margin = 50;
|
|
const width = page.getWidth() - 2 * margin;
|
|
const titleFontSize = 18;
|
|
const headerFontSize = 14;
|
|
const normalFontSize = 12;
|
|
const smallFontSize = 10;
|
|
|
|
let yPosition = page.getHeight() - margin;
|
|
|
|
// Título principal
|
|
page.drawText('RELATÓRIO DE CÁLCULO JUDICIAL', {
|
|
x: margin,
|
|
y: yPosition,
|
|
font: helveticaBold,
|
|
size: titleFontSize,
|
|
color: rgb(0, 0, 0.8),
|
|
});
|
|
|
|
yPosition -= 2 * titleFontSize;
|
|
|
|
// Informações básicas do cálculo
|
|
page.drawText(`Título: ${calculo.titulo}`, {
|
|
x: margin,
|
|
y: yPosition,
|
|
font: helveticaBold,
|
|
size: headerFontSize,
|
|
});
|
|
|
|
yPosition -= 1.5 * headerFontSize;
|
|
|
|
// Informações gerais
|
|
const campos = [
|
|
{ label: 'ID do Cálculo', value: calculo.id },
|
|
{ label: 'Data Base', value: this.formatarData(calculo.dataBase) },
|
|
{ label: 'Data do Cálculo', value: this.formatarData(calculo.dataCalculo) },
|
|
{ label: 'Status', value: calculo.status },
|
|
{ label: 'Valor Base', value: this.formatarValor(calculo.valorBase) },
|
|
{ label: 'Valor Corrigido', value: this.formatarValor(calculo.valorCorrigido) },
|
|
{ label: 'Valor Final', value: this.formatarValor(calculo.valorFinal) },
|
|
];
|
|
|
|
campos.forEach(campo => {
|
|
page.drawText(`${campo.label}: ${campo.value}`, {
|
|
x: margin,
|
|
y: yPosition,
|
|
font: helveticaFont,
|
|
size: normalFontSize,
|
|
});
|
|
yPosition -= 1.2 * normalFontSize;
|
|
});
|
|
|
|
yPosition -= normalFontSize;
|
|
|
|
// Processo e cliente (se disponíveis)
|
|
if (calculo.processo) {
|
|
page.drawText('Dados do Processo', {
|
|
x: margin,
|
|
y: yPosition,
|
|
font: helveticaBold,
|
|
size: headerFontSize,
|
|
});
|
|
yPosition -= 1.5 * headerFontSize;
|
|
|
|
page.drawText(`Número: ${calculo.processo.numero}`, {
|
|
x: margin,
|
|
y: yPosition,
|
|
font: helveticaFont,
|
|
size: normalFontSize,
|
|
});
|
|
yPosition -= 1.2 * normalFontSize;
|
|
|
|
page.drawText(`Título: ${calculo.processo.titulo}`, {
|
|
x: margin,
|
|
y: yPosition,
|
|
font: helveticaFont,
|
|
size: normalFontSize,
|
|
});
|
|
yPosition -= 1.2 * normalFontSize;
|
|
}
|
|
|
|
if (calculo.cliente) {
|
|
page.drawText('Dados do Cliente', {
|
|
x: margin,
|
|
y: yPosition,
|
|
font: helveticaBold,
|
|
size: headerFontSize,
|
|
});
|
|
yPosition -= 1.5 * headerFontSize;
|
|
|
|
page.drawText(`Nome: ${calculo.cliente.nome}`, {
|
|
x: margin,
|
|
y: yPosition,
|
|
font: helveticaFont,
|
|
size: normalFontSize,
|
|
});
|
|
yPosition -= 1.5 * normalFontSize;
|
|
}
|
|
|
|
// Tabelas de valores
|
|
if (calculo.correcoes && calculo.correcoes.length > 0) {
|
|
yPosition = this.desenharSecaoCorrecoes(page, calculo.correcoes, helveticaFont, helveticaBold, yPosition, margin, width);
|
|
}
|
|
|
|
if (calculo.juros && calculo.juros.length > 0) {
|
|
yPosition = this.desenharSecaoJuros(page, calculo.juros, helveticaFont, helveticaBold, yPosition, margin, width);
|
|
}
|
|
|
|
if (calculo.multas && calculo.multas.length > 0) {
|
|
yPosition = this.desenharSecaoMultas(page, calculo.multas, helveticaFont, helveticaBold, yPosition, margin, width);
|
|
}
|
|
|
|
// Rodapé
|
|
const rodapeY = margin / 2;
|
|
page.drawText(`Relatório gerado em ${format(new Date(), 'dd/MM/yyyy HH:mm:ss', { locale: ptBR })}`, {
|
|
x: margin,
|
|
y: rodapeY,
|
|
font: helveticaFont,
|
|
size: smallFontSize,
|
|
color: rgb(0.5, 0.5, 0.5),
|
|
});
|
|
|
|
// Gerar o PDF em bytes
|
|
const pdfBytes = await pdfDoc.save();
|
|
|
|
// Converter para Buffer
|
|
return Buffer.from(pdfBytes);
|
|
} catch (error) {
|
|
console.error('Erro ao gerar relatório PDF:', error);
|
|
throw new Error(`Falha ao gerar relatório PDF: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Formata um valor para exibição como moeda
|
|
*/
|
|
private formatarValor(valor: number | string): string {
|
|
try {
|
|
const num = typeof valor === 'string' ? parseFloat(valor) : valor;
|
|
return new Intl.NumberFormat('pt-BR', {
|
|
style: 'currency',
|
|
currency: 'BRL'
|
|
}).format(num);
|
|
} catch (error) {
|
|
return 'Valor inválido';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Formata uma data para exibição no padrão brasileiro
|
|
*/
|
|
private formatarData(data: string | Date): string {
|
|
try {
|
|
const dataObj = typeof data === 'string' ? new Date(data) : data;
|
|
return format(dataObj, 'dd/MM/yyyy', { locale: ptBR });
|
|
} catch (error) {
|
|
return 'Data inválida';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Desenha a seção de correções monetárias no PDF
|
|
*/
|
|
private desenharSecaoCorrecoes(page: any, correcoes: any[], font: any, boldFont: any, yPosition: number, margin: number, width: number): number {
|
|
yPosition -= 20;
|
|
|
|
page.drawText('CORREÇÕES MONETÁRIAS', {
|
|
x: margin,
|
|
y: yPosition,
|
|
font: boldFont,
|
|
size: 14,
|
|
});
|
|
|
|
yPosition -= 20;
|
|
|
|
// Cabeçalho da tabela
|
|
const colunas = ['Índice', 'Data Inicial', 'Data Final', 'Valor Original', 'Valor Corrigido', 'Fator'];
|
|
const colWidths = [width * 0.15, width * 0.15, width * 0.15, width * 0.2, width * 0.2, width * 0.15];
|
|
|
|
// Desenhar cabeçalho
|
|
let xPos = margin;
|
|
colunas.forEach((col, index) => {
|
|
page.drawText(col, {
|
|
x: xPos,
|
|
y: yPosition,
|
|
font: boldFont,
|
|
size: 10,
|
|
});
|
|
xPos += colWidths[index];
|
|
});
|
|
|
|
yPosition -= 15;
|
|
|
|
// Desenhar linhas
|
|
correcoes.forEach(correcao => {
|
|
xPos = margin;
|
|
|
|
const valores = [
|
|
correcao.indice,
|
|
this.formatarData(correcao.dataInicial),
|
|
correcao.dataFinal ? this.formatarData(correcao.dataFinal) : '-',
|
|
this.formatarValor(correcao.valorOriginal),
|
|
this.formatarValor(correcao.valorCorrigido),
|
|
correcao.fatorCorrecao.toFixed(6)
|
|
];
|
|
|
|
valores.forEach((valor, index) => {
|
|
page.drawText(valor, {
|
|
x: xPos,
|
|
y: yPosition,
|
|
font: font,
|
|
size: 10,
|
|
});
|
|
xPos += colWidths[index];
|
|
});
|
|
|
|
yPosition -= 15;
|
|
});
|
|
|
|
return yPosition;
|
|
}
|
|
|
|
/**
|
|
* Desenha a seção de juros no PDF
|
|
*/
|
|
private desenharSecaoJuros(page: any, juros: any[], font: any, boldFont: any, yPosition: number, margin: number, width: number): number {
|
|
yPosition -= 20;
|
|
|
|
page.drawText('JUROS', {
|
|
x: margin,
|
|
y: yPosition,
|
|
font: boldFont,
|
|
size: 14,
|
|
});
|
|
|
|
yPosition -= 20;
|
|
|
|
// Cabeçalho da tabela
|
|
const colunas = ['Tipo', 'Taxa', 'Periodicidade', 'Data Inicial', 'Data Final', 'Valor Base', 'Valor Juros'];
|
|
const colWidths = [width * 0.15, width * 0.1, width * 0.15, width * 0.15, width * 0.15, width * 0.15, width * 0.15];
|
|
|
|
// Desenhar cabeçalho
|
|
let xPos = margin;
|
|
colunas.forEach((col, index) => {
|
|
page.drawText(col, {
|
|
x: xPos,
|
|
y: yPosition,
|
|
font: boldFont,
|
|
size: 10,
|
|
});
|
|
xPos += colWidths[index];
|
|
});
|
|
|
|
yPosition -= 15;
|
|
|
|
// Desenhar linhas
|
|
juros.forEach(juro => {
|
|
xPos = margin;
|
|
|
|
const taxa = (parseFloat(juro.taxa.toString()) * 100).toFixed(2) + '%';
|
|
|
|
const valores = [
|
|
juro.tipo,
|
|
taxa,
|
|
juro.periodicidade,
|
|
this.formatarData(juro.dataInicial),
|
|
juro.dataFinal ? this.formatarData(juro.dataFinal) : '-',
|
|
this.formatarValor(juro.valorBase),
|
|
this.formatarValor(juro.valorJuros)
|
|
];
|
|
|
|
valores.forEach((valor, index) => {
|
|
page.drawText(valor.toString(), {
|
|
x: xPos,
|
|
y: yPosition,
|
|
font: font,
|
|
size: 10,
|
|
});
|
|
xPos += colWidths[index];
|
|
});
|
|
|
|
yPosition -= 15;
|
|
});
|
|
|
|
return yPosition;
|
|
}
|
|
|
|
/**
|
|
* Desenha a seção de multas no PDF
|
|
*/
|
|
private desenharSecaoMultas(page: any, multas: any[], font: any, boldFont: any, yPosition: number, margin: number, width: number): number {
|
|
yPosition -= 20;
|
|
|
|
page.drawText('MULTAS', {
|
|
x: margin,
|
|
y: yPosition,
|
|
font: boldFont,
|
|
size: 14,
|
|
});
|
|
|
|
yPosition -= 20;
|
|
|
|
// Cabeçalho da tabela
|
|
const colunas = ['Tipo', 'Percentual', 'Valor Base', 'Valor Multa', 'Fundamento'];
|
|
const colWidths = [width * 0.15, width * 0.15, width * 0.2, width * 0.2, width * 0.3];
|
|
|
|
// Desenhar cabeçalho
|
|
let xPos = margin;
|
|
colunas.forEach((col, index) => {
|
|
page.drawText(col, {
|
|
x: xPos,
|
|
y: yPosition,
|
|
font: boldFont,
|
|
size: 10,
|
|
});
|
|
xPos += colWidths[index];
|
|
});
|
|
|
|
yPosition -= 15;
|
|
|
|
// Desenhar linhas
|
|
multas.forEach(multa => {
|
|
xPos = margin;
|
|
|
|
const percentual = (parseFloat(multa.percentual.toString()) * 100).toFixed(2) + '%';
|
|
|
|
const valores = [
|
|
multa.tipo,
|
|
percentual,
|
|
this.formatarValor(multa.valorBase),
|
|
this.formatarValor(multa.valorMulta),
|
|
multa.fundamento || '-'
|
|
];
|
|
|
|
valores.forEach((valor, index) => {
|
|
page.drawText(valor.toString(), {
|
|
x: xPos,
|
|
y: yPosition,
|
|
font: font,
|
|
size: 10,
|
|
});
|
|
xPos += colWidths[index];
|
|
});
|
|
|
|
yPosition -= 15;
|
|
});
|
|
|
|
return yPosition;
|
|
}
|
|
|
|
/**
|
|
* Extrai o endereço IP real do cliente de várias fontes possíveis
|
|
* @param ipAddress IP básico
|
|
* @param forwardedIp IP encaminhado
|
|
* @param xForwardedFor Header X-Forwarded-For
|
|
* @returns O IP mais confiável encontrado
|
|
*/
|
|
private extractRealClientIP(ipAddress?: string, forwardedIp?: string, xForwardedFor?: string): string {
|
|
// Lista de IPs locais/internos que devem ser evitados
|
|
const localIPs = ['::1', '127.0.0.1', 'localhost', '::ffff:127.0.0.1', '::ffff:10.15.0.1'];
|
|
|
|
// Verificar primeiro X-Forwarded-For (mais confiável em ambientes de produção)
|
|
if (xForwardedFor) {
|
|
const ips = xForwardedFor.split(',').map(ip => ip.trim());
|
|
if (ips.length > 0 && !localIPs.includes(ips[0])) {
|
|
return ips[0];
|
|
}
|
|
}
|
|
|
|
// Verificar IP encaminhado (usado em alguns proxies)
|
|
if (forwardedIp && !localIPs.includes(forwardedIp)) {
|
|
return forwardedIp;
|
|
}
|
|
|
|
// Verificar IP básico
|
|
if (ipAddress && !localIPs.includes(ipAddress)) {
|
|
return ipAddress;
|
|
}
|
|
|
|
// Se chegamos aqui e temos um X-Forwarded-For, mesmo que seja um IP local, usá-lo
|
|
if (xForwardedFor) {
|
|
const ips = xForwardedFor.split(',').map(ip => ip.trim());
|
|
if (ips.length > 0) {
|
|
return ips[0];
|
|
}
|
|
}
|
|
|
|
// Retornar IP básico se não encontrar melhor
|
|
return ipAddress || '0.0.0.0';
|
|
}
|
|
}
|