const express = require('express'); const http = require('http'); const socketIo = require('socket.io'); const fs = require('fs').promises; const path = require('path'); const cors = require('cors'); const cookieParser = require('cookie-parser'); require('dotenv').config(); // Importar módulos const imageProcessor = require('./image-processor'); const { authenticateToken, hashPassword, JWT_SECRET } = require('./auth'); const jwt = require('jsonwebtoken'); const db = require('./database'); const authRoutes = require('./routes/auth'); const imageRoutes = require('./routes/images'); const gtosRouter = require('./routes/gtos'); const systemRoutes = require('./routes/system'); const adminClinicsRoutes = require('./routes/admin-clinics'); const { router: clientAuthRoutes, requireClientAuth } = require('./routes/client-auth'); const { checkInstallation } = require('./installer/check-installation'); const redis = require('./redis'); const storage = require('./storage'); // ================================================================ // CONFIGURAÇÃO DO SERVIDOR // ================================================================ const app = express(); const server = http.createServer(app); const io = socketIo(server, { cors: { origin: process.env.ALLOWED_ORIGINS || "*", methods: ["GET", "POST"] }, transports: ['websocket', 'polling'], allowEIO3: true, pingTimeout: 60000, pingInterval: 25000, connectTimeout: 45000, upgradeTimeout: 30000, maxHttpBufferSize: 5e7 // 50MB para suportar raios-x grandes }); const connectedClients = []; const PORT = process.env.PORT || 3000; const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, 'uploads'); const PROCESSED_DIR = process.env.PROCESSED_DIR || path.join(__dirname, 'processed'); // ================================================================ // MIDDLEWARES BÁSICOS // ================================================================ app.use(cors()); app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ extended: true, limit: '50mb' })); app.use(cookieParser()); // ================================================================ // ROTAS DA API // ================================================================ // ================================================================ // ROTAS PÚBLICAS (SEMPRE ACESSÍVEIS) // ================================================================ // Health check app.get('/status', (req, res) => { res.json({ status: 'online', timestamp: new Date().toISOString(), uptime: process.uptime() }); }); // DragonflyDB Ping check app.get('/api/redis/ping', async (req, res) => { try { const client = redis.getClient(); await client.set('ping_test', 'pong', 'EX', 10); const value = await client.get('ping_test'); res.json({ success: true, dragonfly_db: 'connected', test_value: value }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // Verificar se o nome do computador/cliente já existe app.get('/api/clients/check-name', async (req, res) => { try { const name = (req.query.name || '').trim(); const token = req.query.token || req.headers['x-api-key'] || req.headers['authorization']?.split(' ')[1]; const clientId = req.query.clientId || ''; const serverApiKey = process.env.CLIENT_API_KEY || 'rf-dental-secure-key-2026'; if (token !== serverApiKey) { return res.status(401).json({ success: false, error: 'Chave de API inválida' }); } if (!name) { return res.status(400).json({ success: false, error: 'Nome não fornecido' }); } // 1. Verificar se existe no banco de dados na tabela images (busca case-insensitive) const row = await db.get('SELECT COUNT(*) as count FROM images WHERE LOWER(client_name) = LOWER($1)', [name]); const existsInDb = row && row.count > 0; // 2. Verificar se existe algum cliente atualmente conectado com esse nome (desconsiderando o próprio clientId) const existsConnected = connectedClients.some(c => c.name.toLowerCase() === name.toLowerCase() && (!clientId || c.clientId !== clientId) ); if (existsInDb || existsConnected) { return res.json({ success: true, available: false, exists: true }); } res.json({ success: true, available: true, exists: false }); } catch (error) { console.error('Erro ao verificar nome do cliente:', error); res.status(500).json({ success: false, error: error.message }); } }); // Debug endpoint for browser logs app.post('/api/debug-log', (req, res) => { console.log('=== BROWSER HTML ==='); console.log(req.body.html || 'No HTML provided'); console.log('===================='); res.json({ success: true }); }); // Middleware para verificar se o usuário autenticado é admin const requireAdmin = (req, res, next) => { if (req.user && req.user.is_admin) { return next(); } return res.status(403).json({ error: 'Acesso negado. Apenas administradores.' }); }; // Status de conexões Socket.IO app.get('/api/socket/status', authenticateToken, requireAdmin, (req, res) => { const sockets = Array.from(io.sockets.sockets.values()); const clients = sockets.map(socket => ({ id: socket.id, connected: socket.connected, transport: socket.conn.transport.name, isIdentified: socket.isIdentified || false, handshake: socket.handshake.query, remoteAddress: socket.handshake.address })); const identifiedClients = connectedClients.filter(c => { return sockets.some(s => s.id === c.socketId); }); res.json({ totalConnections: sockets.length, identifiedClients: identifiedClients.length, clients: clients, identified: identifiedClients, timestamp: new Date().toISOString() }); }); // Endpoint para forçar identificação de um cliente (debug) app.post('/api/socket/request-identify/:socketId', authenticateToken, requireAdmin, (req, res) => { const { socketId } = req.params; const socket = io.sockets.sockets.get(socketId); if (!socket) { return res.status(404).json({ error: 'Socket não encontrado' }); } socket.emit('please-identify', { message: 'Solicitação manual de identificação' }); res.json({ success: true, message: 'Solicitação de identificação enviada', socketId }); }); // Endpoint para teste de conexão Socket.IO app.post('/api/socket/test-connection', authenticateToken, requireAdmin, async (req, res) => { try { const sockets = Array.from(io.sockets.sockets.values()); const totalConnections = sockets.length; const identifiedClients = connectedClients.filter(c => { return sockets.some(s => s.id === c.socketId); }); if (identifiedClients.length === 0) { return res.json({ success: true, totalConnections: totalConnections, identifiedClients: 0, testResults: [], message: 'Nenhum cliente identificado para testar', timestamp: new Date().toISOString() }); } // Enviar evento de teste para todos os clientes identificados const testPromises = identifiedClients.map((client) => { return new Promise((resolve) => { const socket = io.sockets.sockets.get(client.socketId); if (!socket || !socket.connected) { resolve({ socketId: client.socketId, name: client.name, success: false, error: 'Cliente não está conectado' }); return; } const testId = `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const testStartTime = Date.now(); let resolved = false; const timeout = setTimeout(() => { if (!resolved) { resolved = true; resolve({ socketId: client.socketId, name: client.name, success: false, error: 'Timeout - cliente não respondeu em 5 segundos' }); } }, 5000); const ackHandler = (data) => { if (!resolved && data.testId === testId) { resolved = true; clearTimeout(timeout); socket.off('test-connection-ack', ackHandler); resolve({ socketId: client.socketId, name: client.name, success: true, latency: data.latency || (Date.now() - testStartTime), timestamp: data.timestamp }); } }; socket.on('test-connection-ack', ackHandler); socket.emit('test-connection', { testId: testId, timestamp: testStartTime, message: 'Teste de conexão iniciado pelo servidor' }); }); }); // Aguardar todas as respostas const testResults = await Promise.all(testPromises); res.json({ success: true, totalConnections: totalConnections, identifiedClients: identifiedClients.length, testResults: testResults, timestamp: new Date().toISOString() }); } catch (error) { console.error('Erro no teste de conexão:', error); res.status(500).json({ success: false, error: error.message }); } }); // ================================================================ // SINCRONIZAÇÃO MANUAL // ================================================================ app.post('/api/system/trigger-sync', authenticateToken, requireAdmin, (req, res) => { try { const sockets = Array.from(io.sockets.sockets.values()); const identifiedWindowsClients = connectedClients.filter(c => { return (c.type === 'client' || c.type === 'windows') && sockets.some(s => s.id === c.socketId); }); let devicesTriggered = 0; identifiedWindowsClients.forEach(client => { const socket = io.sockets.sockets.get(client.socketId); if (socket) { socket.emit('force-sync'); devicesTriggered++; } }); res.json({ success: true, devicesTriggered: devicesTriggered }); } catch (error) { console.error('Erro ao acionar sincronização:', error); res.status(500).json({ error: 'Erro interno ao acionar sincronização.' }); } }); // API de Instalação - Status app.get('/api/install/status', async (req, res) => { try { const status = await checkInstallation(); res.json(status); } catch (error) { res.json({ installed: false, needsInstall: true, databaseExists: false, userExists: false, error: error.message }); } }); // API de Instalação - Executar app.post('/api/install', async (req, res) => { try { const { db: dbConfig, admin } = req.body; // Validar dados if (!dbConfig || !admin) { return res.status(400).json({ success: false, error: 'Dados incompletos' }); } if (!admin.username || !admin.email || !admin.password) { return res.status(400).json({ success: false, error: 'Preencha todos os dados do administrador' }); } if (admin.password.length < 8) { return res.status(400).json({ success: false, error: 'A senha deve ter no mínimo 8 caracteres' }); } // Atualizar variáveis de ambiente process.env.DB_HOST = dbConfig.host || 'localhost'; process.env.DB_PORT = dbConfig.port || 3306; process.env.DB_NAME = dbConfig.name || 'dental_images'; process.env.DB_USER = dbConfig.user || 'root'; process.env.DB_PASSWORD = dbConfig.password || ''; // Salvar no arquivo .env await updateEnvFile({ DB_HOST: process.env.DB_HOST, DB_PORT: process.env.DB_PORT, DB_NAME: process.env.DB_NAME, DB_USER: process.env.DB_USER, DB_PASSWORD: process.env.DB_PASSWORD }); // Inicializar banco de dados await db.initDatabase(); console.log('✅ Banco de dados inicializado'); // Criar usuário admin const passwordHash = await hashPassword(admin.password); await db.run( `INSERT INTO users (username, email, password_hash, created_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, password_hash = EXCLUDED.password_hash`, [admin.username, admin.email, passwordHash] ); console.log('✅ Usuário admin criado:', admin.username); // Criar arquivo .installed const installedPath = path.join(__dirname, 'data', '.installed'); await fs.writeFile(installedPath, new Date().toISOString()); // Criar arquivo version.txt const pkg = require('./package.json'); const versionPath = path.join(__dirname, 'version.txt'); await fs.writeFile(versionPath, pkg.version || '2.0.0'); res.json({ success: true, message: 'Instalação concluída com sucesso!' }); } catch (error) { console.error('Erro na instalação:', error); res.status(500).json({ success: false, error: error.message || 'Erro na instalação' }); } }); // Função para atualizar arquivo .env async function updateEnvFile(updates) { const envPath = path.join(__dirname, '.env'); let envContent = ''; try { // Ler arquivo .env existente envContent = await fs.readFile(envPath, 'utf8'); } catch (error) { // Se não existe, criar conteúdo inicial envContent = `PORT=3000 JWT_SECRET=${process.env.JWT_SECRET || 'sua-chave-secreta-alterar-em-producao'} SESSION_SECRET=${process.env.SESSION_SECRET || 'outra-chave-secreta'} ALLOWED_ORIGINS=${process.env.ALLOWED_ORIGINS || '*'} TRUST_PROXY=true `; } // Atualizar valores Object.keys(updates).forEach(key => { const regex = new RegExp(`^${key}=.*$`, 'm'); const newLine = `${key}=${updates[key]}`; if (regex.test(envContent)) { envContent = envContent.replace(regex, newLine); } else { envContent += `${newLine}\n`; } }); // Adicionar configurações de diretórios se não existirem if (!envContent.includes('UPLOAD_DIR')) { envContent += `UPLOAD_DIR=${UPLOAD_DIR}\n`; } if (!envContent.includes('PROCESSED_DIR')) { envContent += `PROCESSED_DIR=${PROCESSED_DIR}\n`; } // Salvar arquivo await fs.writeFile(envPath, envContent); console.log('✅ Arquivo .env atualizado'); } // Rotas de autenticação (públicas) // Rotas de autenticação (públicas) app.use('/api/auth', authRoutes); app.use('/api/client', clientAuthRoutes); // ================================================================ // MIDDLEWARE DE VERIFICAÇÃO DE INSTALAÇÃO (APENAS PARA APIs) // ================================================================ async function checkInstallationMiddleware(req, res, next) { // Identificar se é rota da API const isApiRoute = req.originalUrl.includes('/api') || (req.headers.accept && req.headers.accept.includes('application/json')); // SEMPRE permitir rotas públicas, static files e Socket.IO if (req.path === '/' || req.path.startsWith('/install') || req.path.startsWith('/login') || req.path.startsWith('/status') || req.path.startsWith('/socket.io') || req.path.startsWith('/api/auth') || req.path.startsWith('/api/install') || req.path.startsWith('/api/clients/check-name') || req.path.match(/\.(js|css|svg|png|jpg|jpeg|woff2?|ttf|eot)$/)) { return next(); } // Para APIs que não são de instalação ou auth, verificar instalação if (isApiRoute) { try { // Procura no diretório persistente de dados const installedFile = path.join(__dirname, 'data', '.installed'); const fsSync = require('fs'); if (!fsSync.existsSync(installedFile)) { return res.status(503).json({ error: 'Sistema não instalado. Acesse /install para configurar.' }); } } catch (error) { return res.status(503).json({ error: 'Erro ao verificar instalação.' }); } } else { // Se não for API e não for rota pública liberada acima, e estiver sem .installed, // manda pro install. try { const installedFile = path.join(__dirname, 'data', '.installed'); const fsSync = require('fs'); if (!fsSync.existsSync(installedFile)) { return res.redirect('/install'); } } catch (error) { // Ignora erro e prossegue } } next(); } // Aplicar middleware apenas para verificar APIs app.use(checkInstallationMiddleware); // ================================================================ // ROTAS PÚBLICAS (SEM AUTENTICAÇÃO) // ================================================================ // Middleware que pula autenticação para rotas públicas de imagens const publicImageRoutes = express.Router(); // Serve os BYTES da thumbnail diretamente (image/jpeg), para uso direto em . // Se a imagem ainda não tem thumbnail, gera na hora a partir da original, // serve imediatamente e faz backfill (upload pro Wasabi + atualiza banco) em background. publicImageRoutes.get('/:id/thumbnail', async (req, res) => { try { const image = await db.get( 'SELECT id, thumb_filename, filename, clinic_name, client_name, patient_name FROM images WHERE id = $1', [req.params.id] ); if (!image) return res.status(404).json({ error: 'Imagem não encontrada' }); // Thumb de um id nunca muda → cache agressivo no navegador (1 ano, imutável). // Repetir visita = 0 requisição ao servidor (served from disk cache). res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // 1. Já existe thumbnail — serve direto (local ou Wasabi) if (image.thumb_filename) { try { const buf = await storage.getImageBuffer(image.thumb_filename, image.clinic_name, image.client_name, image.patient_name); if (buf) { res.setHeader('Content-Type', 'image/jpeg'); return res.end(buf); } } catch (e) { console.error(`⚠️ [thumbnail] Falha ao ler thumb existente (id ${image.id}):`, e.message); // cai para regeneração abaixo } } // 2. Sem thumbnail — puxa a original, gera a thumb na hora const originalBuf = await storage.getImageBuffer(image.filename, image.clinic_name, image.client_name, image.patient_name); if (!originalBuf) { return res.status(404).json({ error: 'Imagem original não encontrada' }); } const thumbBuf = await imageProcessor.getThumbnail(originalBuf, 500, 500); // Serve imediatamente para não travar a UI res.setHeader('Content-Type', 'image/jpeg'); res.end(thumbBuf); // Backfill em background: salva a thumb no Wasabi e atualiza o banco const thumbFilename = `thumb_${image.filename.replace(/\.[^.]+$/, '')}.jpg`; storage.saveImage(thumbFilename, thumbBuf, image.clinic_name, image.client_name, image.patient_name, false) .then(() => db.run('UPDATE images SET thumb_filename = $1 WHERE id = $2', [thumbFilename, image.id])) .then(() => console.log(`✅ [thumb-backfill] Thumb gerada/salva: ${thumbFilename} (id ${image.id})`)) .catch(err => console.error(`⚠️ [thumb-backfill] Falha id ${image.id}:`, err.message)); } catch (e) { console.error(`❌ [thumbnail] Erro id ${req.params.id}:`, e.message); if (!res.headersSent) res.status(500).json({ error: e.message }); } }); app.use('/api/images', publicImageRoutes); // ================================================================ // ROTAS PROTEGIDAS (REQUEREM AUTENTICAÇÃO) // ================================================================ // APIs de imagens - requerem autenticação app.use('/api/images', authenticateToken); app.use('/api/images', imageRoutes); // APIs de GTOs - requerem autenticação app.use('/api/gtos', authenticateToken); app.use('/api/gtos', gtosRouter); // APIs do Sistema - requerem autenticação app.use('/api/system', authenticateToken); app.use('/api/system', systemRoutes); // APIs Administrativas (Clínicas e Dispositivos) - requerem autenticação (checagem interna na rota) app.use('/api/admin/clinics', authenticateToken); app.use('/api/admin/clinics', adminClinicsRoutes); // ================================================================ // MIDDLEWARES DE ARQUIVOS ESTÁTICOS (Movidos para o fim) // ================================================================ // Serve o build estático do React (gerado pelo Vite em dental-client/dist/) app.use(express.static(path.join(__dirname, 'dental-client', 'dist'))); // Rota de proxy para arquivos do S3 (ou locais via fallback) // Rota de proxy para arquivos do S3 (ou locais via fallback) app.get('/uploads/:filename', async (req, res, next) => { try { // Adicionar headers de cache estritos (cache por 1 ano, pois as imagens nunca mudam o mesmo filename) res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); const filename = req.params.filename; const fsSync = require('fs'); const localUpload = path.join(UPLOAD_DIR, filename); const localProcessed = path.join(PROCESSED_DIR, filename); // Se a imagem existe fisicamente na VPS, serve daqui e ignora o Wasabi. if (fsSync.existsSync(localUpload) || fsSync.existsSync(localProcessed)) { return next(); } if (storage.isWasabiEnabled()) { const presignedUrl = await storage.getDownloadPresignedUrl(filename); if (presignedUrl) { // Redirecionamento 302 para o navegador baixar diretamente do Wasabi return res.redirect(302, presignedUrl); } } next(); // Passa pro express.static se Wasabi estiver desativado ou falhou } catch (err) { next(); // Se der erro, tenta o fallback do express.static local } }); app.use('/uploads', express.static(UPLOAD_DIR)); app.use('/uploads', express.static(PROCESSED_DIR)); // Rota pública para servir updates do client desktop (auto-updater Electron) app.use('/updates', express.static(path.join(__dirname, 'updates'), { maxAge: '1m', setHeaders: (res, filePath) => { // Forçar download para .exe if (filePath.endsWith('.exe')) { res.set('Content-Type', 'application/octet-stream'); } } })); // Rota para baixar o instalador do cliente diretamente pelo painel app.get('/download-client', async (req, res) => { try { const updatesDir = path.join(__dirname, 'updates'); const files = await fs.readdir(updatesDir); const exeFile = files.find(f => f.endsWith('.exe')); if (exeFile) { res.download(path.join(updatesDir, exeFile), exeFile); } else { res.status(404).send('Instalador do cliente ainda não disponível para download.'); } } catch (error) { res.status(500).send('Erro ao buscar o instalador do cliente.'); } }); // ================================================================ // SPA CATCH-ALL — Todas as rotas não-API servem o React (React Router) // Deve ficar APÓS todas as rotas de API e arquivos estáticos // ================================================================ app.get('*', (req, res, next) => { // Não interceptar rotas de API, uploads ou updates if (req.path.startsWith('/api') || req.path.startsWith('/uploads') || req.path.startsWith('/updates') || req.path.startsWith('/socket.io')) { return next(); } res.sendFile(path.join(__dirname, 'dental-client', 'dist', 'index.html')); }); // ================================================================ // SOCKET.IO - CONEXÕES EM TEMPO REAL // ================================================================ // Middleware de Autenticação para o Socket.IO io.use(async (socket, next) => { const token = socket.handshake.auth?.token || socket.handshake.query?.token; const email = socket.handshake.auth?.email; const password = socket.handshake.auth?.password; // Obter a API KEY do ambiente ou usar uma padrão (fallback local temporário) const serverApiKey = process.env.CLIENT_API_KEY || 'rf-dental-secure-key-2026'; if (!token) { console.log(`❌ Conexão Socket.IO rejeitada (Sem token) - IP: ${socket.handshake.address}`); return next(new Error('Authentication error: Token required')); } // 1. Verificar se é a API_KEY antiga/legado do Sensor if (token === serverApiKey) { socket.clientType = 'sensor'; return next(); // Token válido, prosseguir } // 1.5 Verificar se é um machine_token (usado pelo novo app desktop 1.2.4+) if (email && password && token.includes('-')) { try { const device = await db.get(`SELECT * FROM clinics_devices WHERE machine_token = $1`, [token]); if (device && device.email === email && device.is_active) { const { verifyPassword } = require('./auth'); const validPassword = await verifyPassword(password, device.password_hash); if (validPassword) { console.log(`✅ Conexão Socket.IO autorizada via machine_token - IP: ${socket.handshake.address}`); socket.clientType = 'sensor'; socket.user = { role: 'client_device', clinicId: device.id, clinicName: device.clinic_name, pcName: device.pc_name }; // Atualiza o último IP e data await db.run(`UPDATE clinics_devices SET last_ip = $1 WHERE id = $2`, [socket.handshake.address, device.id]); return next(); // Credenciais válidas } } } catch (e) { console.error('Erro ao validar machine_token no socket:', e); } console.log(`❌ Conexão Socket.IO rejeitada (Machine Token inválido) - IP: ${socket.handshake.address}`); return next(new Error('Authentication error: Invalid machine_token or credentials')); } // 2. Se não for a API_KEY, tentar validar como JWT (Painel Web ou Novo App Desktop) try { const user = jwt.verify(token, JWT_SECRET); // Se o token for do tipo client_device (App Desktop Moderno), ele age como sensor if (user.role === 'client_device') { socket.clientType = 'sensor'; socket.user = user; } else { socket.clientType = 'web'; socket.user = user; } return next(); // JWT válido, prosseguir } catch (error) { console.log(`❌ Conexão Socket.IO rejeitada (Token Inválido) - IP: ${socket.handshake.address}`); return next(new Error('Authentication error: Invalid token')); } }); io.on('connection', (socket) => { console.log('📱 Cliente conectado:', socket.id); console.log('📱 Socket transport:', socket.conn.transport.name); console.log('📱 Socket handshake:', JSON.stringify(socket.handshake.query)); console.log('📱 Remote address:', socket.handshake.address); // Marcar cliente como conectado (mesmo sem identificação) socket.isIdentified = false; // Enviar confirmação imediata de conexão socket.emit('connection-ack', { message: 'Conexão estabelecida com sucesso', socketId: socket.id, serverTime: new Date().toISOString() }); console.log('✅ Confirmação de conexão enviada para:', socket.id); // Tentar identificar automaticamente após 1 segundo setTimeout(() => { if (!socket.isIdentified && socket.connected) { console.log('⚠️ Cliente ainda não identificado após 1s. Socket ainda conectado:', socket.id); // Enviar solicitação de identificação socket.emit('please-identify', { message: 'Por favor, identifique-se enviando o evento client-identify' }); } }, 1000); // Identificação de clientes socket.on('client-identify', (data) => { console.log('👤 [client-identify] Evento recebido de:', socket.id); console.log('👤 Cliente identificado:', data?.type || 'SEM TYPE', data?.name || 'SEM NAME'); console.log('👤 Dados completos:', JSON.stringify(data, null, 2)); if (!data || !data.type || !data.name) { console.error('❌ [client-identify] Dados inválidos:', data); socket.emit('error', { message: 'Dados de identificação inválidos' }); return; } const clientName = data.name.trim(); const clientId = data.clientId || ''; // 1. Validar unicidade em relação aos clientes já conectados const duplicate = connectedClients.find(c => c.name.toLowerCase() === clientName.toLowerCase() && c.clientId !== clientId ); if (duplicate) { console.log(`❌ [client-identify] Conexão rejeitada: O nome "${clientName}" já está em uso por outro dispositivo.`); socket.emit('error', { message: `O nome "${clientName}" já está sendo usado por outro computador neste servidor. Escolha um nome exclusivo.` }); setTimeout(() => { socket.disconnect(true); }, 500); return; } // 2. Se for uma reconexão do mesmo clientId, desconectar e limpar os sockets antigos do mesmo cliente const oldConnections = connectedClients.filter(c => c.clientId === clientId && c.socketId !== socket.id ); for (const old of oldConnections) { console.log(`🔄 Desconectando socket antigo ${old.socketId} para o mesmo clientId ${clientId}`); const oldSocket = io.sockets.sockets.get(old.socketId); if (oldSocket) { oldSocket.emit('error', { message: 'Nova conexão estabelecida deste computador.' }); oldSocket.disconnect(true); } const idx = connectedClients.indexOf(old); if (idx > -1) { connectedClients.splice(idx, 1); } } const clientInfo = { socketId: socket.id, type: data.type || 'unknown', name: clientName, clinicName: socket.user?.clinicName || (data.clinicName || '').trim() || 'Clínica não identificada', version: data.version || '1.0.0', system: data.system || 'unknown', clientId: clientId, connectedAt: new Date().toISOString() }; // Remover cliente anterior se existir const existingIndex = connectedClients.findIndex(c => c.socketId === socket.id); if (existingIndex > -1) { connectedClients.splice(existingIndex, 1); } connectedClients.push(clientInfo); socket.isIdentified = true; console.log('✅ Cliente adicionado à lista. Total conectados:', connectedClients.length); // Confirmar para o cliente socket.emit('server-identified', { success: true, message: 'Identificação confirmada', clientInfo: clientInfo }); console.log('✅ Confirmação enviada para cliente:', socket.id); // Atualizar lista para todos io.emit('clients-list-updated', connectedClients); }); // Receber imagem do cliente (aceitar mesmo sem identificação prévia) socket.on('send-image', async (data, callback) => { try { console.log('📤 [send-image] Evento recebido de:', socket.id); console.log('📤 Cliente identificado?', socket.isIdentified ? 'SIM' : 'NÃO (mas aceitando)'); console.log('📤 Metadata:', data?.metadata?.fileName || 'SEM METADATA'); console.log('📤 Patient Data:', data?.patientData?.name || 'SEM PATIENT DATA'); console.log('📤 Image Base64 length:', data?.imageBase64?.length || 'SEM IMAGEM'); // Validar dados mínimos if (!data || !data.imageBase64) { throw new Error('Dados de imagem incompletos. imageBase64 é obrigatório.'); } if (!data.metadata || !data.metadata.fileName) { throw new Error('Metadata incompleta. fileName é obrigatório.'); } // Verificar se a imagem já foi inserida no banco const existing = await db.get( "SELECT id, filename FROM images WHERE original_filename = $1 AND enabled = 1 LIMIT 1", [data.metadata.fileName] ); if (existing) { console.log(`⚠️ Imagem ${data.metadata.fileName} já existe no servidor (ID: ${existing.id}). Ignorando upload.`); const ackData = { success: true, imageId: existing.id, message: 'Imagem já existe no servidor', filename: existing.filename, originalFilename: data.metadata.fileName, savedAt: new Date().toISOString() }; socket.emit('image-received', ackData); if (typeof callback === 'function') { callback(ackData); } return; } // Decodificar imagem base64 const imageBuffer = Buffer.from(data.imageBase64, 'base64'); // Validar imagem await imageProcessor.validateImage(imageBuffer); // Buscar nome do cliente se identificado const clientInfo = connectedClients.find(c => c.socketId === socket.id); const clientName = clientInfo ? clientInfo.name : 'PC não identificado'; const clinicName = socket.user?.clinicName || (clientInfo ? clientInfo.clinicName : 'Clínica não identificada'); const patientName = data.patientData ? `${data.patientData.firstName || ''} ${data.patientData.lastName || ''}`.trim() || 'Desconhecido' : 'Desconhecido'; // Salvar imagem original local e na nuvem const timestamp = Date.now(); const filename = `${timestamp}_${data.metadata.guid}.png`; await storage.saveImage(filename, imageBuffer, clinicName, clientName, patientName, false); // Gerar e salvar miniatura (Thumbnail) — 500x500, qualidade 80% let thumbFilename = null; try { const thumbBuffer = await imageProcessor.getThumbnail(imageBuffer, 500, 500); thumbFilename = `thumb_${filename}`; await storage.saveImage(thumbFilename, thumbBuffer, clinicName, clientName, patientName, false); } catch (thumbErr) { console.error('Erro ao gerar thumbnail:', thumbErr); // Continua mesmo se falhar o thumbnail, usando fallback no front } // Determinar data de criação const formatLocalDateTime = (date) => { const pad = (num) => String(num).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; }; const createdAt = (data.patientData && data.patientData.photographTime) || formatLocalDateTime(new Date()); // Salvar no banco de dados const result = await db.run( `INSERT INTO images ( image_guid, patient_name, clinic_name, client_name, original_filename, filename, enabled, doctor, remark, thumb_filename, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`, [ data.metadata.guid, data.patientData.name || 'Paciente não identificado', clinicName, clientName, data.metadata.fileName, filename, 1, data.patientData.doctor || null, data.patientData.remark || null, thumbFilename, createdAt ] ); console.log('✅ Imagem salva no banco. ID:', result.lastID); console.log('✅ Arquivo submetido ao storage (Wasabi/Local):', filename); // Confirmar para o cliente const ackData = { success: true, imageId: result.lastID, message: 'Imagem recebida com sucesso', filename: filename, originalFilename: data.metadata.fileName, savedAt: new Date().toISOString() }; socket.emit('image-received', ackData); if (typeof callback === 'function') { callback(ackData); } console.log('✅ Confirmação enviada para cliente:', socket.id); // Notificar web clients (apenas se não for backlog de sincronização inicial) if (!data.isBacklog) { io.emit('new-image', { id: result.lastID, image_guid: data.metadata.guid, patient_name: data.patientData.name, doctor: data.patientData.doctor || null, remark: data.patientData.remark || null, filename: filename, created_at: createdAt }); } } catch (error) { console.error('❌ Erro ao processar imagem:', error); const errorData = { success: false, message: 'Erro ao processar imagem', error: error.message }; socket.emit('error', errorData); if (typeof callback === 'function') { callback(errorData); } } }); // Novo evento: Client solicita Presigned URLs para envio direto ao Wasabi socket.on('request-presigned-urls', async (data, callback) => { try { if (!storage.isWasabiEnabled()) { if (typeof callback === 'function') callback({ success: false, error: 'Wasabi não configurado no servidor.' }); return; } const clientInfo = connectedClients.find(c => c.socketId === socket.id); const clientName = clientInfo ? clientInfo.name : 'PC não identificado'; const clinicName = socket.user?.clinicName || (clientInfo ? clientInfo.clinicName : 'Clínica não identificada'); const patientName = data.patientData ? `${data.patientData.firstName || ''} ${data.patientData.lastName || ''}`.trim() || 'Desconhecido' : 'Desconhecido'; const timestamp = Date.now(); const filename = `${timestamp}_${data.metadata.guid}.png`; const thumbFilename = `${timestamp}_${data.metadata.guid}_thumb.jpg`; const originalUrl = await storage.getUploadPresignedUrl(filename, clinicName, clientName, patientName, 'image/png'); const thumbnailUrl = await storage.getUploadPresignedUrl(thumbFilename, clinicName, clientName, patientName, 'image/jpeg'); if (!originalUrl || !thumbnailUrl) { if (typeof callback === 'function') callback({ success: false, error: 'Falha ao gerar URLs no S3.' }); return; } if (typeof callback === 'function') { callback({ success: true, originalUrl, thumbnailUrl, filename, thumbFilename }); } } catch (error) { console.error('❌ Erro ao gerar presigned URLs:', error); if (typeof callback === 'function') callback({ success: false, error: error.message }); } }); // Novo evento: Client notifica que concluiu o envio direto ao Wasabi socket.on('image-upload-direct', async (data, callback) => { try { console.log('📥 [image-upload-direct] Notificação de upload recebida de:', socket.id); const clientInfo = connectedClients.find(c => c.socketId === socket.id); const clientName = clientInfo ? clientInfo.name : 'PC não identificado'; const clinicName = socket.user?.clinicName || (clientInfo ? clientInfo.clinicName : 'Clínica não identificada'); // Determinar data de criação const formatLocalDateTime = (date) => { const pad = (num) => String(num).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; }; const createdAt = (data.patientData && data.patientData.photographTime) || formatLocalDateTime(new Date()); // Salvar no banco de dados com a referência para a thumbnail const result = await db.run( `INSERT INTO images ( image_guid, patient_name, clinic_name, client_name, original_filename, filename, enabled, doctor, remark, thumb_filename, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`, [ data.metadata.guid, data.patientData.name || 'Paciente não identificado', clinicName, clientName, data.metadata.fileName, data.filename, 1, data.patientData.doctor || null, data.patientData.remark || null, data.thumbFilename, createdAt ] ); console.log('✅ Imagem salva no banco via direct upload. ID:', result.lastID); const ackData = { success: true, imageId: result.lastID, message: 'Imagem salva com sucesso (Direct Upload)', filename: data.filename, originalFilename: data.metadata.fileName, savedAt: new Date().toISOString() }; socket.emit('image-received', ackData); io.emit('new-image-saved', { imageId: result.lastID, filename: data.filename, thumbFilename: data.thumbFilename }); if (typeof callback === 'function') callback(ackData); } catch (error) { console.error('❌ Erro no image-upload-direct:', error); if (typeof callback === 'function') callback({ success: false, error: error.message }); } }); socket.on('check-image-exists', async (data, callback) => { try { if (!data || !data.fileName) { if (typeof callback === 'function') callback({ success: false, error: 'fileName é obrigatório' }); return; } const existing = await db.get( "SELECT id, filename FROM images WHERE original_filename = $1 AND enabled = 1 LIMIT 1", [data.fileName] ); if (typeof callback === 'function') { callback({ success: true, exists: !!existing, imageId: existing ? existing.id : null, filename: existing ? existing.filename : null }); } } catch (error) { console.error('❌ Erro ao verificar existência de imagem:', error); if (typeof callback === 'function') callback({ success: false, error: error.message }); } }); // Debug: Logar todos os eventos recebidos socket.onAny((event, ...args) => { if (event !== 'ping' && event !== 'pong') { console.log(`🔔 [${socket.id}] Evento recebido: ${event}`, args.length > 0 ? '(com dados)' : '(sem dados)'); if (args.length > 0 && event !== 'ping') { console.log(` Dados:`, JSON.stringify(args[0], null, 2).substring(0, 200)); } } }); // Monitorar upgrades de transport socket.conn.on('upgrade', () => { console.log(`🔄 [${socket.id}] Transport upgraded para: ${socket.conn.transport.name}`); }); // Monitorar erros de conexão socket.conn.on('error', (error) => { console.error(`❌ [${socket.id}] Erro de conexão:`, error.message); }); // Heartbeat - manter conexão viva socket.on('ping', () => { socket.emit('pong', { timestamp: new Date().toISOString() }); }); // Handler para teste de conexão socket.on('test-connection', (data) => { console.log('🧪 [test-connection] Evento recebido de:', socket.id); console.log('🧪 Test ID:', data?.testId); const startTime = data.timestamp || Date.now(); const latency = Date.now() - startTime; socket.emit('test-connection-ack', { testId: data.testId, socketId: socket.id, latency: latency, timestamp: new Date().toISOString(), message: 'Teste de conexão bem-sucedido' }); console.log(`✅ [test-connection] Resposta enviada para ${socket.id}. Latência: ${latency}ms`); }); // Desconexão socket.on('disconnect', (reason) => { console.log('📴 Cliente desconectado:', socket.id, '- Motivo:', reason); console.log('📴 Estava identificado?', socket.isIdentified ? 'SIM' : 'NÃO'); // Remover da lista const index = connectedClients.findIndex(c => c.socketId === socket.id); if (index > -1) { const removed = connectedClients.splice(index, 1); console.log('🗑️ Cliente removido da lista:', removed[0].name || socket.id); } // Atualizar lista apenas se havia clientes identificados if (connectedClients.length > 0) { io.emit('clients-list-updated', connectedClients); } }); // Tratamento de erros do socket socket.on('error', (error) => { console.error(`❌ [${socket.id}] Erro no socket:`, error.message); }); }); // ================================================================ // INICIALIZAÇÃO // ================================================================ async function initDirectories() { try { await fs.mkdir(UPLOAD_DIR, { recursive: true }); await fs.mkdir(PROCESSED_DIR, { recursive: true }); console.log('✅ Pastas criadas:', UPLOAD_DIR, PROCESSED_DIR); } catch (error) { console.error('Erro ao criar pastas:', error); } } async function start() { try { // Inicializar diretórios await initDirectories(); // Recarregar .env se existir const envPath = path.join(__dirname, '.env'); const fsSync = require('fs'); if (fsSync.existsSync(envPath)) { require('dotenv').config(); } // Inicializar banco de dados se configurações existirem if (process.env.DB_TYPE === 'sqlite' || (process.env.DB_HOST && process.env.DB_NAME)) { try { await db.initDatabase(); await storage.loadConfigFromDb(); console.log('✅ Banco de dados e Storage inicializados'); } catch (error) { console.log('⚠️ Erro ao inicializar o banco:', error.message); } } else { console.log('⚠️ Banco de dados não configurado. Execute a instalação em /install'); } // Inicializar cliente DragonflyDB/Redis redis.initRedis(); // Iniciar servidor // Backfill de thumbnails ausentes: roda em background após o boot setTimeout(async () => { try { const { runThumbBackfill } = require('./routes/images'); const missing = await db.all( `SELECT id, filename, clinic_name, client_name, patient_name FROM images WHERE thumb_filename IS NULL AND enabled = true ORDER BY created_at DESC` ); if (missing.length > 0) { console.log(`[boot] Iniciando backfill de thumbnails para ${missing.length} imagem(ns) sem thumb...`); runThumbBackfill(missing); } } catch (e) { /* silencia erro de backfill para não afetar o boot */ } }, 5000); server.listen(PORT, () => { console.log('═══════════════════════════════════════════════════════'); console.log('🚀 DENTAL IMAGE SERVER STARTED'); console.log('═══════════════════════════════════════════════════════'); console.log(`📡 Server running on port ${PORT}`); console.log(`🌐 Web interface: http://localhost:${PORT}`); console.log(`🔧 Installation: http://localhost:${PORT}/install`); console.log(`🔐 Login: http://localhost:${PORT}/login`); console.log(`📁 Uploads at: ${UPLOAD_DIR}`); console.log(`🖼️ Processed at: ${PROCESSED_DIR}`); console.log('═══════════════════════════════════════════════════════'); const installedFile = path.join(__dirname, 'data', '.installed'); if (!fsSync.existsSync(installedFile)) { console.log('⚠️ Sistema não instalado. Acesse /install para configurar.'); } }); } catch (error) { console.error('❌ Erro ao iniciar servidor:', error); process.exit(1); } } // ================================================================ // TRATAMENTO DE ERROS // ================================================================ process.on('uncaughtException', (error) => { console.error('❌ Erro não capturado:', error); }); process.on('unhandledRejection', (reason, promise) => { console.error('❌ Promise rejeitada:', reason); }); // ================================================================ // INICIAR // ================================================================ start();