diff --git a/dental-server/migrate-to-wasabi.js b/dental-server/migrate-to-wasabi.js deleted file mode 100644 index f09e8d5..0000000 --- a/dental-server/migrate-to-wasabi.js +++ /dev/null @@ -1,97 +0,0 @@ -// ──────────────────────────────────────────────────────────────────────────── -// migrate-to-wasabi.js — Migração one-off: envia arquivos locais (originais + -// thumbs) para o Wasabi e verifica via HEAD. NÃO apaga o local (segurança). -// Uso: docker exec rx_scoreodonto_app node /app/migrate-to-wasabi.js -// ──────────────────────────────────────────────────────────────────────────── -const db = require('./database'); -const storage = require('./storage'); -const fs = require('fs'); -const path = require('path'); -const { S3Client, HeadObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3'); - -const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads'; -const PROCESSED_DIR = process.env.PROCESSED_DIR || '/app/processed'; - -function sanitize(seg) { - if (!seg) return 'desconhecido'; - return seg.trim().replace(/[\/\\?#%*:"<>|]/g, '_'); -} -function contentTypeFor(filename) { - const ext = path.extname(filename).toLowerCase(); - if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; - if (ext === '.webp') return 'image/webp'; - return 'image/png'; -} -function findLocal(filename) { - for (const dir of [UPLOAD_DIR, PROCESSED_DIR]) { - const p = path.join(dir, filename); - if (fs.existsSync(p)) return p; - } - return null; -} - -(async () => { - await db.initDatabase(); - await storage.loadConfigFromDb(); - const cfg = JSON.parse((await db.get("SELECT value FROM system_config WHERE key='wasabi_config'")).value); - const ep = cfg.wasabiEndpoint.startsWith('http') ? cfg.wasabiEndpoint : 'https://' + cfg.wasabiEndpoint; - const s3 = new S3Client({ - region: cfg.wasabiRegion, endpoint: ep, - credentials: { accessKeyId: cfg.wasabiAccessKey, secretAccessKey: cfg.wasabiSecretKey }, - forcePathStyle: true, - }); - const bucket = cfg.wasabiBucket; - - const rows = await db.all( - 'SELECT id, filename, thumb_filename, clinic_name, client_name, patient_name FROM images ORDER BY id' - ); - console.log(`\n🔎 ${rows.length} imagens no banco. Migrando para bucket "${bucket}"...\n`); - - let uploaded = 0, already = 0, missingLocal = 0, failed = 0; - - async function ensure(filename, clinic, client, patient) { - if (!filename) return; - const key = `${sanitize(clinic)}/${sanitize(client)}/${sanitize(patient)}/${filename}`; - // Já existe no Wasabi? - try { await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key })); already++; return; } - catch (e) { if (e.name !== 'NotFound' && e.$metadata?.httpStatusCode !== 404) { /* outro erro: tenta upload mesmo assim */ } } - // Ler local - const local = findLocal(filename); - if (!local) { missingLocal++; return; } - try { - const body = await fs.promises.readFile(local); - await s3.send(new PutObjectCommand({ - Bucket: bucket, Key: key, - Body: body, ContentType: contentTypeFor(filename), - })); - uploaded++; - if (uploaded % 50 === 0) console.log(` … ${uploaded} enviados (${already} já existiam)`); - } catch (e) { failed++; if (failed <= 10) console.log(` ❌ falha ${key}: ${e.name} ${e.message}`); } - } - - // Monta lista de tarefas (original + thumb por imagem) e processa com concorrência - const tasks = []; - for (const r of rows) { - tasks.push([r.filename, r.clinic_name, r.client_name, r.patient_name]); - if (r.thumb_filename) tasks.push([r.thumb_filename, r.clinic_name, r.client_name, r.patient_name]); - } - const CONCURRENCY = Number(process.env.MIG_CONCURRENCY) || 40; - console.log(`📦 ${tasks.length} arquivos a verificar (concorrência ${CONCURRENCY})\n`); - - let idx = 0; - async function worker() { - while (idx < tasks.length) { - const i = idx++; - const [f, cl, c, p] = tasks[i]; - await ensure(f, cl, c, p); - } - } - await Promise.all(Array.from({ length: CONCURRENCY }, worker)); - - console.log(`\n✅ Migração concluída:`); - console.log(` enviados agora: ${uploaded}`); - console.log(` já no Wasabi: ${already}`); - console.log(` sem local: ${missingLocal}`); - console.log(` falhas: ${failed}`); - process.exit(0); -})().catch(e => { console.error('ERRO FATAL:', e); process.exit(1); }); diff --git a/dental-server/server.js b/dental-server/server.js index 5e24b60..5523d8c 100644 --- a/dental-server/server.js +++ b/dental-server/server.js @@ -877,26 +877,30 @@ io.on('connection', (socket) => { throw new Error('Metadata incompleta. fileName é obrigatório.'); } - // Verificar se a imagem já foi inserida no banco + // Verificar se a imagem já existe no banco E no storage const existing = await db.get( - "SELECT id, filename FROM images WHERE original_filename = $1 AND enabled = 1 LIMIT 1", + "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) { - 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); + 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; } - 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 @@ -1125,18 +1129,28 @@ io.on('connection', (socket) => { if (typeof callback === 'function') callback({ success: false, error: 'fileName é obrigatório' }); return; } - + const existing = await db.get( - "SELECT id, filename FROM images WHERE original_filename = $1 AND enabled = 1 LIMIT 1", + "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: !!existing, - imageId: existing ? existing.id : null, - filename: existing ? existing.filename : null + exists: physicallyExists, + imageId: physicallyExists ? existing.id : null, + filename: physicallyExists ? existing.filename : null }); } } catch (error) { diff --git a/dental-server/storage.js b/dental-server/storage.js index 29a39da..5c97dcf 100644 --- a/dental-server/storage.js +++ b/dental-server/storage.js @@ -1,4 +1,4 @@ -const { S3Client, GetObjectCommand, DeleteObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3'); +const { S3Client, GetObjectCommand, DeleteObjectCommand, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const { Upload } = require('@aws-sdk/lib-storage'); const fs = require('fs').promises; @@ -369,12 +369,54 @@ async function getUploadPresignedUrl(filename, clinicName, clientName, patientNa } } +/** + * Verifica se o arquivo existe no storage (local ou Wasabi) sem baixar o conteúdo. + * Retorna true se encontrado em qualquer um dos dois lugares. + */ +async function fileExists(filename, clinicName = null, clientName = null, patientName = null) { + // Verificar local primeiro + const localPaths = [ + path.join(UPLOAD_DIR, filename), + path.join(PROCESSED_DIR, filename), + ]; + for (const p of localPaths) { + if (fsSync.existsSync(p)) return true; + } + + if (!s3 || !currentBucket) return false; + + try { + let clName = clinicName; + let cName = clientName; + let pName = patientName; + + if (!cName || !pName) { + try { + const row = await db.get( + 'SELECT clinic_name, client_name, patient_name FROM images WHERE filename = $1 OR thumb_filename = $2 LIMIT 1', + [filename, filename] + ); + if (row) { clName = row.clinic_name; cName = row.client_name; pName = row.patient_name; } + } catch (_) {} + } + + const key = `${sanitizePathSegment(clName)}/${sanitizePathSegment(cName)}/${sanitizePathSegment(pName)}/${filename}`; + await s3.send(new HeadObjectCommand({ Bucket: currentBucket, Key: key })); + return true; + } catch (e) { + if (e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404) return false; + // Erro de rede/auth — assume não existe para forçar re-upload + return false; + } +} + module.exports = { loadConfigFromDb, saveImage, getImageBuffer, getImageUrl, deleteImage, + fileExists, getDownloadPresignedUrl, getUploadPresignedUrl, isWasabiEnabled: () => !!(s3 && currentBucket),