frontend; backend: adiciona sistema novo de gravacao de provas

Signed-off-by: Rodrigo B. de Sousa Martins <rodrigo.sousa.577@gmail.com>
This commit is contained in:
rodrigo committed 2025-07-27 17:53:30 -03:00
1 parent 0750b26208
commit f357fb0334
5 files changed
+1226

No files matched your search

+25
View File
@@ -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");
+397
View File
@@ -0,0 +1,397 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
interface SessionRecordingProps {
onSessionStart?: (sessionId: string) => void;
onSessionEnd?: (sessionId: string, reportPath: string) => void;
}
export const SessionRecording: React.FC<SessionRecordingProps> = ({
onSessionStart,
onSessionEnd
}) => {
const [isRecording, setIsRecording] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [recordingTime, setRecordingTime] = useState(0);
const [screenshots, setScreenshots] = useState<string[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const recordedChunksRef = useRef<Blob[]>([]);
const timerRef = useRef<NodeJS.Timeout | null>(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
setIsRecording(true);
setRecordingTime(0);
// Iniciar timer
timerRef.current = setInterval(() => {
setRecordingTime(prev => prev + 1);
}, 1000);
// Overlay de informações na tela
createScreenOverlay(metadata);
onSessionStart?.(newSessionId);
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);
}
}, [onSessionStart]);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
removeScreenOverlay();
}
}, [isRecording]);
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();
onSessionEnd?.(sessionId, reportPath);
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 (
<div className="max-w-4xl mx-auto p-6">
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Gravação de Sessão de Coleta de Prova
</h2>
<div className="space-y-6">
{/* Status da gravação */}
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-3">
<div className={`w-3 h-3 rounded-full ${isRecording ? 'bg-red-500 animate-pulse' : 'bg-gray-300'}`} />
<span className="font-medium">
{isRecording ? 'Gravando' : isProcessing ? 'Processando...' : 'Pronto para gravar'}
</span>
{isRecording && (
<span className="font-mono text-lg text-red-600">
{formatTime(recordingTime)}
</span>
)}
</div>
{sessionId && (
<span className="text-sm text-gray-500">
Sessão: {sessionId}
</span>
)}
</div>
{/* Controles */}
<div className="flex justify-center space-x-4">
{!isRecording && !isProcessing && (
<button
onClick={startRecording}
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium"
>
Iniciar Gravação
</button>
)}
{isRecording && (
<>
<button
onClick={takeScreenshot}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
>
Capturar Tela
</button>
<button
onClick={stopRecording}
className="px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 font-medium"
>
Parar Gravação
</button>
</>
)}
</div>
{/* Informações da sessão */}
{(isRecording || screenshots.length > 0) && (
<div className="border-t pt-4">
<h3 className="font-medium text-gray-900 mb-2">Informações da Sessão</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Screenshots capturados:</span>
<span className="ml-2 font-medium">{screenshots.length}</span>
</div>
<div>
<span className="text-gray-500">Tempo de gravação:</span>
<span className="ml-2 font-medium">{formatTime(recordingTime)}</span>
</div>
</div>
</div>
)}
{/* Lista de screenshots */}
{screenshots.length > 0 && (
<div className="border-t pt-4">
<h3 className="font-medium text-gray-900 mb-2">Screenshots Capturados</h3>
<div className="space-y-1">
{screenshots.map((screenshot, index) => (
<div key={index} className="text-sm text-gray-600">
{index + 1}. {screenshot}
</div>
))}
</div>
</div>
)}
{isProcessing && (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
<p className="text-gray-600">Processando gravação e gerando relatório...</p>
</div>
)}
</div>
</div>
</div>
);
};
// Função para coletar metadados de contexto
async function collectContextMetadata() {
const getLocation = (): Promise<GeolocationPosition | null> => {
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 = `
<div>🔴 GRAVAÇÃO ATIVA</div>
<div>IP: ${metadata.ipAddress || 'Coletando...'}</div>
<div>Timestamp: ${new Date().toLocaleString()}</div>
<div>User-Agent: ${metadata.userAgent.substring(0, 50)}...</div>
`;
document.body.appendChild(overlay);
}
// Função para remover overlay
function removeScreenOverlay() {
const overlay = document.getElementById('recording-overlay');
if (overlay) {
overlay.remove();
}
}
export default SessionRecording;
+227
View File
@@ -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<SessionData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow p-6">
<div className="animate-pulse">
<div className="h-6 bg-gray-300 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-300 rounded w-1/2 mb-2"></div>
<div className="h-4 bg-gray-300 rounded w-2/3"></div>
</div>
</div>
</div>
</div>
);
}
if (error || !session) {
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow p-6 text-center">
<div className="text-red-600 text-xl font-bold mb-4">
Erro ao carregar sessão
</div>
<p className="text-gray-600 mb-6">{error || 'Sessão não encontrada'}</p>
<Link
to="/sessions/new"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Nova Sessão
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow overflow-hidden">
{/* Header */}
<div className="bg-green-600 text-white px-6 py-4">
<h1 className="text-2xl font-bold">Relatório de Sessão de Coleta de Prova</h1>
<p className="text-green-100">ID da Sessão: {session.id}</p>
</div>
{/* Resumo da sessão */}
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Resumo da Sessão</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-medium text-gray-500">Data/Hora de Início</h3>
<p className="text-lg text-gray-900">
{format(new Date(session.startTime), 'dd/MM/yyyy HH:mm:ss', { locale: ptBR })}
</p>
</div>
{session.endTime && (
<div>
<h3 className="text-sm font-medium text-gray-500">Data/Hora de Fim</h3>
<p className="text-lg text-gray-900">
{format(new Date(session.endTime), 'dd/MM/yyyy HH:mm:ss', { locale: ptBR })}
</p>
</div>
)}
<div>
<h3 className="text-sm font-medium text-gray-500">Duração</h3>
<p className="text-lg text-gray-900">
{formatDuration(session.startTime, session.endTime)}
</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Screenshots Capturados</h3>
<p className="text-lg text-gray-900">{session.screenshots.length}</p>
</div>
</div>
</div>
{/* Informações de rede */}
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Informações de Rede</h2>
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-500">Endereço IP</h3>
<p className="text-gray-900 font-mono">{session.ipAddress}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">DNS Reverso</h3>
<p className="text-gray-900 font-mono">{session.dnsInfo}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">User-Agent</h3>
<p className="text-gray-900 font-mono text-sm break-all">{session.userAgent}</p>
</div>
</div>
</div>
{/* Geolocalização */}
{session.geolocation && (
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Geolocalização</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h3 className="text-sm font-medium text-gray-500">Latitude</h3>
<p className="text-gray-900 font-mono">{session.geolocation.latitude}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Longitude</h3>
<p className="text-gray-900 font-mono">{session.geolocation.longitude}</p>
</div>
</div>
</div>
)}
{/* Metadados adicionais */}
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Metadados do Sistema</h2>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700 overflow-x-auto">
{JSON.stringify(session.metadata, null, 2)}
</pre>
</div>
</div>
{/* Ações */}
<div className="p-6 flex flex-col sm:flex-row gap-4">
<a
href={`/api/sessions/${session.id}/report`}
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Baixar Relatório PDF
</a>
{session.videoPath && (
<button className="inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Visualizar Vídeo
</button>
)}
<Link
to="/sessions/new"
className="inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Nova Sessão
</Link>
</div>
</div>
<div className="mt-4 text-center text-sm text-gray-500">
Relatório gerado pelo sistema AdvX Provas - Coleta de Evidências Digitais
</div>
</div>
</div>
);
};
export default SessionReport;
+225
View File
@@ -0,0 +1,225 @@
import express from 'express';
import multer from 'multer';
import { SessionProvaService } from '../services/SessionProvaService';
import { prisma } from '../config/database';
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 {
// 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' });
}
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' });
}
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;
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;
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;
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;
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;
+352
View File
@@ -0,0 +1,352 @@
import { prisma } from '../config/database';
import { createHash } from 'crypto';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import * as fs from 'fs/promises';
import * as path from 'path';
import sharp from 'sharp';
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<SessionData> {
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<string> {
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<string> {
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<string> {
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<string> {
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
page.drawText(`Capturado em: ${format(new Date(parseInt(screenshot.split('_')[1].split('.')[0])), '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
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}`,
``
];
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'}`,
``,
`METADADOS ADICIONAIS:`,
JSON.stringify(sessionData.metadata, null, 2)
);
// 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;
}
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<string> {
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<SessionData | null> {
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<any> {
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
};
}
}