Files
rx.scoreodonto.com/dental-server/server.js
T
VPS 4 Deploy Agent 016305ebf0 feat(rx): healthcheck de reconciliação banco<->Wasabi por tenant/clínica (T3.2)
GET /api/admin/rx-health (admin): contagens por (tenant_id, clinica_id) + último
resultado do cron de storage; ?clinica_id=&checkLimit=N faz HEAD-check bounded de
objetos ausentes no Wasabi. Read-only, aditivo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:14:43 +02:00

1737 lines
67 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();
// 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 });
}
});
// 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<original_filename>
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 <img src>.
// 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=<c>&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();