Files
rx.scoreodonto.com-client/client-monitor.js
T
VPS 4 Builder 3392a1090a
continuous-integration/webhook Falha no build do client (VPS4)
feat: CRUD remoto ACKs
2026-06-02 20:52:28 +02:00

1727 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const io = require('socket.io-client');
const chokidar = require('chokidar');
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const http = require('http');
const { exec } = require('child_process');
// Função para gerar o thumbnail usando a CPU local do cliente (via IPC com o index.js do Electron)
async function gerarThumbnail(buffer) {
return new Promise((resolve) => {
if (!process.send) return resolve(null); // Fallback se não for fork
const id = Date.now().toString() + Math.random().toString();
const timeout = setTimeout(() => resolve(null), 5000);
const listener = (message) => {
if (message && message.event === 'resize-result' && message.id === id) {
clearTimeout(timeout);
process.removeListener('message', listener);
if (message.success) resolve(Buffer.from(message.thumbBase64, 'base64'));
else resolve(null);
}
};
process.on('message', listener);
process.send({ event: 'resize-image', id, base64: buffer.toString('base64') });
});
}
// Resolve dynamic folder in AppData or default path passed by Electron
const appDataFolder = process.env.DENTAL_CONFIG_DIR || path.join(
process.env.APPDATA ||
(process.platform === 'darwin' ? path.join(process.env.HOME, 'Library', 'Application Support') : path.join(process.env.HOME, '.config')),
'ScoreOdonto Dental Client'
);
if (!fs.existsSync(appDataFolder)) {
fs.mkdirSync(appDataFolder, { recursive: true });
}
// Carregar configuração dinamicamente para poder atualizá-la
const configPath = path.join(appDataFolder, 'config.json');
const configBackupPath = path.join(appDataFolder, 'config.backup.json');
// Lê e valida um JSON de config; retorna null se ausente/corrompido
function readConfigFileSafe(filePath) {
try {
if (!fs.existsSync(filePath)) return null;
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0) {
return parsed;
}
return null;
} catch (e) {
return null;
}
}
// Grava config.json + cópia de segurança (preserva clientId/credenciais)
function persistConfig() {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
try {
fs.writeFileSync(configBackupPath, JSON.stringify(config, null, 2));
} catch (e) {
console.error('Erro ao gravar backup de config:', e.message);
}
}
let config = {};
let configChanged = false;
// Tenta principal e, se faltar/corromper, restaura do backup antes de cair no default
config = readConfigFileSafe(configPath) || readConfigFileSafe(configBackupPath);
if (config) {
if (!fs.existsSync(configPath)) {
// Restaurado do backup: regrava o principal
configChanged = true;
}
} else {
config = {
serverUrl: "https://rx.scoreodonto.com",
monitorPath: "C:\\ProgramData\\RF\\Dental Sensor\\Images",
clientName: "COMPUTADOR-LOCAL",
clientType: "windows",
apiKey: "rf-dental-secure-key-2026",
adminUsername: "admin",
adminPassword: "admin"
};
configChanged = true;
}
// Garantir que temos um clientId exclusivo para este computador
if (!config.clientId) {
const crypto = require('crypto');
config.clientId = crypto.randomUUID ? crypto.randomUUID() : 'client_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
configChanged = true;
}
if (configChanged) {
try {
persistConfig();
} catch (err) {
console.error('Erro ao inicializar clientId no config.json:', err.message);
}
}
// Função para verificar se o nome do cliente está disponível no servidor remoto
async function checkClientNameAvailability(serverUrl, clientName, apiKey, clientId) {
return new Promise((resolve) => {
try {
let base = serverUrl.trim();
if (base.endsWith('/')) {
base = base.slice(0, -1);
}
const checkUrl = `${base}/api/clients/check-name?name=${encodeURIComponent(clientName.trim())}&token=${encodeURIComponent(apiKey || '')}&clientId=${encodeURIComponent(clientId || '')}`;
const clientModule = checkUrl.startsWith('https') ? require('https') : require('http');
const req = clientModule.get(checkUrl, { timeout: 6000 }, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
try {
if (res.statusCode === 200) {
const data = JSON.parse(body);
if (data.success) {
resolve({ success: true, available: data.available });
return;
}
}
const errMsg = res.statusCode === 401 ? 'Chave de API inválida' : `Código HTTP ${res.statusCode}`;
resolve({ success: false, error: errMsg });
} catch (e) {
resolve({ success: false, error: `Erro de parse: ${e.message}` });
}
});
});
req.on('error', (err) => {
resolve({ success: false, error: err.message });
});
req.on('timeout', () => {
req.destroy();
resolve({ success: false, error: 'Tempo limite esgotado' });
});
} catch (error) {
resolve({ success: false, error: error.message });
}
});
}
// Variáveis de estado global
let socket;
let isConnected = false;
let isIdentified = false;
let watcher;
function sendStatusToParent() {
if (process.send) {
process.send({
event: 'status',
connected: isConnected,
identified: isIdentified
});
}
}
let isSyncing = false;
const recentLogs = [];
// Instancia única global do banco local para evitar sobrecarga de I/O
let localDbInstance = null;
function getLocalDb() {
if (!localDbInstance) {
try {
const { DatabaseSync } = require('node:sqlite');
const dbPath = path.join('C:', 'ProgramData', 'RF', 'Dental Sensor', 'data');
if (fs.existsSync(dbPath)) {
localDbInstance = new DatabaseSync(dbPath);
}
} catch (e) {
console.error('Erro ao abrir banco local:', e.message);
}
}
return localDbInstance;
}
// Cache local de uploads concluídos para evitar reenvio de arquivos no scan inicial
const uploadCachePath = path.join(appDataFolder, 'uploaded_cache.json');
let uploadedCache = new Set();
try {
if (fs.existsSync(uploadCachePath)) {
const cachedFiles = JSON.parse(fs.readFileSync(uploadCachePath, 'utf8'));
uploadedCache = new Set(cachedFiles);
console.log(`💾 Cache de uploads carregado com ${uploadedCache.size} arquivos.`);
}
} catch (e) {
console.error('Erro ao carregar cache de uploads:', e.message);
}
function saveUploadCache() {
try {
fs.writeFileSync(uploadCachePath, JSON.stringify([...uploadedCache], null, 2));
} catch (e) {
console.error('Erro ao salvar cache de uploads:', e.message);
}
}
// Função auxiliar para registrar logs locais e expor no painel
function addLog(msg) {
const timestamp = new Date().toLocaleTimeString();
const formatted = `[${timestamp}] ${msg}`;
console.log(formatted);
recentLogs.push(formatted);
if (recentLogs.length > 50) {
recentLogs.shift();
}
if (process.send) {
process.send({ event: 'log', log: formatted });
}
}
// Interface Web de Administração em HTML/CSS (zero dependências)
function getAdminHtml() {
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Dental Client - Painel</title>
<link rel="icon" type="image/png" href="/favicon.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0b0a12;
--card: rgba(18, 16, 28, 0.85);
--border: rgba(255, 255, 255, 0.08);
--primary: #7c4dff;
--primary-hover: #651fff;
--accent: #00e5ff;
--success: #00e676;
--danger: #ff1744;
--text: #f5f5f7;
--muted: #8e8e93;
--mono: 'JetBrains Mono', monospace;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
html {
height: 100%;
overflow-x: hidden;
}
body {
background: var(--bg);
background-image:
radial-gradient(at 0% 0%, rgba(124, 77, 255, 0.2) 0px, transparent 60%),
radial-gradient(at 100% 100%, rgba(0, 229, 255, 0.12) 0px, transparent 60%);
background-attachment: fixed;
color: var(--text);
font-family: 'Inter', sans-serif;
min-height: 100dvh;
padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0);
-webkit-font-smoothing: antialiased;
}
/* ---- LAYOUT ---- */
.page {
width: 100%;
max-width: 980px;
margin: 0 auto;
padding: 14px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
@media (min-width: 768px) {
.page {
padding: 28px 24px;
gap: 20px;
display: grid;
grid-template-columns: 1.3fr 0.7fr;
grid-template-areas:
"header header"
"config status"
"logs logs";
}
}
/* ---- CARD ---- */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 18px;
padding: 18px 16px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
@media (min-width: 768px) {
.card { padding: 24px 22px; border-radius: 20px; }
}
.card-header { grid-area: header; }
.card-config { grid-area: config; }
.card-status { grid-area: status; }
.card-logs { grid-area: logs; }
/* ---- HEADER CARD ---- */
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon { font-size: 2rem; line-height: 1; }
.logo-text h1 {
font-size: 1.25rem;
font-weight: 700;
background: linear-gradient(135deg, #fff 0%, var(--muted) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.logo-text p {
font-size: 0.75rem;
color: var(--muted);
margin-top: 1px;
}
@media (min-width: 480px) {
.logo-text h1 { font-size: 1.5rem; }
.logo-text p { font-size: 0.8rem; }
}
.status-pill {
display: flex;
align-items: center;
gap: 7px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
padding: 7px 14px;
border-radius: 50px;
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.dot {
width: 9px; height: 9px;
border-radius: 50%;
flex-shrink: 0;
}
.dot.connected {
background: var(--success);
box-shadow: 0 0 10px var(--success);
animation: pulse 2s infinite;
}
.dot.disconnected {
background: var(--danger);
box-shadow: 0 0 10px var(--danger);
}
@keyframes pulse {
0% { transform: scale(0.9); box-shadow: 0 0 0 0 rgba(0,230,118,.7); }
70% { transform: scale(1); box-shadow: 0 0 0 8px rgba(0,230,118,0); }
100% { transform: scale(0.9); box-shadow: 0 0 0 0 rgba(0,230,118,0); }
}
/* ---- SECTION TITLE ---- */
.section-title {
font-size: 0.95rem;
font-weight: 600;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
/* ---- FORM ---- */
.form-group { margin-bottom: 14px; }
label {
display: block;
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 6px;
font-weight: 500;
}
input {
width: 100%;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: 10px;
padding: 11px 14px;
color: #fff;
font-family: 'Inter', sans-serif;
font-size: 0.9rem;
transition: border-color 0.2s, box-shadow 0.2s;
-webkit-appearance: none;
}
input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(124, 77, 255, 0.2);
background: rgba(0,0,0,0.4);
}
/* ---- BUTTONS ---- */
.btn-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 16px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
border-radius: 10px;
padding: 12px 14px;
font-size: 0.875rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.2s ease;
touch-action: manipulation;
user-select: none;
}
.btn:active { transform: scale(0.97); }
.btn-primary {
background: var(--primary);
color: #fff;
}
.btn-primary:hover { background: var(--primary-hover); }
.btn-outline {
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
color: var(--text);
}
.btn-outline:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.2); }
.btn-sm {
padding: 9px 12px;
font-size: 0.82rem;
border-radius: 8px;
width: 100%;
}
/* ---- INFO ROWS ---- */
.info-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
font-size: 0.82rem;
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.03);
}
.info-row:last-child { border-bottom: none; }
.info-label { color: var(--muted); flex-shrink: 0; }
.info-value { font-weight: 500; color: #fff; text-align: right; word-break: break-all; }
.info-mono {
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: -0.3px;
}
/* ---- ACTIONS ---- */
.actions-col {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
}
/* ---- TERMINAL ---- */
.terminal {
background: #050408;
border: 1px solid rgba(255,255,255,0.05);
border-radius: 10px;
padding: 12px;
height: 220px;
overflow-y: auto;
font-family: var(--mono);
font-size: 0.75rem;
line-height: 1.5;
color: #a0a0b0;
-webkit-overflow-scrolling: touch;
}
@media (min-width: 768px) {
.terminal { height: 260px; font-size: 0.8rem; }
}
.log-entry {
margin-bottom: 3px;
white-space: pre-wrap;
border-left: 2px solid rgba(124, 77, 255, 0.35);
padding-left: 8px;
word-break: break-word;
}
/* ---- TOAST ---- */
.toast {
position: fixed;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
left: 50%;
transform: translateX(-50%) translateY(120px);
padding: 12px 22px;
border-radius: 50px;
color: white;
font-weight: 600;
font-size: 0.875rem;
z-index: 9999;
display: none;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
white-space: nowrap;
max-width: calc(100vw - 32px);
animation: toastIn 0.3s cubic-bezier(.34,1.56,.64,1) forwards;
}
@keyframes toastIn {
from { transform: translateX(-50%) translateY(120px); opacity: 0; }
to { transform: translateX(-50%) translateY(0); opacity: 1; }
}
.toast.success { background: #00c853; color: #000; }
.toast.error { background: var(--danger); }
.toast.info { background: var(--primary); }
/* ---- SPINNER ---- */
.spin {
width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: white;
animation: rot 0.8s linear infinite;
display: none;
flex-shrink: 0;
}
@keyframes rot { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="toast" class="toast"></div>
<div class="page">
<!-- HEADER -->
<div class="card card-header">
<div class="header-inner">
<div class="logo">
<span class="logo-icon">🦷</span>
<div class="logo-text">
<h1>Dental Client</h1>
<p>Painel do Administrador</p>
</div>
</div>
<div class="status-pill">
<span id="status-dot" class="dot disconnected"></span>
<span id="status-text">Carregando...</span>
</div>
</div>
</div>
<!-- CONFIG -->
<div class="card card-config">
<div class="section-title">⚙️ Configurações do Cliente</div>
<form id="config-form" onsubmit="saveConfig(event)">
<div class="form-group">
<label for="serverUrl">🌐 URL do Servidor Destino</label>
<input type="text" id="serverUrl" placeholder="http://localhost:3000" required autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false">
</div>
<div class="form-group">
<label for="monitorPath">📁 Pasta para Monitorar</label>
<input type="text" id="monitorPath" placeholder="C:\\ProgramData\\RF\\Dental Sensor\\Images" required>
</div>
<div class="form-group">
<label for="clientName">🖥️ Nome deste Computador</label>
<input type="text" id="clientName" placeholder="PC-SALA-1" required>
</div>
<div class="form-group">
<label for="apiKey">🔑 Chave da API (Servidor VPS)</label>
<input type="password" id="apiKey" placeholder="Sua chave secreta do servidor" required>
</div>
<div class="form-group">
<label for="adminPassword">🔒 Senha do Painel Local</label>
<input type="text" id="adminPassword" placeholder="Senha para proteger esta tela" required>
</div>
<div class="btn-row">
<button type="button" class="btn btn-outline" onclick="testConnection()">
<span id="test-spin" class="spin"></span>
<span id="test-label">⚡ Testar</span>
</button>
<button type="submit" class="btn btn-primary">
<span id="save-spin" class="spin"></span>
<span id="save-label">💾 Salvar</span>
</button>
</div>
</form>
</div>
<!-- STATUS -->
<div class="card card-status">
<div class="section-title">📊 Status do Sistema</div>
<div>
<div class="info-row">
<span class="info-label">Sistema</span>
<span class="info-value" id="info-platform">-</span>
</div>
<div class="info-row">
<span class="info-label">Uptime</span>
<span class="info-value" id="info-uptime">-</span>
</div>
<div class="info-row">
<span class="info-label">Socket ID</span>
<span class="info-value info-mono" id="info-socket-id">-</span>
</div>
</div>
<div class="section-title" style="margin-top: 18px;">🛠️ Atalhos</div>
<div class="actions-col">
<button class="btn btn-outline btn-sm" onclick="openFolder()">📁 Abrir Pasta de Imagens</button>
<button class="btn btn-outline btn-sm" onclick="openServerWeb()">🔗 Abrir Painel do Servidor</button>
</div>
</div>
<!-- LOGS -->
<div class="card card-logs">
<div class="section-title">📜 Atividades Recentes</div>
<div class="terminal" id="logs-container">
<div class="log-entry">Carregando logs...</div>
</div>
</div>
</div>
<script>
let currentConfig = {};
function showToast(msg, type = 'info') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast ' + type;
t.style.display = 'block';
setTimeout(() => { t.style.display = 'none'; }, 3500);
}
async function fetchStatus() {
try {
const res = await fetch('/api/status');
const data = await res.json();
if (!currentConfig.serverUrl) {
currentConfig = data.config;
document.getElementById('serverUrl').value = data.config.serverUrl || '';
document.getElementById('monitorPath').value = data.config.monitorPath || '';
document.getElementById('clientName').value = data.config.clientName || '';
document.getElementById('apiKey').value = data.config.apiKey || '';
document.getElementById('adminPassword').value = data.config.adminPassword || '';
}
const dot = document.getElementById('status-dot');
const text = document.getElementById('status-text');
if (data.connected) {
dot.className = 'dot connected';
text.textContent = data.identified ? 'Identificado' : 'Conectado...';
} else {
dot.className = 'dot disconnected';
text.textContent = 'Desconectado';
}
document.getElementById('info-platform').textContent = data.platform === 'win32' ? 'Windows' : data.platform;
document.getElementById('info-uptime').textContent = fmtUptime(data.uptime);
document.getElementById('info-socket-id').textContent = data.socketId || '—';
const logsEl = document.getElementById('logs-container');
const atBottom = logsEl.scrollHeight - logsEl.clientHeight <= logsEl.scrollTop + 60;
logsEl.innerHTML = data.logs.map(l => '<div class="log-entry">' + esc(l) + '</div>').join('');
if (atBottom) logsEl.scrollTop = logsEl.scrollHeight;
} catch (e) {
document.getElementById('status-dot').className = 'dot disconnected';
document.getElementById('status-text').textContent = 'Erro';
}
}
function fmtUptime(s) {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60);
return h + 'h ' + m + 'm ' + sec + 's';
}
function esc(t) {
return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
async function saveConfig(e) {
e.preventDefault();
const spin = document.getElementById('save-spin');
const lbl = document.getElementById('save-label');
spin.style.display = 'inline-block'; lbl.textContent = ' Salvando...';
try {
const r = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverUrl: document.getElementById('serverUrl').value.trim(),
monitorPath: document.getElementById('monitorPath').value.trim(),
clientName: document.getElementById('clientName').value.trim(),
apiKey: document.getElementById('apiKey').value.trim(),
adminPassword: document.getElementById('adminPassword').value.trim()
})
});
const d = await r.json();
if (d.success) { showToast('Configurações salvas!', 'success'); currentConfig = {}; }
else showToast('Erro: ' + d.error, 'error');
} catch (err) { showToast('Falha: ' + err.message, 'error'); }
finally { spin.style.display = 'none'; lbl.textContent = '💾 Salvar'; }
}
async function testConnection() {
const url = document.getElementById('serverUrl').value.trim();
if (!url) { showToast('Informe a URL.', 'error'); return; }
const spin = document.getElementById('test-spin');
const lbl = document.getElementById('test-label');
spin.style.display = 'inline-block'; lbl.textContent = ' Testando...';
try {
const r = await fetch('/api/test-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const d = await r.json();
if (d.success) showToast(d.message, 'success');
else showToast('Inacessível: ' + d.error, 'error');
} catch (err) { showToast('Falha: ' + err.message, 'error'); }
finally { spin.style.display = 'none'; lbl.textContent = '⚡ Testar'; }
}
async function openFolder() {
try {
const r = await fetch('/api/open-folder', { method: 'POST' });
const d = await r.json();
showToast(d.success ? 'Pasta aberta!' : 'Erro: ' + d.error, d.success ? 'success' : 'error');
} catch { showToast('Erro na requisição.', 'error'); }
}
function openServerWeb() {
const url = document.getElementById('serverUrl').value.trim();
if (url) window.open(url, '_blank');
else showToast('URL não configurada.', 'error');
}
setInterval(fetchStatus, 2000);
fetchStatus();
</script>
</body>
</html>`;
}
function startAdminServer() {
const server = http.createServer((req, res) => {
// Habilitar CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// Parse da URL
const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
// Rota pública: Favicon
if (parsedUrl.pathname === '/favicon.png' && req.method === 'GET') {
const faviconPath = path.join(__dirname, 'favicon.png');
if (fs.existsSync(faviconPath)) {
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(fs.readFileSync(faviconPath));
} else {
res.writeHead(404);
res.end();
}
return;
}
// --- PROTEÇÃO DO PAINEL LOCAL (Basic Auth) ---
const authHeader = req.headers['authorization'];
if (config.adminPassword && config.adminPassword.trim() !== '') {
if (!authHeader) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Dental Client Admin"' });
res.end('Acesso Negado');
return;
}
const authStr = Buffer.from(authHeader.split(' ')[1], 'base64').toString();
const [user, pass] = authStr.split(':');
// Autenticamos apenas pela senha, o usuário pode ser admin ou vazio
if (pass !== config.adminPassword) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Dental Client Admin"' });
res.end('Acesso Negado');
return;
}
}
// ---------------------------------------------
if (parsedUrl.pathname === '/' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(getAdminHtml());
}
else if (parsedUrl.pathname === '/api/status' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
connected: isConnected,
identified: isIdentified,
socketId: socket ? socket.id : null,
config: config,
logs: recentLogs,
platform: process.platform,
uptime: process.uptime()
}));
}
else if (parsedUrl.pathname === '/api/config' && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', async () => {
try {
const newConfig = JSON.parse(body);
if (!newConfig.serverUrl || !newConfig.monitorPath || !newConfig.clientName || !newConfig.apiKey || !newConfig.adminPassword) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Campos obrigatórios ausentes.' }));
return;
}
// Verificar se o nome mudou ou se o servidor mudou para checar disponibilidade
if (newConfig.clientName !== config.clientName || newConfig.serverUrl !== config.serverUrl) {
addLog(`🔍 Verificando se o nome "${newConfig.clientName}" está disponível no servidor...`);
const check = await checkClientNameAvailability(newConfig.serverUrl, newConfig.clientName, newConfig.apiKey, config.clientId);
if (check.success && !check.available) {
addLog(`❌ O nome "${newConfig.clientName}" já está em uso no servidor.`);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: `O nome "${newConfig.clientName}" já está sendo usado por outro computador neste servidor. Escolha um nome exclusivo.` }));
return;
}
}
// Gravar no config.json
config.serverUrl = newConfig.serverUrl;
config.monitorPath = newConfig.monitorPath;
config.clientName = newConfig.clientName;
config.apiKey = newConfig.apiKey;
config.adminPassword = newConfig.adminPassword;
try {
persistConfig();
addLog(`💾 Novas configurações persistidas em config.json`);
} catch (writeErr) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Erro ao salvar config.json: ' + writeErr.message }));
return;
}
// Aplicar dinamicamente
reconnectAndRestart();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: e.message }));
}
});
}
else if (parsedUrl.pathname === '/api/test-server' && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const data = JSON.parse(body);
const testUrl = data.url;
if (!testUrl) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'URL inválida.' }));
return;
}
const checkUrl = testUrl.endsWith('/') ? `${testUrl}status` : `${testUrl}/status`;
const clientModule = checkUrl.startsWith('https') ? require('https') : require('http');
addLog(`⚡ Testando conectividade com: ${checkUrl}`);
const testReq = clientModule.get(checkUrl, { timeout: 4000 }, (testRes) => {
let resBody = '';
testRes.on('data', chunk => resBody += chunk);
testRes.on('end', () => {
if (testRes.statusCode >= 200 && testRes.statusCode < 300) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: `Conexão bem sucedida! Código: ${testRes.statusCode}` }));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: `Código de status inesperado: ${testRes.statusCode}` }));
}
});
});
testReq.on('error', (err) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: err.message }));
});
testReq.on('timeout', () => {
testReq.destroy();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Timeout de 4 segundos esgotado.' }));
});
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: e.message }));
}
});
}
else if (parsedUrl.pathname === '/api/open-folder' && req.method === 'POST') {
try {
const normalizedPath = path.normalize(config.monitorPath);
if (!fs.existsSync(normalizedPath)) {
fs.mkdirSync(normalizedPath, { recursive: true });
}
exec(`explorer.exe "${normalizedPath}"`);
addLog(`📁 Pasta de imagens aberta no Explorer: ${normalizedPath}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: e.message }));
}
}
else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
});
server.listen(3001, '127.0.0.1', () => {
addLog('🌐 Painel de Administração iniciado em: http://localhost:3001');
});
}
// Aplicar alterações do painel dinamicamente
function reconnectAndRestart() {
addLog('🔄 Aplicando novas configurações dinamicamente...');
// 1. Fechar conexão socket anterior
if (socket) {
addLog('🔌 Desconectando socket ativo...');
socket.disconnect();
socket.removeAllListeners();
}
isConnected = false;
isIdentified = false;
sendStatusToParent();
// 2. Parar watcher antigo
if (watcher) {
addLog('👀 Interrompendo monitoramento anterior...');
watcher.close();
}
// 3. Conectar novamente com novas definições
connectToServer();
// 4. Reiniciar monitoramento
setTimeout(() => {
startMonitoring();
}, 1000);
}
// Função de Conexão com o Servidor Dental (Socket.IO)
function connectToServer() {
const serverUrl = process.env.SERVER_URL || config.serverUrl;
const apiKey = process.env.CLIENT_API_KEY || config.apiKey;
const serverEmail = config.serverEmail || '';
const serverPassword = config.serverPassword || '';
const token = config.token || apiKey || '';
const isMachineToken = token && token.includes('-') && token !== 'rf-dental-secure-key-2026';
addLog(`🔄 Conectando ao servidor: ${serverUrl}...`);
addLog(`🔑 Modo de autenticação: ${isMachineToken ? 'Machine Token (Clínica cadastrada)' : '⚠️ API Key genérica — Clínica NÃO identificada'}`);
socket = io(serverUrl, {
transports: ['websocket', 'polling'],
upgrade: true,
timeout: 20000,
reconnection: true,
reconnectionDelay: 2000,
reconnectionAttempts: Infinity,
forceNew: true,
auth: {
token: token,
email: serverEmail,
password: serverPassword
}
});
socket.on('connect_error', (err) => {
addLog(`❌ Erro de Conexão: ${err.message}`);
if (err.message.includes('Authentication error')) {
addLog(`🛑 VERIFIQUE A CHAVE DA API! Acesso negado pelo servidor.`);
}
});
socket.on('connect', () => {
addLog('✅ Conectado ao servidor Socket.IO com sucesso!');
isConnected = true;
sendStatusToParent();
setTimeout(() => identifyClient(), 500);
});
socket.on('disconnect', (reason) => {
addLog(`❌ Desconectado do servidor. Motivo: ${reason}`);
isConnected = false;
isIdentified = false;
sendStatusToParent();
});
socket.on('connection-ack', (data) => {
addLog(`✅ Confirmação de socket recebida. Socket ID: ${data.socketId}`);
});
socket.on('please-identify', () => {
addLog('⚠️ Servidor solicitou identificação do cliente.');
identifyClient();
});
socket.on('server-identified', (data) => {
const info = data.clientInfo || {};
const clinicLabel = info.clinicName && info.clinicName !== 'Clínica não identificada'
? `Clínica: ${info.clinicName}`
: '⚠️ Clínica não identificada — configure o Token no painel de configurações';
addLog(`✅ Identificação confirmada: PC=${info.name} | ${clinicLabel}`);
// Se o servidor enviou o pcName autoritativo (via machine_token),
// atualiza o clientName local para manter em sincronia.
if (info.pcName && info.pcName !== config.clientName) {
addLog(`🔄 Nome do PC atualizado pelo servidor: ${info.pcName}`);
config.clientName = info.pcName;
try { persistConfig(); } catch (_) {}
}
isIdentified = true;
sendStatusToParent();
});
// Ouvinte para o Sinal de Sincronização Remota (Enviado pelo Painel Web)
socket.on('sync-request', async (data) => {
addLog(`🔄 [Sincronização] Sinal recebido do servidor. Timestamp do sinal: ${data?.timestamp || 'N/A'}`);
try {
addLog('🚀 Iniciando varredura local de imagens do sensor...');
await executarSincronizacaoLocal();
} catch (error) {
addLog(`❌ Erro durante o processamento da sincronização remota: ${error.message}`);
}
});
socket.on('connect_error', (error) => {
addLog(`❌ Erro de conexão Socket.IO: ${error.message}`);
});
socket.on('error', (error) => {
addLog(`❌ Erro no socket: ${error.message}`);
});
socket.on('image-received', (data) => {
addLog(`✅ Imagem processada e salva no servidor! ID: ${data.imageId}, Arquivo: ${data.filename}`);
if (data.originalFilename) {
uploadedCache.add(data.originalFilename);
saveUploadCache();
}
});
// Plan B: servidor solicita reenvio de arquivos ausentes no Wasabi
socket.on('reupload-files', async (data) => {
const files = Array.isArray(data.files) ? data.files : [];
if (files.length === 0) return;
addLog(`📥 [Plan B] Servidor solicitou reenvio de ${files.length} arquivo(s) ausente(s) no Wasabi.`);
// Notifica o processo Electron para exibir balão informativo
if (process.send) {
process.send({ event: 'reupload-requested', count: files.length });
}
let reuploadado = 0;
let naoEncontrado = 0;
for (const filename of files) {
if (isSendingPaused) {
addLog(`⏸️ [Plan B] Envio pausado — interrompendo reenvio. ${reuploadado} já reenviados.`);
break;
}
const filePath = path.join(config.monitorPath, filename);
if (!fs.existsSync(filePath)) {
addLog(`⚠️ [Plan B] Arquivo não encontrado localmente: ${filename}`);
naoEncontrado++;
continue;
}
try {
addLog(`♻️ [Plan B] Reenviando: ${filename}`);
// Remove do cache para forçar reenvio mesmo que já conste como enviado
uploadedCache.delete(filename);
await sendImageToServer(filePath, false, true); // bypassCache = true
reuploadado++;
} catch (e) {
addLog(`❌ [Plan B] Falha ao reenviar ${filename}: ${e.message}`);
}
}
addLog(`✅ [Plan B] Reenvio concluído: ${reuploadado} reenviado(s), ${naoEncontrado} não encontrado(s) localmente.`);
});
socket.on('test-connection', (data) => {
addLog(`🧪 Recebido teste de conexão do servidor. 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 respondido pelo cliente'
});
});
socket.on('remote-crud-patient', async (data) => {
addLog(`📩 Recebido comando de CRUD Remoto (Ação: ${data.action})`);
try {
const db = getLocalDb();
if (!db) {
addLog('❌ Falha ao acessar banco de dados local para CRUD.');
if (data.eventId) socket.emit('remote-crud-ack', { eventId: data.eventId, pcName: config.clientName, success: false, error: 'Falha banco local' });
return;
}
if (data.action === 'create') {
const p = data.patient;
addLog(`👤 Criando paciente remoto: ${p.fullName}`);
const stmt = db.prepare('INSERT INTO Patients (Id, FirstName, LastName, Doctor, Remark) VALUES (?, ?, ?, ?, ?)');
stmt.run(p.id, p.firstName || '', p.lastName || '', p.doctor || '', p.remark || '');
addLog('✅ Paciente criado com sucesso localmente!');
} else if (data.action === 'update') {
const oldName = data.oldName;
const p = data.patient;
addLog(`✏️ Atualizando paciente remoto: ${oldName}`);
const stmt = db.prepare("UPDATE Patients SET Doctor = COALESCE(?, Doctor), Remark = COALESCE(?, Remark) WHERE (FirstName || ' ' || LastName) = ?");
stmt.run(p.doctor || null, p.remark || null, oldName);
addLog('✅ Paciente atualizado com sucesso localmente!');
} else if (data.action === 'delete') {
const pName = data.patientName;
addLog(`🗑️ Excluindo paciente remoto: ${pName}`);
const stmt = db.prepare("DELETE FROM Patients WHERE (FirstName || ' ' || LastName) = ?");
stmt.run(pName);
addLog('✅ Paciente excluído com sucesso localmente!');
}
if (data.eventId) {
socket.emit('remote-crud-ack', { eventId: data.eventId, pcName: config.clientName, success: true });
}
} catch (e) {
addLog(`❌ Erro no CRUD remoto: ${e.message}`);
if (data.eventId) {
socket.emit('remote-crud-ack', { eventId: data.eventId, pcName: config.clientName, success: false, error: e.message });
}
}
});
}
// Função para Identificar o Cliente
function identifyClient() {
if (!isConnected || isIdentified) return;
addLog(`👤 Enviando identificação: Nome=${config.clientName || 'Notebook Consultório Principal'}, Tipo=windows`);
socket.emit('client-identify', {
type: 'windows', // MANDATÓRIO: Identifica o notebook na Área de Sincronização
name: config.clientName || 'Notebook Consultório Principal', // Nome amigável que aparecerá no painel
clinicName: config.clinicName || '', // Nome da clínica (preenchido pelo servidor via machine_token auth)
version: '1.1.0',
system: 'windows',
path: config.monitorPath,
clientId: config.clientId // Identificador exclusivo
});
// Reenviar identificação em 3 segundos se não confirmado
setTimeout(() => {
if (!isIdentified && isConnected) {
addLog('⚠️ Confirmação de identificação pendente. Tentando novamente...');
identifyClient();
}
}, 3000);
}
// Função para executar a varredura local e sincronização remota
async function executarSincronizacaoLocal() {
if (isSyncing) {
addLog('⚠️ Sincronização local já está em execução. Ignorando nova chamada concorrente.');
return;
}
if (isSendingPaused) {
addLog('⏸️ Envio pausado — sincronização ignorada. Clique em "Continuar Envio".');
return;
}
isSyncing = true;
const pastaImagensSensor = config.monitorPath;
if (!fs.existsSync(pastaImagensSensor)) {
addLog(`⚠️ Pasta de imagens não encontrada no caminho: ${pastaImagensSensor}`);
isSyncing = false;
return;
}
try {
const arquivos = fs.readdirSync(pastaImagensSensor);
// Ordenar da mais recente para a mais antiga (mtime DESC) para que o
// dentista receba primeiro as imagens do atendimento atual.
const imagens = arquivos
.filter(file => /\.(png|jpg|jpeg)$/i.test(file))
.map(file => ({ file, mtime: fs.statSync(path.join(pastaImagensSensor, file)).mtime.getTime() }))
.sort((a, b) => b.mtime - a.mtime)
.map(({ file }) => file);
addLog(`🔎 Encontradas ${imagens.length} imagens locais no total. Verificando pendências com o servidor...`);
let processadas = 0;
for (const file of imagens) {
if (isSendingPaused) {
addLog(`⏸️ Envio pausado durante a sincronização — interrompendo. ${processadas} já processadas.`);
break;
}
const filePath = path.join(pastaImagensSensor, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
continue;
}
try {
// sendImageToServer envia o arquivo e valida no servidor via check-image-exists, ignorando o cache local.
// Se a imagem existir no servidor, ela será adicionada ao cache e não será reenviada.
await sendImageToServer(filePath, true, true); // isBacklog = true, bypassCache = true
processadas++;
} catch (err) {
addLog(`❌ Falha ao processar imagem ${file} na sincronização: ${err.message}`);
}
}
addLog(`✅ Sincronização concluída. ${processadas} imagens locais processadas.`);
} catch (e) {
addLog(`❌ Erro durante a varredura local de imagens: ${e.message}`);
} finally {
isSyncing = false;
}
}
// Fila para processar uploads de forma sequencial
const uploadQueue = [];
let isProcessingQueue = false;
let isSendingPaused = false; // Controle de pausa/continuação do envio
async function processQueue() {
if (isProcessingQueue) return;
if (isSendingPaused) {
addLog(`⏸️ Envio pausado — ${uploadQueue.length} imagem(ns) aguardando na fila.`);
return;
}
isProcessingQueue = true;
while (uploadQueue.length > 0 && !isSendingPaused) {
const item = uploadQueue.shift();
const nextPath = item.filePath;
const isBacklog = item.isBacklog;
try {
await sendImageToServer(nextPath, isBacklog);
} catch (e) {
addLog(`⚠️ Falha ao processar upload de ${path.basename(nextPath)}: ${e.message}`);
}
}
isProcessingQueue = false;
if (isSendingPaused && uploadQueue.length > 0) {
addLog(`⏸️ Envio pausado — ${uploadQueue.length} imagem(ns) permanecem na fila.`);
}
}
// Função de Monitoramento da Pasta de Imagens (Chokidar)
function startMonitoring() {
const targetDir = config.monitorPath;
if (!fs.existsSync(targetDir)) {
addLog(`💡 Criando pasta de monitoramento: ${targetDir}`);
fs.mkdirSync(targetDir, { recursive: true });
}
addLog(`👀 Iniciando monitoramento de arquivos PNG em: ${targetDir}`);
let isReady = false;
watcher = chokidar.watch(path.join(targetDir, '**/*.png'), {
persistent: true,
ignoreInitial: false,
awaitWriteFinish: {
stabilityThreshold: 1500, // aumentar estabilidade para raios-x grandes
pollInterval: 500
}
});
watcher.on('add', (filePath) => {
// Ignorar pastas internas do servidor se estiver na raiz errada
if (filePath.includes('dental-server')) return;
uploadQueue.push({ filePath, isBacklog: !isReady });
processQueue();
});
watcher.on('ready', () => {
isReady = true;
addLog('✅ Monitoramento Chokidar pronto e aguardando novos raios-X!');
});
watcher.on('error', (error) => {
addLog(`❌ Erro no Chokidar: ${error.message}`);
});
}
// Função para buscar dados do paciente no banco SQLite local do Dental Sensor
function lookupPatientInfo(fileName) {
try {
const db = getLocalDb();
if (!db) {
return null;
}
// Tentar busca exata pelo nome do arquivo
let img = db.prepare("SELECT * FROM Images WHERE LOWER(FileName) = LOWER(?)").get(fileName);
// Se não encontrar, tentar busca por prefixo (primeiros 10 caracteres do GUID)
if (!img) {
const baseName = fileName.replace(/\.png$/i, '').toLowerCase();
if (baseName.length >= 10) {
const prefix = baseName.substring(0, 10);
img = db.prepare("SELECT * FROM Images WHERE FileName LIKE ? LIMIT 1").get(`%${prefix}%`);
}
}
if (img) {
let photographTime = img.PhotographTime;
if (photographTime && photographTime.includes('.')) {
photographTime = photographTime.split('.')[0];
}
const patient = db.prepare("SELECT * FROM Patients WHERE Id = ?").get(img.PatientId);
const firstName = patient ? (patient.FirstName || '').trim() : '';
const lastName = patient ? (patient.LastName || '').trim() : '';
const fullName = `${firstName} ${lastName}`.trim() || 'Sem nome';
const doctor = patient ? (patient.Doctor || '').trim() || 'Sem médico' : 'Sem médico';
const remark = patient ? (patient.Remark || '').trim() || 'Sem observação' : 'Sem observação';
return { name: fullName, doctor, remark, photographTime };
}
} catch (e) {
addLog(`⚠️ Erro ao buscar dados do paciente no banco local: ${e.message}`);
}
return null;
}
// Notifica o processo Electron (index.js) de erro no Wasabi via IPC.
// Throttle de 60s para não inundar o usuário num backlog grande.
let lastWasabiErrorNotifyAt = 0;
function notifyWasabiError(message) {
if (!process.send) return;
const now = Date.now();
if (now - lastWasabiErrorNotifyAt < 60000) return;
lastWasabiErrorNotifyAt = now;
process.send({ event: 'wasabi-error', message });
}
// Enviar imagem para o servidor via Socket.IO e aguardar confirmação
function sendImageToServer(filePath, isBacklog, bypassCache = false) {
return new Promise(async (resolve, reject) => {
try {
if (!isConnected) {
addLog(`⚠️ Falha ao enviar: Sem conexão com o servidor.`);
resolve();
return;
}
if (!isIdentified) {
identifyClient();
await new Promise(r => setTimeout(r, 1200));
}
const fileName = path.basename(filePath);
if (!bypassCache && uploadedCache.has(fileName)) {
resolve();
return;
}
// Verificar no servidor se a imagem já existe antes de ler e codificar em base64
const existsOnServer = await new Promise((res) => {
socket.emit('check-image-exists', { fileName }, (resData) => {
if (resData && resData.success && resData.exists) {
res(true);
} else {
res(false);
}
});
// Timeout de segurança para a verificação
setTimeout(() => res(false), 5000);
});
if (existsOnServer) {
addLog(`️ Imagem ${fileName} já existe no servidor. Adicionando ao cache local.`);
uploadedCache.add(fileName);
saveUploadCache();
resolve();
return;
}
let fileBuffer = fs.readFileSync(filePath);
const originalSize = fileBuffer.length;
const isImage = /\.(png|jpg|jpeg)$/i.test(fileName);
let thumbBuffer = null;
if (isImage) {
try {
addLog(`⚡ Gerando miniatura localmente para: ${fileName}...`);
thumbBuffer = await gerarThumbnail(fileBuffer);
if (thumbBuffer) {
addLog(`✅ Miniatura gerada com sucesso (${Math.round(thumbBuffer.length / 1024)} KB)`);
} else {
addLog(`⚠️ Não foi possível gerar miniatura, o servidor precisará usar o original.`);
}
} catch (e) {
addLog(`⚠️ Erro ao gerar miniatura: ${e.message}`);
}
}
let patientName = "Cliente não identificado";
let doctor = 'Sem médico';
let remark = 'Sem observação';
let photographTime = null;
const localInfo = lookupPatientInfo(fileName);
if (localInfo) {
patientName = localInfo.name;
doctor = localInfo.doctor;
remark = localInfo.remark;
photographTime = localInfo.photographTime;
addLog(`🔍 Dados do paciente: "${patientName}", Dentista: "${doctor}"`);
} else {
try {
const stat = fs.statSync(filePath);
const fileDate = stat.mtime || stat.birthtime || new Date();
const pad = (num) => String(num).padStart(2, '0');
photographTime = `${fileDate.getFullYear()}-${pad(fileDate.getMonth() + 1)}-${pad(fileDate.getDate())} ${pad(fileDate.getHours())}:${pad(fileDate.getMinutes())}:${pad(fileDate.getSeconds())}`;
} catch (e) {
addLog(`⚠️ Erro ao ler metadados: ${e.message}`);
}
}
const metadata = {
fileName: fileName,
guid: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
fileSize: fileBuffer.length
};
const patientData = {
name: patientName,
doctor: doctor,
remark: remark,
photographTime: photographTime
};
addLog(`📤 Solicitando URLs temporárias para envio de: ${fileName} (${Math.round(fileBuffer.length / 1024)} KB)`);
// Pedir Presigned URLs
socket.emit('request-presigned-urls', { metadata, patientData }, async (urlResponse) => {
if (urlResponse && urlResponse.success) {
try {
addLog(`🌐 Fazendo Direct Upload pro Wasabi (Bypass VPS)...`);
// Fazer PUT da original
await fetch(urlResponse.originalUrl, {
method: 'PUT',
body: fileBuffer,
headers: { 'Content-Type': 'image/png' }
});
// Fazer PUT do thumb se existir
if (thumbBuffer && urlResponse.thumbnailUrl) {
await fetch(urlResponse.thumbnailUrl, {
method: 'PUT',
body: thumbBuffer,
headers: { 'Content-Type': 'image/jpeg' }
});
}
addLog(`✅ Upload direto concluído! Notificando servidor...`);
// Notificar o servidor que o upload terminou
socket.emit('image-upload-direct', {
metadata,
patientData,
filename: urlResponse.filename,
thumbFilename: thumbBuffer ? urlResponse.thumbFilename : null
}, (response) => {
if (response && response.success) {
uploadedCache.add(response.originalFilename);
saveUploadCache();
resolve();
} else {
reject(new Error(response ? response.error : 'Erro na notificação de direct-upload'));
}
});
} catch (uploadErr) {
addLog(`❌ Falha no Direct Upload: ${uploadErr.message}. Tentando via VPS...`);
notifyWasabiError(`Falha no upload direto ao Wasabi: ${uploadErr.message}. Usando envio via VPS como alternativa.`);
fallbackUpload();
}
} else {
addLog(`️ Wasabi não disponível no servidor. Usando envio padrão via VPS.`);
notifyWasabiError(`Wasabi indisponível no servidor. Imagens sendo enviadas via VPS (mais lento).`);
fallbackUpload();
}
});
function fallbackUpload() {
let didTimeout = false;
const timeoutId = setTimeout(() => {
didTimeout = true;
reject(new Error(`Timeout de 45 segundos aguardando resposta do servidor para: ${fileName}`));
}, 45000);
socket.emit('send-image', {
imageBase64: fileBuffer.toString('base64'),
isBacklog: isBacklog,
metadata: metadata,
patientData: patientData
}, (response) => {
if (didTimeout) return;
clearTimeout(timeoutId);
if (response && response.success) {
addLog(`✅ Confirmação do servidor recebida: ${response.message || 'sucesso'}`);
if (response.originalFilename) {
uploadedCache.add(response.originalFilename);
saveUploadCache();
}
resolve();
} else {
reject(new Error(response ? response.error : 'Erro desconhecido do servidor'));
}
});
}
} catch (error) {
addLog(`❌ Erro ao ler/enviar arquivo: ${error.message}`);
reject(error);
}
});
}
// Configuração interativa via terminal CLI (fallback)
function runSetupWizard() {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
console.log('\n=======================================================');
console.log('⚙️ CONFIGURAÇÃO DO DENTAL CLIENT');
console.log('=======================================================');
console.log('Pressione ENTER para manter o valor atual entre colchetes.\n');
rl.question(`🌐 IP/URL do Servidor Destino [${config.serverUrl}]: `, (urlInput) => {
config.serverUrl = urlInput.trim() || config.serverUrl;
rl.question(`📁 Pasta para Monitorar Imagens [${config.monitorPath}]: `, (pathInput) => {
config.monitorPath = pathInput.trim() || config.monitorPath;
const askName = () => {
rl.question(`🖥️ Nome deste Computador [${config.clientName}]: `, async (nameInput) => {
const chosenName = nameInput.trim() || config.clientName;
console.log(`🔍 Verificando se o nome "${chosenName}" está disponível no servidor...`);
const check = await checkClientNameAvailability(config.serverUrl, chosenName, config.apiKey, config.clientId);
if (check.success && !check.available) {
console.log(`❌ O nome "${chosenName}" JÁ está sendo usado no servidor!`);
console.log(`⚠️ Escolha outro nome exclusivo para evitar duplicidade de buckets ou vazamento de dados.\n`);
askName();
} else {
if (!check.success) {
console.log(`⚠️ Não foi possível validar o nome no servidor (${check.error}).`);
console.log(` Prosseguindo com o nome "${chosenName}" por precaução...\n`);
} else {
console.log(`✅ Nome "${chosenName}" disponível!\n`);
}
config.clientName = chosenName;
try {
persistConfig();
console.log('═══════════════════════════════════════════════════════');
console.log('✅ CONFIGURAÇÃO SALVA COM SUCESSO EM config.json!');
console.log(` Servidor Destino: ${config.serverUrl}`);
console.log(` Pasta Monitorada: ${config.monitorPath}`);
console.log(` Nome do Cliente : ${config.clientName}`);
console.log('═══════════════════════════════════════════════════════\n');
} catch (err) {
console.error('❌ Erro ao salvar config.json:', err.message);
}
rl.close();
resolve();
}
});
};
askName();
});
});
});
}
// Função Principal
async function main() {
const args = process.argv.slice(2);
const needsSetup = args.includes('--setup') || args.includes('--config') || !config.serverUrl;
if (needsSetup) {
await runSetupWizard();
}
addLog('🦷 DENTAL CLIENT - Monitor de Imagens');
addLog('═══════════════════════════════════════════════════════');
addLog(`📁 Monitorando: ${config.monitorPath}`);
addLog(`🌐 Servidor : ${config.serverUrl}`);
addLog(`🖥️ Cliente : ${config.clientName}`);
addLog('═══════════════════════════════════════════════════════');
// Iniciar servidor HTTP de administração local apenas se não rodar no Electron
if (!process.send) {
startAdminServer();
} else {
sendStatusToParent();
}
// Conectar ao Socket.IO
connectToServer();
// Iniciar monitor de arquivos após 2 segundos
setTimeout(startMonitoring, 2000);
}
main();
// Encerrar processos de forma limpa
process.on('SIGINT', () => {
addLog('🛑 Sinal de encerramento recebido. Desconectando e saindo...');
if (socket) socket.disconnect();
if (watcher) watcher.close();
process.exit(0);
});
process.on('uncaughtException', (error) => {
addLog(`❌ Erro não capturado no processo: ${error.message}`);
});
if (process.send) {
process.on('message', async (msg) => {
if (msg && msg.command === 'sync') {
addLog('🔄 [IPC] Recebida solicitação de varredura local...');
await executarSincronizacaoLocal();
}
if (msg && msg.command === 'pause') {
isSendingPaused = true;
addLog('⏸️ [IPC] Envio de imagens PAUSADO pelo usuário.');
}
if (msg && msg.command === 'resume') {
isSendingPaused = false;
addLog('▶️ [IPC] Envio de imagens RETOMADO. Processando fila pendente...');
processQueue();
}
});
}