diff --git a/package.json b/package.json index d1cd598..7767dc8 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@mui/icons-material": "^5.15.6", "@mui/material": "^5.15.6", "@mui/x-date-pickers": "^6.19.0", - "@prisma/client": "^6.5.0", + "@prisma/client": "^6.12.0", "@tinymce/tinymce-react": "^6.1.0", "@tiptap/extension-link": "^2.11.7", "@tiptap/react": "^2.11.7", @@ -67,7 +67,12 @@ "uuid": "^9.0.1", "webdav": "^5.3.1", "xlsx": "^0.18.5", - "zod": "^3.22.4" + "zod": "^3.22.4", + "react-webcam": "^7.2.0", + "recordrtc": "^5.6.2", + "html2canvas": "^1.4.1", + "canvas": "^3.1.2", + "ffmpeg-static": "^5.2.0" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.1", @@ -93,11 +98,13 @@ "eslint-plugin-react-hooks": "^5.2.0", "postcss": "^8.4.33", "prettier": "^3.5.3", - "prisma": "^6.5.0", + "prisma": "^6.12.0", "tailwindcss": "^3.4.1", "ts-node": "^10.9.2", "typescript": "^5.8.2", - "vite": "^6.2.4" + "vite": "^6.2.4", + "@types/recordrtc": "^5.6.11", + "@types/html2canvas": "^1.0.0" }, "prisma": { "seed": "ts-node prisma/seed.ts" @@ -113,4 +120,4 @@ "--window-size=1920x1080" ] } -} +} \ No newline at end of file diff --git a/prisma/migrations/add_session_table.sql b/prisma/migrations/add_session_table.sql new file mode 100644 index 0000000..e54e546 --- /dev/null +++ b/prisma/migrations/add_session_table.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "SessaoProva" ( + "id" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'ATIVA', + "dadosSessao" TEXT NOT NULL, + "dataInicio" TIMESTAMP(3) NOT NULL, + "dataFim" TIMESTAMP(3), + "videoPath" TEXT, + "relatorioPath" TEXT, + "hashSessao" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SessaoProva_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SessaoProva_hashSessao_idx" ON "SessaoProva"("hashSessao"); + +-- CreateIndex +CREATE INDEX "SessaoProva_status_idx" ON "SessaoProva"("status"); + +-- CreateIndex +CREATE INDEX "SessaoProva_dataInicio_idx" ON "SessaoProva"("dataInicio"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 524822d..6ac9bd0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1067,3 +1067,22 @@ model CalculoPensaoAlimenticia { @@map("calculos_pensao_alimenticia") } + +model SessaoProva { + id String @id @default(cuid()) + status String @default("ATIVA") // ATIVA, PROCESSANDO, CONCLUIDA, ERRO + dadosSessao String // JSON com dados da sessão + dataInicio DateTime + dataFim DateTime? + videoPath String? + relatorioPath String? + hashSessao String? @unique + metadata Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([hashSessao]) + @@index([status]) + @@index([dataInicio]) + @@map("sessao_prova") +} diff --git a/src/App.tsx b/src/App.tsx index 3a7a7c7..9e3bec9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -95,6 +95,7 @@ const router = createBrowserRouter( element={} errorElement={} /> + {/* Rotas para Cálculos Judiciais */} @@ -148,4 +149,19 @@ export const App: React.FC = () => { ); }; +export default App; + return ( + + + + + + + + + + + ); +}; + export default App; diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 82a707a..1f733e8 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,6 +1,95 @@ import React from 'react'; -import { DashboardLayout } from './dashboard/DashboardLayout'; +import { Link } from 'react-router-dom'; export const Dashboard: React.FC = () => { - return ; + return ( +
+
+

Dashboard

+

Visão geral do seu escritório

+
+ +
+ {/* Widget para sessões de prova */} +
+
+
+ + + +
+
+
+
+ Sessões de Gravação +
+
+ Disponível +
+
+
+
+
+ + Nova sessão de coleta → + +
+
+ + {/* Widget para provas digitais */} +
+
+
+ + + +
+
+
+
+ Provas Digitais +
+
+ Ativo +
+
+
+
+
+ + Validar documento → + +
+
+
+
+ ); }; + +export default Dashboard; diff --git a/src/components/layouts/MainLayout.tsx b/src/components/layouts/MainLayout.tsx index 9c12a6d..881868c 100644 --- a/src/components/layouts/MainLayout.tsx +++ b/src/components/layouts/MainLayout.tsx @@ -116,6 +116,15 @@ export const MainLayout: React.FC = () => { icon: MoneyIcon } ] + }, + { + label: 'Provas Digitais', + icon: ShieldCheckIcon, + subitems: [ + { label: 'Upload de Documentos', path: '/provas/upload' }, + { label: 'Gravação de Sessão', path: '/sessions/new' }, + { label: 'Administrar Provas', path: '/provas/admin', roles: ['ADVOGADO_ADMIN'] }, + ] } ]; diff --git a/src/components/provas/SessionRecording.tsx b/src/components/provas/SessionRecording.tsx new file mode 100644 index 0000000..9732bb0 --- /dev/null +++ b/src/components/provas/SessionRecording.tsx @@ -0,0 +1,387 @@ +import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'react-toastify'; + +export const SessionRecording: React.FC = () => { + const [recording, setRecording] = useState(false); + const [sessionId, setSessionId] = useState(null); + const [recordingTime, setRecordingTime] = useState(0); + const [screenshots, setScreenshots] = useState([]); + const [isProcessing, setIsProcessing] = useState(false); + + const mediaRecorderRef = useRef(null); + const streamRef = useRef(null); + const recordedChunksRef = useRef([]); + const timerRef = useRef(null); + + const navigate = useNavigate(); + + useEffect(() => { + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + } + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + } + }; + }, []); + + const startRecording = useCallback(async () => { + try { + // Coletar informações de contexto + const metadata = await collectContextMetadata(); + + // Criar sessão no backend + const response = await fetch('/api/sessions/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(metadata) + }); + + if (!response.ok) { + throw new Error('Erro ao criar sessão'); + } + + const { sessionId: newSessionId } = await response.json(); + setSessionId(newSessionId); + + // Solicitar captura de tela + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: { mediaSource: 'screen' }, + audio: true + }); + + streamRef.current = stream; + + // Configurar MediaRecorder + const mediaRecorder = new MediaRecorder(stream, { + mimeType: 'video/webm;codecs=vp9' + }); + + mediaRecorderRef.current = mediaRecorder; + recordedChunksRef.current = []; + + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + recordedChunksRef.current.push(event.data); + } + }; + + mediaRecorder.onstop = () => { + handleRecordingStop(newSessionId); + }; + + // Iniciar gravação + mediaRecorder.start(1000); // Gravar em chunks de 1 segundo + setRecording(true); + setRecordingTime(0); + + // Iniciar timer + timerRef.current = setInterval(() => { + setRecordingTime(prev => prev + 1); + }, 1000); + + // Overlay de informações na tela + createScreenOverlay(metadata); + + toast.success('Gravação de sessão iniciada!'); + + } catch (error) { + console.error('Erro ao iniciar gravação:', error); + toast.error('Erro ao iniciar gravação: ' + error.message); + } + }, []); + + const stopRecording = useCallback(() => { + if (mediaRecorderRef.current && recording) { + mediaRecorderRef.current.stop(); + setRecording(false); + + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + } + + removeScreenOverlay(); + } + }, [recording]); + + const takeScreenshot = useCallback(async () => { + if (!sessionId || !streamRef.current) { + toast.error('Sessão não ativa'); + return; + } + + try { + // Capturar screenshot usando Canvas + const canvas = document.createElement('canvas'); + const video = document.createElement('video'); + + video.srcObject = streamRef.current; + video.play(); + + video.onloadedmetadata = async () => { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + const ctx = canvas.getContext('2d'); + ctx?.drawImage(video, 0, 0); + + // Converter para base64 + const screenshotData = canvas.toDataURL('image/png'); + + // Enviar para o backend + const response = await fetch(`/api/sessions/${sessionId}/screenshot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ screenshot: screenshotData }) + }); + + if (response.ok) { + const { filename } = await response.json(); + setScreenshots(prev => [...prev, filename]); + toast.success('Screenshot capturado!'); + } + }; + + } catch (error) { + console.error('Erro ao capturar screenshot:', error); + toast.error('Erro ao capturar screenshot'); + } + }, [sessionId]); + + const handleRecordingStop = async (sessionId: string) => { + try { + setIsProcessing(true); + + // Combinar chunks do vídeo + const blob = new Blob(recordedChunksRef.current, { type: 'video/webm' }); + + // Enviar vídeo para o backend + const formData = new FormData(); + formData.append('video', blob, `session_${sessionId}.webm`); + + const uploadResponse = await fetch(`/api/sessions/${sessionId}/video`, { + method: 'POST', + body: formData + }); + + if (!uploadResponse.ok) { + throw new Error('Erro ao enviar vídeo'); + } + + // Finalizar sessão e gerar relatório + const finalizeResponse = await fetch(`/api/sessions/${sessionId}/finalize`, { + method: 'POST' + }); + + if (!finalizeResponse.ok) { + throw new Error('Erro ao finalizar sessão'); + } + + const { reportPath } = await finalizeResponse.json(); + + toast.success('Sessão finalizada e relatório gerado!'); + + // Navegar para página de resultados + navigate(`/sessions/report/${sessionId}`); + + } catch (error) { + console.error('Erro ao processar gravação:', error); + toast.error('Erro ao processar gravação: ' + error.message); + } finally { + setIsProcessing(false); + } + }; + + const formatTime = (seconds: number): string => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + + return ( +
+
+

+ Gravação de Sessão de Coleta de Prova +

+ +
+ {/* Status da gravação */} +
+
+
+ + {recording ? 'Gravando' : isProcessing ? 'Processando...' : 'Pronto para gravar'} + + {recording && ( + + {formatTime(recordingTime)} + + )} +
+ + {sessionId && ( + + Sessão: {sessionId} + + )} +
+ + {/* Controles */} +
+ {!recording && !isProcessing && ( + + )} + + {recording && ( + <> + + + + + )} +
+ + {/* Informações da sessão */} + {(recording || screenshots.length > 0) && ( +
+

Informações da Sessão

+
+
+ Screenshots capturados: + {screenshots.length} +
+
+ Tempo de gravação: + {formatTime(recordingTime)} +
+
+
+ )} + + {/* Lista de screenshots */} + {screenshots.length > 0 && ( +
+

Screenshots Capturados

+
+ {screenshots.map((screenshot, index) => ( +
+ {index + 1}. {screenshot} +
+ ))} +
+
+ )} + + {isProcessing && ( +
+
+

Processando gravação e gerando relatório...

+
+ )} +
+
+
+ ); +}; + +// Função para coletar metadados de contexto +async function collectContextMetadata() { + const getLocation = (): Promise => { + return new Promise((resolve) => { + if (!navigator.geolocation) { + resolve(null); + return; + } + + navigator.geolocation.getCurrentPosition( + (position) => resolve(position), + () => resolve(null), + { timeout: 5000 } + ); + }); + }; + + const location = await getLocation(); + + return { + userAgent: navigator.userAgent, + language: navigator.language, + platform: navigator.platform, + cookieEnabled: navigator.cookieEnabled, + onlineStatus: navigator.onLine, + screenResolution: `${screen.width}x${screen.height}`, + viewportSize: `${window.innerWidth}x${window.innerHeight}`, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + geolocation: location ? { + latitude: location.coords.latitude, + longitude: location.coords.longitude, + accuracy: location.coords.accuracy + } : null, + timestamp: new Date().toISOString(), + url: window.location.href, + referrer: document.referrer + }; +} + +// Função para criar overlay na tela +function createScreenOverlay(metadata: any) { + const overlay = document.createElement('div'); + overlay.id = 'recording-overlay'; + overlay.style.cssText = ` + position: fixed; + top: 10px; + right: 10px; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 10px; + border-radius: 5px; + font-family: monospace; + font-size: 12px; + z-index: 10000; + max-width: 300px; + `; + + overlay.innerHTML = ` +
🔴 GRAVAÇÃO ATIVA
+
IP: ${metadata.ipAddress || 'Coletando...'}
+
Timestamp: ${new Date().toLocaleString()}
+
User-Agent: ${metadata.userAgent.substring(0, 50)}...
+ `; + + document.body.appendChild(overlay); +} + +// Função para remover overlay +function removeScreenOverlay() { + const overlay = document.getElementById('recording-overlay'); + if (overlay) { + overlay.remove(); + } +} + +export default SessionRecording; diff --git a/src/components/provas/SessionReport.tsx b/src/components/provas/SessionReport.tsx new file mode 100644 index 0000000..418d857 --- /dev/null +++ b/src/components/provas/SessionReport.tsx @@ -0,0 +1,227 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { format } from 'date-fns'; +import { ptBR } from 'date-fns/locale'; + +interface SessionData { + id: string; + startTime: string; + endTime?: string; + ipAddress: string; + dnsInfo: string; + userAgent: string; + geolocation?: { + latitude: number; + longitude: number; + }; + screenshots: string[]; + videoPath?: string; + metadata: any; +} + +export const SessionReport: React.FC = () => { + const { sessionId } = useParams<{ sessionId: string }>(); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchSession = async () => { + try { + const response = await fetch(`/api/sessions/${sessionId}`); + + if (!response.ok) { + throw new Error('Sessão não encontrada'); + } + + const data = await response.json(); + setSession(data.session); + } catch (error) { + console.error('Erro ao buscar sessão:', error); + setError(error.message); + } finally { + setLoading(false); + } + }; + + if (sessionId) { + fetchSession(); + } + }, [sessionId]); + + const formatDuration = (start: string, end?: string) => { + if (!end) return 'Em andamento'; + + const startTime = new Date(start); + const endTime = new Date(end); + const diffMs = endTime.getTime() - startTime.getTime(); + + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diffMs % (1000 * 60)) / 1000); + + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error || !session) { + return ( +
+
+
+
+ Erro ao carregar sessão +
+

{error || 'Sessão não encontrada'}

+ + Nova Sessão + +
+
+
+ ); + } + + return ( +
+
+
+ {/* Header */} +
+

Relatório de Sessão de Coleta de Prova

+

ID da Sessão: {session.id}

+
+ + {/* Resumo da sessão */} +
+

Resumo da Sessão

+
+
+

Data/Hora de Início

+

+ {format(new Date(session.startTime), 'dd/MM/yyyy HH:mm:ss', { locale: ptBR })} +

+
+ + {session.endTime && ( +
+

Data/Hora de Fim

+

+ {format(new Date(session.endTime), 'dd/MM/yyyy HH:mm:ss', { locale: ptBR })} +

+
+ )} + +
+

Duração

+

+ {formatDuration(session.startTime, session.endTime)} +

+
+ +
+

Screenshots Capturados

+

{session.screenshots.length}

+
+
+
+ + {/* Informações de rede */} +
+

Informações de Rede

+
+
+

Endereço IP

+

{session.ipAddress}

+
+ +
+

DNS Reverso

+

{session.dnsInfo}

+
+ +
+

User-Agent

+

{session.userAgent}

+
+
+
+ + {/* Geolocalização */} + {session.geolocation && ( +
+

Geolocalização

+
+
+

Latitude

+

{session.geolocation.latitude}

+
+
+

Longitude

+

{session.geolocation.longitude}

+
+
+
+ )} + + {/* Metadados adicionais */} +
+

Metadados do Sistema

+
+
+                {JSON.stringify(session.metadata, null, 2)}
+              
+
+
+ + {/* Ações */} +
+ + Baixar Relatório PDF + + + {session.videoPath && ( + + )} + + + Nova Sessão + +
+
+ +
+ Relatório gerado pelo sistema AdvX Provas - Coleta de Evidências Digitais +
+
+
+ ); +}; + +export default SessionReport; diff --git a/src/middleware/sessionAuth.ts b/src/middleware/sessionAuth.ts new file mode 100644 index 0000000..cdbb3ae --- /dev/null +++ b/src/middleware/sessionAuth.ts @@ -0,0 +1,51 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { prisma } from '../config/database'; + +export interface SessionRequest extends Request { + user?: { + id: string; + email: string; + perfil: string; + }; +} + +export const sessionAuthMiddleware = async ( + req: SessionRequest, + res: Response, + next: NextFunction +) => { + try { + const token = req.headers.authorization?.replace('Bearer ', ''); + + if (!token) { + // Para sessões, permitir acesso sem autenticação em certas rotas + if (req.path.includes('/public/') || req.method === 'GET') { + return next(); + } + return res.status(401).json({ error: 'Token não fornecido' }); + } + + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'default-secret') as any; + + const user = await prisma.usuario.findUnique({ + where: { id: decoded.id }, + select: { + id: true, + email: true, + perfil: true, + ativo: true + } + }); + + if (!user || !user.ativo) { + return res.status(401).json({ error: 'Usuário inválido ou inativo' }); + } + + req.user = user; + next(); + } catch (error) { + console.error('Erro na autenticação de sessão:', error); + res.status(401).json({ error: 'Token inválido' }); + } +}; diff --git a/src/server/index.ts b/src/server/index.ts index 96bf2e4..65e52da 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -24,9 +24,10 @@ import processosAdministrativosRoutes from './routes/processosAdministrativos'; import provasRouter from './routes/provas'; import calculosJudiciaisRoutes from './routes/calculosJudiciais'; import indicesMonetariosRoutes from './routes/indicesMonetarios'; -import pensaoAlimenticiaRouter from './routes/pensaoAlimenticia'; // Importar router de pensão alimentícia -import financeiroPainelRoutes from './routes/financeiroPainel'; // Importar rotas do financeiro painel -import audienciasRoutes from './routes/audiencias'; // Importar rotas de audiências +import pensaoAlimenticiaRouter from './routes/pensaoAlimenticia'; +import financeiroPainelRoutes from './routes/financeiroPainel'; +import audienciasRoutes from './routes/audiencias'; +import sessionsRouter from './routes/sessions'; // Adicionar importação das rotas de sessão const app = express(); @@ -1471,6 +1472,7 @@ app.patch("/api/atendimentos/:id/status", authMiddleware, async (req, res) => { app.use('/api/anexos', anexosRoutes); app.use('/api/atendimentos', atendimentosRoutes); app.use('/api/provas', provasRouter); +app.use('/api/sessions', sessionsRouter); // Adicionar rotas de sessão app.use('/api/tarefas', tarefasRoutes); app.use('/api/dashboard', authMiddleware, dashboardRoutes); @@ -1480,5 +1482,5 @@ app.use('/api/processos-administrativos', processosAdministrativosRoutes); app.use('/api/calculos', calculosJudiciaisRoutes); app.use('/api/indices', indicesMonetariosRoutes); app.use('/api/pensao-alimenticia', pensaoAlimenticiaRouter); -app.use('/api/financeiro-painel', financeiroPainelRoutes); // Adicionar uso das rotas do financeiro painel -app.use('/api/audiencias', audienciasRoutes); // Adicionar uso das rotas de audiências \ No newline at end of file +app.use('/api/financeiro-painel', financeiroPainelRoutes); +app.use('/api/audiencias', audienciasRoutes); \ No newline at end of file diff --git a/src/server/routes/sessions.ts b/src/server/routes/sessions.ts new file mode 100644 index 0000000..400a93c --- /dev/null +++ b/src/server/routes/sessions.ts @@ -0,0 +1,254 @@ +import express from 'express'; +import multer from 'multer'; +import { SessionProvaService } from '../../services/SessionProvaService'; +import { prisma } from '../prismaClient'; +import * as path from 'path'; +import * as fs from 'fs'; + +const router = express.Router(); +const upload = multer({ storage: multer.memoryStorage() }); +const sessionService = new SessionProvaService(); + +// Criar nova sessão de gravação +router.post('/create', async (req, res) => { + try { + // Validação de entrada + if (!req.body || typeof req.body !== 'object') { + return res.status(400).json({ error: 'Dados de entrada inválidos' }); + } + + // Coletar IP real + const realIP = req.headers['x-forwarded-for']?.toString().split(',')[0].trim() || + req.ip || + req.socket?.remoteAddress || + '127.0.0.1'; + + const metadata = { + ...req.body, + ipAddress: realIP, + serverTimestamp: new Date().toISOString(), + headers: { + 'user-agent': req.headers['user-agent'], + 'accept-language': req.headers['accept-language'], + 'referer': req.headers['referer'], + 'host': req.headers['host'] + } + }; + + const sessionData = await sessionService.createSession(metadata); + + res.json({ + success: true, + sessionId: sessionData.id, + metadata: sessionData.metadata + }); + } catch (error) { + console.error('Erro ao criar sessão:', error); + res.status(500).json({ + error: 'Erro ao criar sessão de gravação', + details: error.message + }); + } +}); + +// Adicionar screenshot à sessão +router.post('/:sessionId/screenshot', async (req, res) => { + try { + const { sessionId } = req.params; + const { screenshot } = req.body; + + if (!screenshot) { + return res.status(400).json({ error: 'Screenshot não fornecido' }); + } + + if (!sessionId || typeof sessionId !== 'string') { + return res.status(400).json({ error: 'ID de sessão inválido' }); + } + + const filename = await sessionService.addScreenshot(sessionId, screenshot); + + res.json({ + success: true, + filename, + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error('Erro ao salvar screenshot:', error); + res.status(500).json({ + error: 'Erro ao salvar screenshot', + details: error.message + }); + } +}); + +// Upload do vídeo da sessão +router.post('/:sessionId/video', upload.single('video'), async (req, res) => { + try { + const { sessionId } = req.params; + + if (!req.file) { + return res.status(400).json({ error: 'Vídeo não fornecido' }); + } + + if (!sessionId || typeof sessionId !== 'string') { + return res.status(400).json({ error: 'ID de sessão inválido' }); + } + + const filename = await sessionService.saveSessionVideo(sessionId, req.file.buffer); + + res.json({ + success: true, + filename, + size: req.file.size, + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error('Erro ao salvar vídeo:', error); + res.status(500).json({ + error: 'Erro ao salvar vídeo', + details: error.message + }); + } +}); + +// Finalizar sessão e gerar relatório +router.post('/:sessionId/finalize', async (req, res) => { + try { + const { sessionId } = req.params; + + if (!sessionId || typeof sessionId !== 'string') { + return res.status(400).json({ error: 'ID de sessão inválido' }); + } + + const reportPath = await sessionService.finalizeSession(sessionId); + + res.json({ + success: true, + sessionId, + reportPath, + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error('Erro ao finalizar sessão:', error); + res.status(500).json({ + error: 'Erro ao finalizar sessão', + details: error.message + }); + } +}); + +// Obter informações da sessão +router.get('/:sessionId', async (req, res) => { + try { + const { sessionId } = req.params; + + if (!sessionId || typeof sessionId !== 'string') { + return res.status(400).json({ error: 'ID de sessão inválido' }); + } + + const sessionData = await sessionService.getSession(sessionId); + + if (!sessionData) { + return res.status(404).json({ error: 'Sessão não encontrada' }); + } + + res.json({ + success: true, + session: sessionData + }); + } catch (error) { + console.error('Erro ao buscar sessão:', error); + res.status(500).json({ + error: 'Erro ao buscar sessão', + details: error.message + }); + } +}); + +// Baixar relatório da sessão +router.get('/:sessionId/report', async (req, res) => { + try { + const { sessionId } = req.params; + + if (!sessionId || typeof sessionId !== 'string') { + return res.status(400).json({ error: 'ID de sessão inválido' }); + } + + const session = await prisma.sessaoProva.findUnique({ + where: { id: sessionId } + }); + + if (!session || !session.relatorioPath) { + return res.status(404).json({ error: 'Relatório não encontrado' }); + } + + const reportPath = session.relatorioPath; + + if (!fs.existsSync(reportPath)) { + return res.status(404).json({ error: 'Arquivo do relatório não encontrado' }); + } + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="relatorio_sessao_${sessionId}.pdf"`); + + const fileStream = fs.createReadStream(reportPath); + fileStream.pipe(res); + } catch (error) { + console.error('Erro ao servir relatório:', error); + res.status(500).json({ + error: 'Erro ao baixar relatório', + details: error.message + }); + } +}); + +// Verificar sessão por hash +router.get('/verify/:hash', async (req, res) => { + try { + const { hash } = req.params; + + if (!hash || typeof hash !== 'string') { + return res.status(400).json({ error: 'Hash inválido' }); + } + + const result = await sessionService.verifySession(hash); + + res.json(result); + } catch (error) { + console.error('Erro ao verificar sessão:', error); + res.status(500).json({ + error: 'Erro ao verificar sessão', + details: error.message + }); + } +}); + +// Listar sessões (admin) +router.get('/admin/list', async (req, res) => { + try { + const sessions = await prisma.sessaoProva.findMany({ + orderBy: { dataInicio: 'desc' }, + take: 50 + }); + + res.json({ + success: true, + sessions: sessions.map(session => ({ + id: session.id, + status: session.status, + dataInicio: session.dataInicio, + dataFim: session.dataFim, + hashSessao: session.hashSessao, + temRelatorio: !!session.relatorioPath + })) + }); + } catch (error) { + console.error('Erro ao listar sessões:', error); + res.status(500).json({ + error: 'Erro ao listar sessões', + details: error.message + }); + } +}); + +export default router; diff --git a/src/services/ProvaService.ts b/src/services/ProvaService.ts index e80cb3c..6b6ebc8 100644 --- a/src/services/ProvaService.ts +++ b/src/services/ProvaService.ts @@ -4,6 +4,7 @@ import { createHash } from 'crypto'; import { PDFDocument, StandardFonts } from 'pdf-lib'; import * as OpenTimestamps from 'opentimestamps'; import sharp from 'sharp'; +import { promises as fs } from 'fs'; export class ProvaService { private fileValidator: FileValidator; @@ -96,4 +97,51 @@ export class ProvaService { buffer: await pdfDoc.save() }; } + + async verifyFile(hash: string) { + try { + const prova = await prisma.prova.findFirst({ + where: { checksumSha256: hash } + }); + + if (!prova) { + return { + verified: false, + message: 'Documento não encontrado ou hash inválido' + }; + } + + // Verificar se o arquivo ainda existe + const filePath = prova.filePath; + if (filePath) { + try { + await fs.access(filePath); + } catch { + return { + verified: false, + message: 'Arquivo original não encontrado' + }; + } + } + + return { + verified: true, + message: 'Documento verificado com sucesso', + prova: { + fileName: prova.fileName, + checksumSha256: prova.checksumSha256, + checksumSha512: prova.checksumSha512, + checksumMd5: prova.checksumMd5, + timestamp: prova.timestamp, + metadata: prova.metadata, + dataCriacao: prova.dataCriacao, + dataValida: prova.dataAtualizacao + }, + timestamp: prova.timestamp ? JSON.parse(prova.timestamp) : null + }; + } catch (error) { + console.error('Erro ao verificar arquivo:', error); + throw new Error(`Erro ao verificar arquivo: ${error.message}`); + } + } } \ No newline at end of file diff --git a/src/services/SessionProvaService.ts b/src/services/SessionProvaService.ts new file mode 100644 index 0000000..6c2a1d7 --- /dev/null +++ b/src/services/SessionProvaService.ts @@ -0,0 +1,361 @@ +import { prisma } from '../server/prismaClient'; +import { createHash } from 'crypto'; +import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { format } from 'date-fns'; +import { ptBR } from 'date-fns/locale'; + +export interface SessionData { + id: string; + userAgent: string; + ipAddress: string; + dnsInfo: string; + geolocation?: { + latitude: number; + longitude: number; + }; + startTime: Date; + endTime?: Date; + screenshots: string[]; + videoPath?: string; + metadata: any; +} + +export class SessionProvaService { + private storageBasePath = path.join(process.cwd(), 'storage', 'sessions'); + + constructor() { + this.ensureStorageDirectory(); + } + + private async ensureStorageDirectory() { + try { + await fs.access(this.storageBasePath); + } catch { + await fs.mkdir(this.storageBasePath, { recursive: true }); + } + } + + async createSession(metadata: any): Promise { + const sessionId = this.generateSessionId(); + + // Coletar informações de DNS + const dnsInfo = await this.collectDNSInfo(metadata.ipAddress); + + const sessionData: SessionData = { + id: sessionId, + userAgent: metadata.userAgent, + ipAddress: metadata.ipAddress, + dnsInfo, + geolocation: metadata.geolocation, + startTime: new Date(), + screenshots: [], + metadata: { + ...metadata, + sessionId, + browserInfo: this.parseBrowserInfo(metadata.userAgent), + timestamp: new Date().toISOString() + } + }; + + // Salvar no banco de dados + await prisma.sessaoProva.create({ + data: { + id: sessionId, + status: 'ATIVA', + dadosSessao: JSON.stringify(sessionData), + dataInicio: sessionData.startTime, + metadata: sessionData.metadata + } + }); + + return sessionData; + } + + async addScreenshot(sessionId: string, screenshotData: string): Promise { + const timestamp = Date.now(); + const filename = `screenshot_${timestamp}.png`; + const filepath = path.join(this.storageBasePath, sessionId, filename); + + // Criar diretório da sessão se não existir + await fs.mkdir(path.dirname(filepath), { recursive: true }); + + // Converter base64 para buffer e salvar + const buffer = Buffer.from(screenshotData.replace(/^data:image\/png;base64,/, ''), 'base64'); + await fs.writeFile(filepath, buffer); + + // Atualizar sessão no banco + const session = await prisma.sessaoProva.findUnique({ + where: { id: sessionId } + }); + + if (session) { + const sessionData = JSON.parse(session.dadosSessao) as SessionData; + sessionData.screenshots.push(filename); + + await prisma.sessaoProva.update({ + where: { id: sessionId }, + data: { + dadosSessao: JSON.stringify(sessionData) + } + }); + } + + return filename; + } + + async saveSessionVideo(sessionId: string, videoBlob: Buffer): Promise { + const filename = `session_${sessionId}.webm`; + const filepath = path.join(this.storageBasePath, sessionId, filename); + + await fs.mkdir(path.dirname(filepath), { recursive: true }); + await fs.writeFile(filepath, videoBlob); + + // Atualizar sessão + await prisma.sessaoProva.update({ + where: { id: sessionId }, + data: { + videoPath: filename, + status: 'PROCESSANDO' + } + }); + + return filename; + } + + async finalizeSession(sessionId: string): Promise { + const session = await prisma.sessaoProva.findUnique({ + where: { id: sessionId } + }); + + if (!session) { + throw new Error('Sessão não encontrada'); + } + + const sessionData = JSON.parse(session.dadosSessao) as SessionData; + sessionData.endTime = new Date(); + + // Gerar relatório PDF + const reportPath = await this.generateSessionReport(sessionId, sessionData); + + // Gerar hash da sessão + const sessionHash = this.generateSessionHash(sessionData); + + // Finalizar no banco + await prisma.sessaoProva.update({ + where: { id: sessionId }, + data: { + status: 'CONCLUIDA', + dataFim: sessionData.endTime, + dadosSessao: JSON.stringify(sessionData), + relatorioPath: reportPath, + hashSessao: sessionHash + } + }); + + return reportPath; + } + + private async generateSessionReport(sessionId: string, sessionData: SessionData): Promise { + try { + const pdfDoc = await PDFDocument.create(); + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const boldFont = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + // Primeira página - Screenshots (se houver) + if (sessionData.screenshots.length > 0) { + for (const screenshot of sessionData.screenshots) { + const page = pdfDoc.addPage(); + const screenshotPath = path.join(this.storageBasePath, sessionId, screenshot); + + try { + const imageBuffer = await fs.readFile(screenshotPath); + const pngImage = await pdfDoc.embedPng(imageBuffer); + + const { width, height } = page.getSize(); + const imgDims = pngImage.scale(Math.min(width / pngImage.width, height / pngImage.height) * 0.8); + + page.drawImage(pngImage, { + x: (width - imgDims.width) / 2, + y: (height - imgDims.height) / 2, + width: imgDims.width, + height: imgDims.height + }); + + // Adicionar timestamp no screenshot + const timestampFromFilename = screenshot.split('_')[1]?.split('.')[0]; + if (timestampFromFilename) { + const screenshotDate = new Date(parseInt(timestampFromFilename)); + page.drawText(`Capturado em: ${format(screenshotDate, 'dd/MM/yyyy HH:mm:ss', { locale: ptBR })}`, { + x: 50, + y: 50, + size: 10, + font, + color: rgb(0, 0, 0) + }); + } + } catch (error) { + console.error('Erro ao processar screenshot:', error); + } + } + } + + // Página de relatório detalhado + const reportPage = pdfDoc.addPage(); + const { width, height } = reportPage.getSize(); + let yPosition = height - 50; + + // Título + reportPage.drawText('RELATÓRIO DE SESSÃO DE COLETA DE PROVA DIGITAL', { + x: 50, + y: yPosition, + size: 16, + font: boldFont, + color: rgb(0, 0, 0.8) + }); + yPosition -= 40; + + // Informações da sessão - evitar JSON.stringify que pode causar problemas + const sessionInfo = [ + `ID da Sessão: ${sessionData.id}`, + `Data/Hora de Início: ${format(sessionData.startTime, 'dd/MM/yyyy HH:mm:ss', { locale: ptBR })}`, + `Data/Hora de Fim: ${sessionData.endTime ? format(sessionData.endTime, 'dd/MM/yyyy HH:mm:ss', { locale: ptBR }) : 'Em andamento'}`, + `Duração: ${sessionData.endTime ? this.calculateDuration(sessionData.startTime, sessionData.endTime) : 'Em andamento'}`, + ``, + `INFORMAÇÕES DE REDE:`, + `Endereço IP: ${sessionData.ipAddress}`, + `DNS: ${sessionData.dnsInfo}`, + `User-Agent: ${sessionData.userAgent.substring(0, 100)}...`, + `` + ]; + + if (sessionData.geolocation) { + sessionInfo.push( + `GEOLOCALIZAÇÃO:`, + `Latitude: ${sessionData.geolocation.latitude}`, + `Longitude: ${sessionData.geolocation.longitude}`, + `` + ); + } + + sessionInfo.push( + `CAPTURAS DE TELA: ${sessionData.screenshots.length}`, + `VÍDEO DA SESSÃO: ${sessionData.videoPath ? 'Disponível' : 'Não disponível'}`, + ``, + `DADOS TÉCNICOS:`, + `Browser: ${sessionData.metadata?.browserInfo?.name || 'N/A'} ${sessionData.metadata?.browserInfo?.version || ''}`, + `Timestamp: ${sessionData.metadata?.timestamp || 'N/A'}` + ); + + // Desenhar informações + for (const info of sessionInfo) { + if (yPosition < 100) { + const newPage = pdfDoc.addPage(); + yPosition = newPage.getSize().height - 50; + } + + reportPage.drawText(info, { + x: 50, + y: yPosition, + size: 10, + font: info.includes(':') && info.endsWith(':') ? boldFont : font, + color: rgb(0, 0, 0) + }); + yPosition -= 15; + } + + // Salvar PDF + const pdfBytes = await pdfDoc.save(); + const reportPath = path.join(this.storageBasePath, sessionId, `relatorio_${sessionId}.pdf`); + await fs.writeFile(reportPath, pdfBytes); + + return reportPath; + } catch (error) { + console.error('Erro ao gerar relatório da sessão:', error); + throw new Error(`Erro ao gerar relatório: ${error.message}`); + } + } + + private generateSessionId(): string { + return `sess_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + private generateSessionHash(sessionData: SessionData): string { + const dataToHash = JSON.stringify({ + id: sessionData.id, + startTime: sessionData.startTime, + endTime: sessionData.endTime, + ipAddress: sessionData.ipAddress, + userAgent: sessionData.userAgent, + screenshots: sessionData.screenshots + }); + + return createHash('sha256').update(dataToHash).digest('hex'); + } + + private async collectDNSInfo(ipAddress: string): Promise { + try { + const dns = require('dns').promises; + const hostnames = await dns.reverse(ipAddress); + return hostnames.join(', '); + } catch (error) { + return 'DNS não resolvido'; + } + } + + private parseBrowserInfo(userAgent: string): any { + // Parser simples de user agent + const browsers = [ + { name: 'Chrome', pattern: /Chrome\/([0-9.]+)/ }, + { name: 'Firefox', pattern: /Firefox\/([0-9.]+)/ }, + { name: 'Safari', pattern: /Safari\/([0-9.]+)/ }, + { name: 'Edge', pattern: /Edge\/([0-9.]+)/ } + ]; + + for (const browser of browsers) { + const match = userAgent.match(browser.pattern); + if (match) { + return { name: browser.name, version: match[1] }; + } + } + + return { name: 'Desconhecido', version: 'N/A' }; + } + + private calculateDuration(start: Date, end: Date): string { + const diffMs = end.getTime() - start.getTime(); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + const diffSeconds = Math.floor((diffMs % (1000 * 60)) / 1000); + + return `${diffHours.toString().padStart(2, '0')}:${diffMinutes.toString().padStart(2, '0')}:${diffSeconds.toString().padStart(2, '0')}`; + } + + async getSession(sessionId: string): Promise { + const session = await prisma.sessaoProva.findUnique({ + where: { id: sessionId } + }); + + if (!session) return null; + + return JSON.parse(session.dadosSessao) as SessionData; + } + + async verifySession(sessionHash: string): Promise { + const session = await prisma.sessaoProva.findFirst({ + where: { hashSessao: sessionHash } + }); + + if (!session) { + return { verified: false, message: 'Sessão não encontrada' }; + } + + return { + verified: true, + session: JSON.parse(session.dadosSessao), + status: session.status, + reportPath: session.relatorioPath + }; + } +}