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; const fsSync = require('fs'); const path = require('path'); const db = require('./database'); const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, 'uploads'); const PROCESSED_DIR = process.env.PROCESSED_DIR || path.join(__dirname, 'processed'); let s3 = null; let currentBucket = null; let lastWasabiError = null; // Inicializa ou reconfigura o S3 Client a partir do banco de dados async function loadConfigFromDb() { try { const row = await db.get("SELECT value FROM system_config WHERE key = 'wasabi_config'"); if (row && row.value) { const cfg = JSON.parse(row.value); const accessKey = cfg.wasabiAccessKey; const secretKey = cfg.wasabiSecretKey; const bucket = cfg.wasabiBucket; const region = cfg.wasabiRegion || 'us-east-1'; const rawEndpoint = cfg.wasabiEndpoint || 's3.wasabisys.com'; const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`; if (accessKey && secretKey && bucket) { s3 = new S3Client({ region, endpoint, credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, forcePathStyle: true, }); currentBucket = bucket; lastWasabiError = null; console.log(`✅ [Wasabi] Inicializado com sucesso — Bucket: ${bucket}`); } else { s3 = null; currentBucket = null; lastWasabiError = null; console.log('⚠️ [Wasabi] Credenciais incompletas no banco. Wasabi desativado.'); } } else { s3 = null; currentBucket = null; lastWasabiError = null; console.log('⚠️ [Wasabi] Nenhuma configuração encontrada no banco. Wasabi desativado.'); } } catch (error) { console.error('❌ [Wasabi] Erro ao carregar configuração do banco:', error.message); s3 = null; currentBucket = null; } } // Inicialização movida para o server.js, após a conexão com o banco de dados. /** * Valida credenciais do Wasabi tentando listar objetos no bucket especificado. */ async function validateCredentials(accessKey, secretKey, bucket, region, endpoint) { try { const rawEndpoint = endpoint || 's3.wasabisys.com'; const testS3 = new S3Client({ region: region || 'us-east-1', endpoint: rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`, credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, forcePathStyle: true, }); const { ListObjectsV2Command } = require('@aws-sdk/client-s3'); await testS3.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 1 })); return { valid: true }; } catch (error) { console.error('❌ [Wasabi Validator] Falha na validação:', error.message); if (error.name === 'InvalidAccessKeyId' || error.$metadata?.httpStatusCode === 403) { return { valid: false, message: 'Chave de Acesso Inválida ou Acesso Negado (Erro 403).' }; } if (error.name === 'NoSuchBucket') { return { valid: false, message: 'O bucket informado não existe na sua conta.' }; } return { valid: false, message: `Erro de conexão (${error.name}): ${error.message}` }; } } function handleWasabiError(error, context) { console.error(`❌ [Wasabi] ${context}:`, error.message); if (error.name === 'InvalidAccessKeyId' || error.$metadata?.httpStatusCode === 403) { lastWasabiError = 'Chave de Acesso Inválida ou Acesso Negado (Erro 403).'; } else if (error.name === 'NoSuchBucket') { lastWasabiError = 'O bucket configurado não existe.'; } else if (!lastWasabiError) { // Registra erro genérico apenas se não houver um erro mais crítico já registrado lastWasabiError = error.message; } } // Higieniza nomes de diretórios para o S3 function sanitizePathSegment(segment) { if (!segment) return 'desconhecido'; return segment.trim().replace(/[\/\\?#%*:"<>|]/g, '_'); } // ── Chaves de objeto (multi-tenant) ─────────────────────────────────────────── // NOVA (a partir de 2026-06): tenant_id / clinica_id / paciente / arquivo. // LEGADA (objetos antigos): clinic_name / client_name(pc) / paciente / arquivo. // Durante a transição (antes do resync), objetos antigos seguem na key legada, // então a LEITURA precisa testar a existência e cair pra legada quando preciso. function newKey(tenantId, clinicaId, patientName, filename) { return `${sanitizePathSegment(tenantId)}/${sanitizePathSegment(clinicaId)}/${sanitizePathSegment(patientName)}/${filename}`; } function legacyKey(clinicName, clientName, patientName, filename) { return `${sanitizePathSegment(clinicName)}/${sanitizePathSegment(clientName)}/${sanitizePathSegment(patientName)}/${filename}`; } // Metadados da imagem pelo nome do arquivo (para reads que não recebem tudo). async function metaFor(filename) { try { return await db.get( 'SELECT tenant_id, clinica_id, clinic_name, client_name, patient_name FROM images WHERE filename = $1 OR thumb_filename = $1 LIMIT 1', [filename] ); } catch { return null; } } // Candidatas de key (nova → legada → raiz), deduplicadas. function candidateKeys(filename, m, fb = {}) { const tId = m?.tenant_id, cId = m?.clinica_id; const cn = m?.clinic_name ?? fb.clinicName, cl = m?.client_name ?? fb.clientName, pn = m?.patient_name ?? fb.patientName; const keys = []; if (tId && cId) keys.push(newKey(tId, cId, pn, filename)); keys.push(legacyKey(cn, cl, pn, filename)); keys.push(filename); // raiz (legados muito antigos) return [...new Set(keys)]; } // Retorna a 1ª key que EXISTE no bucket (HEAD), ou null. async function pickExistingKey(keys) { for (const Key of keys) { try { await s3.send(new HeadObjectCommand({ Bucket: currentBucket, Key })); return Key; } catch (_) { /* não existe, tenta próxima */ } } return null; } /** * Retorna a URL assinada de uma imagem no Wasabi (ou null se não estiver lá ou Wasabi desativado) */ async function getImageUrl(filename, clinicName, clientName, patientName, isProcessed = false, expirySeconds = 3600) { if (!s3 || !currentBucket) { return null; } try { const m = await metaFor(filename); const key = await pickExistingKey(candidateKeys(filename, m, { clinicName, clientName, patientName })); if (!key) return null; // Gerar URL assinada com expiração (padrão: 1 hora) const url = await getSignedUrl( s3, new GetObjectCommand({ Bucket: currentBucket, Key: key }), { expiresIn: expirySeconds } ); return url; } catch (error) { console.error(`⚠️ [Wasabi] Erro ao gerar URL assinada para ${filename}:`, error.message); return null; } } /** * Salva a imagem. Primeiro no disco local e depois, se o Wasabi estiver ativo, faz o upload. * Remove a versão local temporária se o upload pro Wasabi der certo. */ async function saveImage(filename, buffer, clinicName, clientName, patientName, isProcessed = false, tenantId = null, clinicaId = null) { const targetDir = isProcessed ? PROCESSED_DIR : UPLOAD_DIR; const destPath = path.join(targetDir, filename); // 1. Salvar localmente await fs.mkdir(path.dirname(destPath), { recursive: true }); await fs.writeFile(destPath, buffer); // 2. Se Wasabi estiver ativo, despachar para a nuvem if (s3 && currentBucket) { try { // Key nova (tenant/clinica/paciente) quando temos o tenant; senão legada. const key = (tenantId && clinicaId) ? newKey(tenantId, clinicaId, patientName, filename) : legacyKey(clinicName, clientName, patientName, filename); const ext = path.extname(filename).toLowerCase(); let contentType = 'image/png'; if (ext === '.jpg' || ext === '.jpeg') contentType = 'image/jpeg'; else if (ext === '.webp') contentType = 'image/webp'; else if (ext === '.svg') contentType = 'image/svg+xml'; console.log(`☁️ [Wasabi] Iniciando upload de ${key}...`); const upload = new Upload({ client: s3, params: { Bucket: currentBucket, Key: key, Body: buffer, ContentType: contentType, }, }); await upload.done(); console.log(`✅ [Wasabi] Upload concluído: ${key}`); // Remover arquivo local já que está seguro na nuvem try { await fs.unlink(destPath); } catch (unlinkErr) { console.warn(`⚠️ [Storage] Não foi possível apagar arquivo local temporário: ${unlinkErr.message}`); } } catch (error) { handleWasabiError(error, `Falha no upload de ${filename}`); // Mantém arquivo local como fallback } } } /** * Tenta ler o arquivo local. Se não existir, tenta baixar do Wasabi. */ async function getImageBuffer(filename, clinicName = null, clientName = null, patientName = null, isProcessed = false) { const targetDir = isProcessed ? PROCESSED_DIR : UPLOAD_DIR; const localPath = path.join(targetDir, filename); if (fsSync.existsSync(localPath)) { return await fs.readFile(localPath); } if (s3 && currentBucket) { // Tenta as keys candidatas (nova → legada → raiz) e baixa a 1ª que existir. const m = await metaFor(filename); const keys = candidateKeys(filename, m, { clinicName, clientName, patientName }); for (const key of keys) { try { const response = await s3.send(new GetObjectCommand({ Bucket: currentBucket, Key: key })); if (response.Body) { const bytes = await response.Body.transformToByteArray(); const buffer = Buffer.from(bytes); try { await fs.mkdir(path.dirname(localPath), { recursive: true }); await fs.writeFile(localPath, buffer); // cache local } catch (_) {} return buffer; } } catch (error) { if (error.name !== 'NoSuchKey' && error.$metadata?.httpStatusCode !== 404) { console.error(`❌ [Wasabi] Falha ao recuperar ${key}:`, error.message); } // senão: não existe nessa key, tenta a próxima } } console.error(`❌ [Wasabi] Arquivo não encontrado em nenhuma key: ${filename}`); } throw new Error('File not found locally or in Wasabi'); } /** * Exclui a imagem do disco local e do S3. */ async function deleteImage(filename, clinicName = null, clientName = null, patientName = null) { const localPaths = [ path.join(UPLOAD_DIR, filename), path.join(PROCESSED_DIR, filename) ]; for (const localPath of localPaths) { try { if (fsSync.existsSync(localPath)) { await fs.unlink(localPath); } } catch (err) {} } if (s3 && currentBucket) { try { // Apaga todas as keys possíveis (nova, legada e raiz) — cobre transição. const m = await metaFor(filename); const keys = candidateKeys(filename, m, { clinicName, clientName, patientName }); for (const Key of keys) { await s3.send(new DeleteObjectCommand({ Bucket: currentBucket, Key })).catch(() => {}); } console.log(`🗑️ [Wasabi] Objeto excluído (keys: ${keys.length}): ${filename}`); } catch (err) { handleWasabiError(err, `Falha ao excluir objeto ${filename} do S3`); } } } /** * Gera uma URL pré-assinada para download da imagem diretamente do Wasabi. * Retorna null se Wasabi não estiver configurado ou se não achar as chaves. */ async function getDownloadPresignedUrl(filename, clinicName = null, clientName = null, patientName = null) { if (!s3 || !currentBucket) return null; try { const m = await metaFor(filename); const key = await pickExistingKey(candidateKeys(filename, m, { clinicName, clientName, patientName })); if (!key) return null; const command = new GetObjectCommand({ Bucket: currentBucket, Key: key }); // URL válida por 1 hora const signedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 }); return signedUrl; } catch (err) { handleWasabiError(err, `Falha ao gerar Presigned URL de download para ${filename}`); return null; } } /** * Gera uma URL pré-assinada para upload da imagem diretamente para o Wasabi. * Usa a key NOVA (tenant/clinica/paciente) quando tenant+clinica vierem; senão legada. */ async function getUploadPresignedUrl(filename, clinicName, clientName, patientName, contentType = 'image/png', tenantId = null, clinicaId = null) { if (!s3 || !currentBucket) return null; try { const key = (tenantId && clinicaId) ? newKey(tenantId, clinicaId, patientName, filename) : legacyKey(clinicName, clientName, patientName, filename); const command = new PutObjectCommand({ Bucket: currentBucket, Key: key, ContentType: contentType }); // URL válida por 1 hora const signedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 }); return signedUrl; } catch (err) { handleWasabiError(err, `Falha ao gerar Presigned URL de upload para ${filename}`); return null; } } /** * 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 { const m = await metaFor(filename); const key = await pickExistingKey(candidateKeys(filename, m, { clinicName, clientName, patientName })); return !!key; } 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), validateCredentials, getWasabiStatus: () => ({ enabled: !!(s3 && currentBucket), error: lastWasabiError }) };