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(); // Carrega secrets locais (desenvolvimento) sem sobrescrever vars já definidas pelo container require('dotenv').config({ path: '.env.local', override: false }); // 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 }); app.set('io', io); global.io = io; global.pendingAcks = new Map(); 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() }); }); // Version endpoint — fonte de verdade para todos os sistemas app.get('/api/version', async (req, res) => { try { const updatesDir = path.join(__dirname, 'updates'); const versionPath = path.join(__dirname, 'version.txt'); let serverVersion = '0.0.0'; try { serverVersion = (await fs.readFile(versionPath, 'utf8')).trim(); } catch (_) {} let clientVersion = '0.0.0'; let clientFile = null; try { const yml = await fs.readFile(path.join(updatesDir, 'latest.yml'), 'utf8'); const ver = yml.match(/^version:\s*(.+)/m); const pth = yml.match(/^path:\s*(.+\.exe)/m); if (ver) clientVersion = ver[1].trim(); if (pth) clientFile = pth[1].trim(); } catch (_) {} res.json({ server: serverVersion, client: clientVersion, client_file: clientFile, client_url: `${req.protocol}://${req.get('host')}/download-client`, }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 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 }); } }); // Health check padronizado (status do backend + banco + cache). Padrão da stack: // ver git.clube67.com/ruicesar/Documentos-Unicos → deploy/versionamento.md app.get('/api/health', async (req, res) => { const checks = {}; try { const t = Date.now(); await db.get('SELECT 1'); checks.database = { status: 'ok', latency_ms: Date.now() - t }; } catch { checks.database = { status: 'down' }; } try { const client = redis.getClient(); if (client) { const t = Date.now(); await client.ping(); checks.cache = { status: 'ok', latency_ms: Date.now() - t }; } else checks.cache = { status: 'disabled' }; } catch { checks.cache = { status: 'down' }; } // Em PROD a versão vem da TAG (APP_VERSION, injetada em runtime pelo deploy); // em DEV cai para version.txt (arquivo da imagem). Padrão "build once, promote". let version = (process.env.APP_VERSION && process.env.APP_VERSION !== 'dev') ? process.env.APP_VERSION : 'dev'; if (version === 'dev') { try { version = (await fs.readFile(path.join(__dirname, 'version.txt'), 'utf8')).trim(); } catch {} } const dbOk = checks.database.status === 'ok'; const status = !dbOk ? 'down' : (checks.cache.status === 'down' ? 'degraded' : 'ok'); res.status(dbOk ? 200 : 503).json({ status, version, commit: process.env.GIT_COMMIT || 'dev', environment: (process.env.APP_ENV || 'DEV').toUpperCase(), uptime_s: Math.round(process.uptime()), checks, }); }); // 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); }); // Clientes Windows ATIVOS = sockets autenticados como dispositivo (machine_token / // api_key de sensor / JWT client_device), MESMO que ainda não tenham emitido // 'client-identify'. Assim a página /clients mostra um PC online que já está // enviando RX (o send-image aceita sem identificação prévia). Painéis web são // excluídos (clientType 'web'). const identById = new Map(identifiedClients.map(c => [c.socketId, c])); const activeClients = []; for (const s of sockets) { const isDevice = s.clientType === 'sensor' || s.user?.role === 'client_device'; if (!isDevice) continue; const ident = identById.get(s.id); if (ident) { activeClients.push({ ...ident, identified: true, online: true }); } else { activeClients.push({ socketId: s.id, type: 'sensor', name: s.user?.pcName || s.handshake?.query?.name || 'PC (não identificado)', clinicName: s.user?.clinicName || 'Clínica não identificada', pcName: s.user?.pcName || '', version: s.handshake?.query?.version || s.handshake?.auth?.version || '—', system: s.handshake?.query?.system || 'Windows', clientId: '', identified: false, online: true, connectedAt: null, }); } } res.json({ totalConnections: sockets.length, identifiedClients: identifiedClients.length, activeClients: activeClients.length, clients: clients, identified: identifiedClients, // compat (clientes que emitiram client-identify) active: activeClients, // inclui dispositivos autenticados sem client-identify 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('sync-request', { timestamp: Date.now() }); 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); // GET /api/auth/device-info?token=xxx — valida machine_token e retorna info do dispositivo. // Usado pelo client para verificar o token antes de salvar (sem precisar de email/senha). app.get('/api/auth/device-info', async (req, res) => { try { const token = (req.query.token || '').trim(); if (!token) return res.status(400).json({ success: false, error: 'Token obrigatório.' }); const device = await db.get( 'SELECT clinic_name, pc_name, email, is_active FROM clinics_devices WHERE machine_token = $1', [token] ); if (!device) return res.status(404).json({ success: false, error: 'Token não encontrado.' }); if (!device.is_active) return res.status(403).json({ success: false, error: 'Dispositivo desativado.' }); res.json({ success: true, clinicName: device.clinic_name, pcName: device.pc_name }); } catch (e) { res.status(500).json({ success: false, error: e.message }); } }); // ================================================================ // 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) // ================================================================ // ================================================================ // PLAN B — Fila de reenvio por cliente // Quando nem thumb nem original existem no Wasabi, o servidor // registra o original_filename na fila do cliente que enviou a imagem. // Na próxima conexão (ou imediatamente se já estiver online) o servidor // emite 'reupload-files' para que o cliente reenvie os arquivos ausentes. // ================================================================ const pendingReupload = new Map(); // clientName → Set function scheduleReupload(image) { if (!image.original_filename || !image.client_name) return; const key = image.client_name; if (!pendingReupload.has(key)) pendingReupload.set(key, new Set()); pendingReupload.get(key).add(image.original_filename); // Se o cliente já estiver online, notifica imediatamente const clientInfo = connectedClients.find(c => c.name === key); if (clientInfo) { const clientSocket = io.sockets.sockets.get(clientInfo.socketId); if (clientSocket) { const files = [...pendingReupload.get(key)]; clientSocket.emit('reupload-files', { files }); pendingReupload.delete(key); console.log(`📤 [plan-b] ${files.length} arquivo(s) solicitado(s) para reenvio → ${key}`); } } } // Middleware que pula autenticação para rotas públicas de imagens const publicImageRoutes = express.Router(); // Tenta ler um arquivo do storage com fallback para o path legado ('Desconhecido'). // Uploads antigos foram salvos no Wasabi com patientName='Desconhecido' porque o handler // de presigned-urls usava firstName/lastName (que o client nunca envia) em vez de name. async function getImageBufferWithFallback(filename, clinicName, clientName, patientName) { try { const buf = await storage.getImageBuffer(filename, clinicName, clientName, patientName); if (buf) return buf; } catch (_) {} // Fallback: path legado onde patientName era sempre 'Desconhecido' if (patientName !== 'Desconhecido') { try { const buf = await storage.getImageBuffer(filename, clinicName, clientName, 'Desconhecido'); if (buf) return buf; } catch (_) {} } return null; } // 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, original_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). res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // 1. Já existe thumbnail — serve direto (local ou Wasabi, com fallback legado) if (image.thumb_filename) { try { const buf = await getImageBufferWithFallback(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 (com fallback legado), gera a thumb na hora const originalBuf = await getImageBufferWithFallback(image.filename, image.clinic_name, image.client_name, image.patient_name); if (!originalBuf) { // Plan B: solicita ao cliente que reenvie o arquivo original ausente scheduleReupload(image); return res.status(404).json({ error: 'Imagem original não encontrada no storage. Reenvio solicitado ao cliente.' }); } 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 (path correto) 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'); } } })); // Alias curto para compatibilidade com links diretos app.get('/download', (req, res) => res.redirect(301, '/download-client')); // Rota para baixar o instalador do cliente diretamente pelo painel app.get('/download-client', async (req, res) => { try { const updatesDir = path.join(__dirname, 'updates'); let exeFile; try { const yml = await fs.readFile(path.join(updatesDir, 'latest.yml'), 'utf8'); const match = yml.match(/^path:\s*(.+\.exe)/m); if (match) exeFile = match[1].trim(); } catch (_) {} if (!exeFile) { const files = await fs.readdir(updatesDir); exeFile = files.filter(f => f.endsWith('.exe')).sort().at(-1); } 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.'); } }); // Limpa EXEs antigos da pasta updates — mantém apenas os 2 mais recentes async function pruneOldInstallers() { try { const updatesDir = path.join(__dirname, 'updates'); const files = await fs.readdir(updatesDir); const exes = files.filter(f => f.endsWith('.exe')).sort(); // ordem alfabética = cronológica pelo nome const toDelete = exes.slice(0, Math.max(0, exes.length - 2)); for (const f of toDelete) { await fs.unlink(path.join(updatesDir, f)).catch(() => {}); await fs.unlink(path.join(updatesDir, `${f}.blockmap`)).catch(() => {}); console.log(`🗑️ [updates] EXE antigo removido: ${f}`); } } catch (_) {} } // ================================================================ // 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(); } const userAgent = req.headers['user-agent'] || ''; const isMobile = /mobile/i.test(userAgent) || /android/i.test(userAgent) || /iphone/i.test(userAgent); // Serve the Mobile Prototype Bundle for mobile devices or explicit /mobile route if (req.path.startsWith('/mobile') || isMobile) { return res.sendFile(path.join(__dirname, 'public_mobile', 'index.html')); } 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 (UUID com hífens — app desktop) // O machine_token é credencial suficiente por si só (UUID único gerado pelo servidor). // Se email+senha também forem enviados, valida os dois para compatibilidade. const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token); if (isUUID) { try { const device = await db.get(`SELECT * FROM clinics_devices WHERE machine_token = $1`, [token]); if (device && device.is_active) { // Se email e senha foram enviados, valida-os também (case-insensitive no email) if (email && password) { const emailMatch = device.email.toLowerCase() === email.trim().toLowerCase(); if (!emailMatch) { console.log(`❌ [machine_token] Email não confere para device id=${device.id}`); return next(new Error('Authentication error: Invalid machine_token or credentials')); } const { verifyPassword } = require('./auth'); const validPassword = await verifyPassword(password, device.password_hash); if (!validPassword) { console.log(`❌ [machine_token] Senha inválida para device id=${device.id}`); return next(new Error('Authentication error: Invalid machine_token or credentials')); } } // machine_token válido (com ou sem email+senha) console.log(`✅ [machine_token] Conexão autorizada — Clínica: ${device.clinic_name} PC: ${device.pc_name}`); socket.clientType = 'sensor'; socket.user = { role: 'client_device', clinicId: device.id, tenantId: device.tenant_id, clinicaId: device.clinica_id, clinicName: device.clinic_name, pcName: device.pc_name }; await db.run(`UPDATE clinics_devices SET last_ip = $1 WHERE id = $2`, [socket.handshake.address, device.id]); return next(); } // Token com hífens mas não é machine_token → pode ser JWT, deixa prosseguir } catch (e) { console.error('Erro ao validar machine_token no socket:', e); } } // 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); // Ouvinte para receber confirmações do CRUD remoto socket.on('remote-crud-ack', (data) => { if (data && data.eventId && global.pendingAcks && global.pendingAcks.has(data.eventId)) { const resolve = global.pendingAcks.get(data.eventId); resolve(data); global.pendingAcks.delete(data.eventId); } }); // 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) { console.error('❌ [client-identify] Dados inválidos:', data); socket.emit('error', { message: 'Dados de identificação inválidos' }); return; } // Se autenticado via machine_token, o pc_name do cadastro é autoritativo const clientName = (socket.user?.pcName || data.name || 'PC não identificado').trim(); const clientId = data.clientId || ''; const clinicName = socket.user?.clinicName || (data.clinicName || '').trim() || 'Clínica não identificada'; // 1. Validar unicidade: mesmo PC da mesma clínica já conectado (clientId diferente) // Usa clinicName + clientName para não bloquear PCs homônimos de clínicas diferentes. const duplicate = connectedClients.find(c => c.name.toLowerCase() === clientName.toLowerCase() && c.clinicName.toLowerCase() === clinicName.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', pcName: socket.user?.pcName || clientName, 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); // Plan B: enviar fila de reenvio pendente para este cliente const pending = pendingReupload.get(clientName); if (pending && pending.size > 0) { const files = [...pending]; socket.emit('reupload-files', { files }); pendingReupload.delete(clientName); console.log(`📤 [plan-b] ${files.length} arquivo(s) pendente(s) enviados para ${clientName} ao reconectar`); } // Atualizar lista para todos io.emit('clients-list-updated', connectedClients); }); // Receber imagem do cliente (aceitar mesmo sem identificação prévia) // LEGADO: envio de imagem em base64 pela VPS. O client Windows atual (v1.3.x) // usa Direct Upload (request-presigned-urls + image-upload-direct). Mantido como // fallback p/ versões antigas do .exe em campo — candidato a remoção quando se // confirmar que nenhuma clínica roda versão pré-Direct-Upload. socket.on('send-image', async (data, callback) => { try { console.log('📤 [send-image] (legado) 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á existe no banco E no storage const existing = await db.get( "SELECT id, filename, clinic_name, client_name, patient_name FROM images WHERE original_filename = $1 AND enabled = 1 LIMIT 1", [data.metadata.fileName] ); if (existing) { const physicallyExists = await storage.fileExists(existing.filename, existing.clinic_name, existing.client_name, existing.patient_name); if (physicallyExists) { 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; } // Registro órfão (arquivo deletado do storage) — remove para re-upload limpo console.log(`🔄 Registro órfão encontrado para ${data.metadata.fileName} (ID: ${existing.id}). Removendo para re-upload.`); await db.run('DELETE FROM images WHERE id = $1', [existing.id]); } // 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?.clinicName || 'Clínica não identificada'; const clinicaId = socket.user?.clinicaId || clientInfo?.clinicaId || null; // clínica (scoreodonto) const tenantId = socket.user?.tenantId || clientInfo?.tenantId || null; // conta/dono (scoreodonto) const patientName = (data.patientData && data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido'; // Salvar imagem original local e na nuvem (key nova tenant/clinica quando houver) const timestamp = Date.now(); const filename = `${timestamp}_${data.metadata.guid}.png`; await storage.saveImage(filename, imageBuffer, clinicName, clientName, patientName, false, tenantId, clinicaId); // 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, tenantId, clinicaId); } 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, clinica_id, tenant_id, client_name, original_filename, filename, enabled, doctor, remark, thumb_filename, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`, [ data.metadata.guid, (data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido', clinicName, clinicaId, tenantId, 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?.clinicName || 'Clínica não identificada'; const clinicaId = socket.user?.clinicaId || clientInfo?.clinicaId || null; // clínica (scoreodonto) const tenantId = socket.user?.tenantId || clientInfo?.tenantId || null; // conta/dono (scoreodonto) const patientName = (data.patientData && data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido'; const timestamp = Date.now(); const filename = `${timestamp}_${data.metadata.guid}.png`; const thumbFilename = `${timestamp}_${data.metadata.guid}_thumb.jpg`; // Key NOVA tenant/clinica/paciente quando temos o tenant; senão legada. const originalUrl = await storage.getUploadPresignedUrl(filename, clinicName, clientName, patientName, 'image/png', tenantId, clinicaId); const thumbnailUrl = await storage.getUploadPresignedUrl(thumbFilename, clinicName, clientName, patientName, 'image/jpeg', tenantId, clinicaId); 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); // Dedup/idempotência: se já existe linha p/ este original_filename, remove a antiga // (reenvio/resync substitui em vez de duplicar). Evita linhas órfãs duplicadas. if (data?.metadata?.fileName) { await db.run('DELETE FROM images WHERE original_filename = $1', [data.metadata.fileName]).catch(() => {}); } const clientInfo = connectedClients.find(c => c.socketId === socket.id); const clientName = clientInfo ? clientInfo.name : 'PC não identificado'; const clinicName = socket.user?.clinicName || clientInfo?.clinicName || 'Clínica não identificada'; const clinicaId = socket.user?.clinicaId || clientInfo?.clinicaId || null; // clínica (scoreodonto) const tenantId = socket.user?.tenantId || clientInfo?.tenantId || null; // conta/dono (scoreodonto) // 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, clinica_id, tenant_id, client_name, original_filename, filename, enabled, doctor, remark, thumb_filename, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`, [ data.metadata.guid, (data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido', clinicName, clinicaId, tenantId, 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, clinic_name, client_name, patient_name FROM images WHERE original_filename = $1 AND enabled = 1 LIMIT 1", [data.fileName] ); // Registro no banco só conta se o arquivo realmente existir no storage let physicallyExists = false; if (existing) { physicallyExists = await storage.fileExists(existing.filename, existing.clinic_name, existing.client_name, existing.patient_name); if (!physicallyExists) { // Apaga o registro órfão para o re-upload criar um novo await db.run('DELETE FROM images WHERE id = $1', [existing.id]); } } if (typeof callback === 'function') { callback({ success: true, exists: physicallyExists, imageId: physicallyExists ? existing.id : null, filename: physicallyExists ? 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(); // ================================================================ // CRON — Tarefas automáticas em background // ================================================================ const THUMB_CRON_INTERVAL_MS = 10 * 60 * 1000; // 10 minutos const VERIFY_CRON_INTERVAL_MS = 30 * 60 * 1000; // 30 minutos const cronState = { thumbBackfill: { name: 'Geração de Thumbnails', description: 'Gera e envia ao Wasabi as miniaturas de RX que ainda não possuem thumbnail (thumb_filename IS NULL).', intervalMs: THUMB_CRON_INTERVAL_MS, running: false, lastRun: null, nextRun: null, lastResult: null, }, verifyStorage: { name: 'Verificação de Integridade', description: 'Verifica se os arquivos de imagem existem no Wasabi; para os ausentes aciona Plan B (reenvio pelo cliente Windows).', intervalMs: VERIFY_CRON_INTERVAL_MS, running: false, lastRun: null, nextRun: null, lastResult: null, }, }; // ── Cron 1: thumb backfill (images sem thumb_filename) ────────── async function runThumbCron() { if (cronState.thumbBackfill.running) return; cronState.thumbBackfill.running = true; cronState.thumbBackfill.lastRun = new Date().toISOString(); cronState.thumbBackfill.nextRun = new Date(Date.now() + THUMB_CRON_INTERVAL_MS).toISOString(); console.log('[cron:thumbs] Iniciando varredura de thumbnails ausentes...'); 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 = 1 ORDER BY created_at DESC` ); if (missing.length > 0) { console.log(`[cron:thumbs] ${missing.length} imagem(ns) sem thumbnail — processando...`); await runThumbBackfill(missing); cronState.thumbBackfill.lastResult = { total: missing.length, status: 'ok' }; } else { console.log('[cron:thumbs] Nenhuma imagem pendente.'); cronState.thumbBackfill.lastResult = { total: 0, status: 'ok' }; } } catch (e) { console.error('[cron:thumbs] Erro:', e.message); cronState.thumbBackfill.lastResult = { total: 0, status: 'error', error: e.message }; } finally { cronState.thumbBackfill.running = false; } } // ── Cron 2: verificar se arquivos existem no storage ──────────── // Percorre imagens com thumb_filename preenchido e verifica se o // arquivo (original ou thumb) está realmente no Wasabi. Para os // ausentes, chama scheduleReupload() para acionar o Plan B. async function runVerifyStorageCron() { if (cronState.verifyStorage.running) return; cronState.verifyStorage.running = true; cronState.verifyStorage.lastRun = new Date().toISOString(); cronState.verifyStorage.nextRun = new Date(Date.now() + VERIFY_CRON_INTERVAL_MS).toISOString(); console.log('[cron:verify] Iniciando verificação de integridade do storage...'); let checked = 0, missing = 0, requested = 0; try { // Limita a 200 por execução para não sobrecarregar const rows = await db.all( `SELECT id, filename, thumb_filename, original_filename, clinic_name, client_name, patient_name FROM images WHERE enabled = 1 ORDER BY created_at DESC LIMIT 200` ); for (const row of rows) { checked++; const buf = await getImageBufferWithFallback( row.thumb_filename || row.filename, row.clinic_name, row.client_name, row.patient_name ); if (!buf) { missing++; scheduleReupload(row); requested++; console.log(`[cron:verify] ⚠️ Arquivo ausente id=${row.id} (${row.original_filename}) → Plan B acionado`); } } cronState.verifyStorage.lastResult = { checked, missing, requested, status: 'ok' }; console.log(`[cron:verify] Concluído: ${checked} verificadas, ${missing} ausentes, ${requested} reenvios solicitados.`); } catch (e) { console.error('[cron:verify] Erro:', e.message); cronState.verifyStorage.lastResult = { checked, missing, requested, status: 'error', error: e.message }; } finally { cronState.verifyStorage.running = false; } } // Rodar na inicialização e depois em intervalos setTimeout(() => { cronState.thumbBackfill.nextRun = new Date(Date.now() + THUMB_CRON_INTERVAL_MS).toISOString(); runThumbCron(); }, 5000); setInterval(runThumbCron, THUMB_CRON_INTERVAL_MS); // Verificação de storage: 1ª vez após 15s (depois do boot estabilizar) setTimeout(() => { cronState.verifyStorage.nextRun = new Date(Date.now() + VERIFY_CRON_INTERVAL_MS).toISOString(); runVerifyStorageCron(); }, 15000); setInterval(runVerifyStorageCron, VERIFY_CRON_INTERVAL_MS); // GET /api/cron/status — estado dos cron jobs (admin) app.get('/api/cron/status', authenticateToken, (req, res) => { res.json( Object.entries(cronState).map(([key, job]) => ({ key, name: job.name, description: job.description, intervalMinutes: Math.round(job.intervalMs / 60000), running: job.running, lastRun: job.lastRun, nextRun: job.nextRun, lastResult: job.lastResult, })) ); }); // POST /api/cron/run/:key — dispara um cron job manualmente (admin) app.post('/api/cron/run/:key', authenticateToken, async (req, res) => { const runners = { thumbBackfill: runThumbCron, verifyStorage: runVerifyStorageCron }; const fn = runners[req.params.key]; if (!fn) return res.status(404).json({ error: 'Cron job não encontrado.' }); res.json({ success: true, message: 'Cron iniciado em background.' }); fn(); }); // GET /api/admin/rx-health — reconciliação banco↔Wasabi por tenant/clínica (read-only). // sem params: contagens por (tenant_id, clinica_id) + último resultado do cron de storage. // ?clinica_id=&checkLimit=N: HEAD-check das N imagens recentes da clínica → ausentes. app.get('/api/admin/rx-health', authenticateToken, requireAdmin, async (req, res) => { try { const byClinic = await db.all( `SELECT tenant_id, clinica_id, MAX(clinic_name) AS clinic_name, COUNT(*)::int AS image_count, COUNT(DISTINCT patient_name)::int AS patient_count, MAX(created_at) AS last_image_at FROM images WHERE enabled = 1 GROUP BY tenant_id, clinica_id ORDER BY clinic_name` ); let missingCheck = null; const clinicaId = req.query.clinica_id; const checkLimit = Math.min(500, parseInt(req.query.checkLimit) || 0); if (clinicaId && checkLimit > 0) { const rows = await db.all( `SELECT id, filename, thumb_filename, clinic_name, client_name, patient_name FROM images WHERE enabled = 1 AND clinica_id = $1 ORDER BY created_at DESC LIMIT $2`, [clinicaId, checkLimit] ); let checked = 0, missing = 0; const missingIds = []; for (const row of rows) { checked++; const ok = await storage.fileExists(row.thumb_filename || row.filename, row.clinic_name, row.client_name, row.patient_name); if (!ok) { missing++; if (missingIds.length < 50) missingIds.push(row.id); } } missingCheck = { clinica_id: clinicaId, checked, missing, missingIds }; } res.json({ by_clinic: byClinic, storage_verify_cron: cronState.verifyStorage?.lastResult || null, storage_verify_last_run: cronState.verifyStorage?.lastRun || null, missing_check: missingCheck, generated_at: new Date().toISOString(), }); } catch (e) { res.status(500).json({ error: e.message }); } }); 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('═══════════════════════════════════════════════════════'); // Limpa EXEs antigos ao iniciar pruneOldInstallers(); // Watch for client updates try { const updatesDir = path.join(__dirname, 'updates'); const fsSync = require('fs'); if (fsSync.existsSync(updatesDir)) { let updateTimeout = null; fsSync.watch(updatesDir, (eventType, filename) => { if (filename === 'latest.yml') { if (updateTimeout) clearTimeout(updateTimeout); updateTimeout = setTimeout(() => { console.log('📦 Nova atualização detectada em latest.yml. Notificando clientes...'); pruneOldInstallers(); io.emit('force-update', { version: 'latest' }); }, 5000); } }); } } catch (e) { console.error('Erro ao configurar watcher de atualizações:', e.message); } const installedFile = path.join(__dirname, 'data', '.installed'); if (!require('fs').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();