1346 lines
48 KiB
JavaScript
1346 lines
48 KiB
JavaScript
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');
|
|
}
|
|
|
|
// ================================================================
|
|
// SERVIDOR DE FRONTEND ESTÁTICO COM CACHE BUSTER AUTOMÁTICO
|
|
// ================================================================
|
|
|
|
// Página de instalação
|
|
app.get('/install', (req, res) => {
|
|
serveHtmlWithCacheBuster(res, path.join(__dirname, 'public', 'install.html'));
|
|
});
|
|
|
|
// Página web principal
|
|
app.get('/', (req, res) => {
|
|
serveHtmlWithCacheBuster(res, path.join(__dirname, 'public', 'index.html'));
|
|
});
|
|
|
|
// Favicon redirect/fallback
|
|
app.get('/favicon.ico', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'favicon.svg'));
|
|
});
|
|
|
|
// Página de clientes conectados
|
|
app.get('/clients', (req, res) => {
|
|
serveHtmlWithCacheBuster(res, path.join(__dirname, 'public', 'clients.html'));
|
|
});
|
|
|
|
// Página de reset de fábrica
|
|
app.get('/reset', (req, res) => {
|
|
serveHtmlWithCacheBuster(res, path.join(__dirname, 'public', 'reset.html'));
|
|
});
|
|
|
|
// Página de Clínicas
|
|
app.get('/admin-clinics', (req, res) => {
|
|
serveHtmlWithCacheBuster(res, path.join(__dirname, 'public', 'admin-clinics.html'));
|
|
});
|
|
|
|
// Rotas de autenticação (públicas)
|
|
app.use('/api/auth', authRoutes);
|
|
app.use('/api/client', clientAuthRoutes);
|
|
|
|
// Página de login
|
|
app.get('/login', (req, res) => {
|
|
serveHtmlWithCacheBuster(res, path.join(__dirname, 'auth', 'login.html'));
|
|
});
|
|
|
|
// Middleware de autenticação estática (movemos pro final depois)
|
|
|
|
// Função utilitária de Cache Buster
|
|
async function serveHtmlWithCacheBuster(res, filePath) {
|
|
try {
|
|
// Lê version.txt (não package.json) para não ter cache-miss no Docker a cada deploy
|
|
let APP_VERSION;
|
|
try {
|
|
APP_VERSION = (await fs.readFile(path.join(__dirname, 'version.txt'), 'utf8')).trim();
|
|
} catch (_) {
|
|
APP_VERSION = require('./package.json').version || Date.now();
|
|
}
|
|
let content = await fs.readFile(filePath, 'utf8');
|
|
|
|
// Injeta versão em tags de script e css
|
|
content = content.replace(/src="([^"]+\.js)(?:\?v=[^"]*)?"/g, `src="$1?v=${APP_VERSION}"`);
|
|
content = content.replace(/href="([^"]+\.css)(?:\?v=[^"]*)?"/g, `href="$1?v=${APP_VERSION}"`);
|
|
// Substitui o número de versão exibido na sidebar (elimina hardcode no HTML)
|
|
content = content.replace(/(<span[^>]*>)\s*v\d+\.\d+\.\d+\s*(<\/span>)/g, `$1v${APP_VERSION}$2`);
|
|
|
|
res.set('Content-Type', 'text/html');
|
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
|
res.send(content);
|
|
} catch (err) {
|
|
console.error('Erro ao ler HTML:', err);
|
|
res.status(500).send('Erro interno ao carregar a página.');
|
|
}
|
|
}
|
|
|
|
|
|
// ================================================================
|
|
// 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();
|
|
publicImageRoutes.get('/:id/thumbnail', async (req, res) => {
|
|
try {
|
|
const image = await db.get('SELECT thumb_filename, filename, client_name, patient_name FROM images WHERE id = $1', [req.params.id]);
|
|
if (!image) return res.status(404).json({ error: 'Imagem não encontrada' });
|
|
|
|
const fileToCheck = image.thumb_filename || image.filename;
|
|
let url;
|
|
|
|
const fsSync = require('fs');
|
|
const path = require('path');
|
|
const localPath = path.join(process.env.UPLOAD_DIR || '/app/uploads', fileToCheck);
|
|
|
|
if (fsSync.existsSync(localPath)) {
|
|
url = `/uploads/${fileToCheck}`;
|
|
} else {
|
|
try {
|
|
const wasabiUrl = await storage.getImageUrl(fileToCheck, image.client_name, image.patient_name, false);
|
|
url = wasabiUrl || `/uploads/${fileToCheck}`;
|
|
} catch (e) {
|
|
console.error('Erro ao gerar URL Wasabi para thumbnail:', e);
|
|
url = `/uploads/${fileToCheck}`;
|
|
}
|
|
}
|
|
res.json({ url });
|
|
} catch (e) {
|
|
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,
|
|
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 : 'Cliente não identificado';
|
|
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, 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, 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,
|
|
client_name,
|
|
original_filename,
|
|
filename,
|
|
enabled,
|
|
doctor,
|
|
remark,
|
|
thumb_filename,
|
|
created_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
|
[
|
|
data.metadata.guid,
|
|
data.patientData.name || 'Paciente não identificado',
|
|
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 : 'Cliente não identificado';
|
|
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, clientName, patientName, 'image/png');
|
|
const thumbnailUrl = await storage.getUploadPresignedUrl(thumbFilename, 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 : 'Cliente não identificado';
|
|
|
|
// 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,
|
|
client_name,
|
|
original_filename,
|
|
filename,
|
|
enabled,
|
|
doctor,
|
|
remark,
|
|
thumb_filename,
|
|
created_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
|
[
|
|
data.metadata.guid,
|
|
data.patientData.name || 'Paciente não identificado',
|
|
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, 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();
|