1542 lines
58 KiB
JavaScript
1542 lines
58 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
|
|
});
|
|
|
|
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()
|
|
});
|
|
});
|
|
|
|
// 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('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');
|
|
}
|
|
}
|
|
}));
|
|
|
|
// 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 (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.
|
|
if (token.includes('-')) {
|
|
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, 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)
|
|
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á 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 patientName = (data.patientData && data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido';
|
|
|
|
// Salvar imagem original local e na nuvem
|
|
const timestamp = Date.now();
|
|
const filename = `${timestamp}_${data.metadata.guid}.png`;
|
|
await storage.saveImage(filename, imageBuffer, clinicName, clientName, patientName, false);
|
|
|
|
// Gerar e salvar miniatura (Thumbnail) — 500x500, qualidade 80%
|
|
let thumbFilename = null;
|
|
try {
|
|
const thumbBuffer = await imageProcessor.getThumbnail(imageBuffer, 500, 500);
|
|
thumbFilename = `thumb_${filename}`;
|
|
await storage.saveImage(thumbFilename, thumbBuffer, clinicName, clientName, patientName, false);
|
|
} catch (thumbErr) {
|
|
console.error('Erro ao gerar thumbnail:', thumbErr);
|
|
// Continua mesmo se falhar o thumbnail, usando fallback no front
|
|
}
|
|
|
|
// Determinar data de criação
|
|
const formatLocalDateTime = (date) => {
|
|
const pad = (num) => String(num).padStart(2, '0');
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
};
|
|
|
|
const createdAt = (data.patientData && data.patientData.photographTime) || formatLocalDateTime(new Date());
|
|
|
|
// Salvar no banco de dados
|
|
const result = await db.run(
|
|
`INSERT INTO images (
|
|
image_guid,
|
|
patient_name,
|
|
clinic_name,
|
|
client_name,
|
|
original_filename,
|
|
filename,
|
|
enabled,
|
|
doctor,
|
|
remark,
|
|
thumb_filename,
|
|
created_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`,
|
|
[
|
|
data.metadata.guid,
|
|
(data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido',
|
|
clinicName,
|
|
clientName,
|
|
data.metadata.fileName,
|
|
filename,
|
|
1,
|
|
data.patientData.doctor || null,
|
|
data.patientData.remark || null,
|
|
thumbFilename,
|
|
createdAt
|
|
]
|
|
);
|
|
|
|
console.log('✅ Imagem salva no banco. ID:', result.lastID);
|
|
console.log('✅ Arquivo submetido ao storage (Wasabi/Local):', filename);
|
|
|
|
// Confirmar para o cliente
|
|
const ackData = {
|
|
success: true,
|
|
imageId: result.lastID,
|
|
message: 'Imagem recebida com sucesso',
|
|
filename: filename,
|
|
originalFilename: data.metadata.fileName,
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
socket.emit('image-received', ackData);
|
|
if (typeof callback === 'function') {
|
|
callback(ackData);
|
|
}
|
|
console.log('✅ Confirmação enviada para cliente:', socket.id);
|
|
|
|
// Notificar web clients (apenas se não for backlog de sincronização inicial)
|
|
if (!data.isBacklog) {
|
|
io.emit('new-image', {
|
|
id: result.lastID,
|
|
image_guid: data.metadata.guid,
|
|
patient_name: data.patientData.name,
|
|
doctor: data.patientData.doctor || null,
|
|
remark: data.patientData.remark || null,
|
|
filename: filename,
|
|
created_at: createdAt
|
|
});
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('❌ Erro ao processar imagem:', error);
|
|
const errorData = {
|
|
success: false,
|
|
message: 'Erro ao processar imagem',
|
|
error: error.message
|
|
};
|
|
socket.emit('error', errorData);
|
|
if (typeof callback === 'function') {
|
|
callback(errorData);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Novo evento: Client solicita Presigned URLs para envio direto ao Wasabi
|
|
socket.on('request-presigned-urls', async (data, callback) => {
|
|
try {
|
|
if (!storage.isWasabiEnabled()) {
|
|
if (typeof callback === 'function') callback({ success: false, error: 'Wasabi não configurado no servidor.' });
|
|
return;
|
|
}
|
|
|
|
const clientInfo = connectedClients.find(c => c.socketId === socket.id);
|
|
const clientName = clientInfo ? clientInfo.name : 'PC não identificado';
|
|
const clinicName = socket.user?.clinicName || clientInfo?.clinicName || 'Clínica não identificada';
|
|
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`;
|
|
|
|
const originalUrl = await storage.getUploadPresignedUrl(filename, clinicName, clientName, patientName, 'image/png');
|
|
const thumbnailUrl = await storage.getUploadPresignedUrl(thumbFilename, clinicName, clientName, patientName, 'image/jpeg');
|
|
|
|
if (!originalUrl || !thumbnailUrl) {
|
|
if (typeof callback === 'function') callback({ success: false, error: 'Falha ao gerar URLs no S3.' });
|
|
return;
|
|
}
|
|
|
|
if (typeof callback === 'function') {
|
|
callback({
|
|
success: true,
|
|
originalUrl,
|
|
thumbnailUrl,
|
|
filename,
|
|
thumbFilename
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Erro ao gerar presigned URLs:', error);
|
|
if (typeof callback === 'function') callback({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// Novo evento: Client notifica que concluiu o envio direto ao Wasabi
|
|
socket.on('image-upload-direct', async (data, callback) => {
|
|
try {
|
|
console.log('📥 [image-upload-direct] Notificação de upload recebida de:', socket.id);
|
|
|
|
const clientInfo = connectedClients.find(c => c.socketId === socket.id);
|
|
const clientName = clientInfo ? clientInfo.name : 'PC não identificado';
|
|
const clinicName = socket.user?.clinicName || clientInfo?.clinicName || 'Clínica não identificada';
|
|
|
|
// Determinar data de criação
|
|
const formatLocalDateTime = (date) => {
|
|
const pad = (num) => String(num).padStart(2, '0');
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
};
|
|
|
|
const createdAt = (data.patientData && data.patientData.photographTime) || formatLocalDateTime(new Date());
|
|
|
|
// Salvar no banco de dados com a referência para a thumbnail
|
|
const result = await db.run(
|
|
`INSERT INTO images (
|
|
image_guid,
|
|
patient_name,
|
|
clinic_name,
|
|
client_name,
|
|
original_filename,
|
|
filename,
|
|
enabled,
|
|
doctor,
|
|
remark,
|
|
thumb_filename,
|
|
created_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`,
|
|
[
|
|
data.metadata.guid,
|
|
(data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido',
|
|
clinicName,
|
|
clientName,
|
|
data.metadata.fileName,
|
|
data.filename,
|
|
1,
|
|
data.patientData.doctor || null,
|
|
data.patientData.remark || null,
|
|
data.thumbFilename,
|
|
createdAt
|
|
]
|
|
);
|
|
|
|
console.log('✅ Imagem salva no banco via direct upload. ID:', result.lastID);
|
|
|
|
const ackData = {
|
|
success: true,
|
|
imageId: result.lastID,
|
|
message: 'Imagem salva com sucesso (Direct Upload)',
|
|
filename: data.filename,
|
|
originalFilename: data.metadata.fileName,
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
socket.emit('image-received', ackData);
|
|
io.emit('new-image-saved', { imageId: result.lastID, filename: data.filename, thumbFilename: data.thumbFilename });
|
|
|
|
if (typeof callback === 'function') callback(ackData);
|
|
|
|
} catch (error) {
|
|
console.error('❌ Erro no image-upload-direct:', error);
|
|
if (typeof callback === 'function') callback({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
socket.on('check-image-exists', async (data, callback) => {
|
|
try {
|
|
if (!data || !data.fileName) {
|
|
if (typeof callback === 'function') callback({ success: false, error: 'fileName é obrigatório' });
|
|
return;
|
|
}
|
|
|
|
const existing = await db.get(
|
|
"SELECT id, filename, 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();
|
|
});
|
|
|
|
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();
|