Files
rx.scoreodonto.com/dental-server/server.js
T

1070 lines
36 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');
// ================================================================
// 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
});
}
});
// 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 {
const pkg = require('./package.json');
const APP_VERSION = pkg.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}"`);
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 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 static files e rotas estáticas
app.use('/login', express.static('auth'));
app.use(express.static('public'));
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.');
}
});
// ================================================================
// SOCKET.IO - CONEXÕES EM TEMPO REAL
// ================================================================
// Middleware de Autenticação para o Socket.IO
io.use((socket, next) => {
const token = socket.handshake.auth?.token || socket.handshake.query?.token;
// 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
}
// 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 = true 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);
// Salvar imagem original
const timestamp = Date.now();
const filename = `${timestamp}_${data.metadata.guid}.png`;
const filePath = path.join(UPLOAD_DIR, filename);
await fs.writeFile(filePath, 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';
// 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,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
data.metadata.guid,
data.patientData.name || 'Paciente não identificado',
clientName,
data.metadata.fileName,
filename,
1,
data.patientData.doctor || null,
data.patientData.remark || null,
createdAt
]
);
console.log('✅ Imagem salva no banco. ID:', result.lastID);
console.log('✅ Arquivo salvo em:', filePath);
// 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);
}
}
});
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 = true 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();
console.log('✅ Banco de dados inicializado');
} 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
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();