fix(sync): re-upload correto quando arquivo foi deletado do storage
continuous-integration/webhook Deploy concluído (VPS4)

- storage.js: adiciona fileExists() com HeadObject (sem baixar o arquivo)
- check-image-exists: verifica existência física no storage, não só no banco;
  se registro existe mas arquivo não, apaga o registro órfão e retorna exists=false
- send-image: mesma lógica — registro órfão é removido e re-upload prossegue
- Remove migrate-to-wasabi.js do repositório

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-05-31 02:33:33 +02:00
parent 91881ab7a3
commit 7cdd82eeb4
3 changed files with 78 additions and 119 deletions
-97
View File
@@ -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); });
+35 -21
View File
@@ -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) {
+43 -1
View File
@@ -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),