const { S3Client } = require('@aws-sdk/client-s3'); const { Upload } = require('@aws-sdk/lib-storage'); const fs = require('fs').promises; const fsSync = require('fs'); const path = require('path'); // Configurações das pastas (resolvendo caminhos corretos) const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, 'uploads'); const PROCESSED_DIR = process.env.PROCESSED_DIR || path.join(__dirname, 'processed'); const CONFIG_FILE = path.join(UPLOAD_DIR, 'wasabi_config.json'); let s3 = null; let currentBucket = null; let currentConfig = {}; // Função para inicializar/reconfigurar o S3 Client function initS3(cfg = {}) { const accessKey = cfg.wasabiAccessKey || process.env.WASABI_ACCESS_KEY; const secretKey = cfg.wasabiSecretKey || process.env.WASABI_SECRET_KEY; const region = cfg.wasabiRegion || process.env.WASABI_REGION || 'us-east-1'; const bucket = cfg.wasabiBucket || process.env.WASABI_BUCKET; const rawEndpoint = cfg.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com'; const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`; currentConfig = { wasabiAccessKey: accessKey || '', wasabiSecretKey: secretKey || '', wasabiBucket: bucket || '', wasabiRegion: region || 'us-east-1', wasabiEndpoint: rawEndpoint || 's3.wasabisys.com' }; if (accessKey && secretKey && bucket) { s3 = new S3Client({ region, endpoint, credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, forcePathStyle: true, }); currentBucket = bucket; console.log(`✅ [Wasabi] Inicializado com sucesso — Bucket: ${bucket}, Endpoint: ${endpoint}`); } else { s3 = null; currentBucket = null; console.log('⚠️ [Wasabi] Chaves WASABI_ACCESS_KEY, WASABI_SECRET_KEY ou WASABI_BUCKET não definidas no JSON/Banco/Ambiente. Wasabi desativado.'); } } // Inicializa a partir do arquivo JSON (se houver) ou das variáveis de ambiente (como fallback inicial síncrono) function loadConfigSync() { try { if (fsSync.existsSync(CONFIG_FILE)) { const data = fsSync.readFileSync(CONFIG_FILE, 'utf8'); const cfg = JSON.parse(data); console.log('📖 [Wasabi] Configuração dinâmica lida de wasabi_config.json'); initS3(cfg); } else { initS3({}); } } catch (error) { console.error('❌ [Wasabi] Erro ao carregar arquivo de config:', error.message); initS3({}); } } // Inicializa a partir do Banco de Dados (Chamado de forma assíncrona após db.initDatabase) async function loadConfigFromDb() { try { const db = require('./database'); const keys = [ 'wasabi_access_key', 'wasabi_secret_key', 'wasabi_bucket', 'wasabi_region', 'wasabi_endpoint' ]; const cfg = {}; let hasSettings = false; for (const k of keys) { const row = await db.get('SELECT value FROM settings WHERE key = ?', [k]); if (row) { // Mapear de snake_case do banco para camelCase do config const camelKey = k.replace(/_([a-z])/g, (g) => g[1].toUpperCase()); cfg[camelKey] = row.value; hasSettings = true; } } if (hasSettings) { console.log('📖 [Wasabi] Configuração carregada com sucesso do Banco de Dados.'); initS3(cfg); } else { // Se não há settings no banco, tenta carregar do fallback JSON loadConfigSync(); } } catch (error) { console.error('❌ [Wasabi] Erro ao carregar do banco, usando fallback:', error.message); loadConfigSync(); } } // Inicialização imediata de fallback (síncrona) loadConfigSync(); // Retorna a configuração atual de forma segura (mascarando a senha/secret key) function getSafeConfig() { return { wasabiAccessKey: currentConfig.wasabiAccessKey, wasabiSecretKey: currentConfig.wasabiSecretKey ? '********' : '', wasabiBucket: currentConfig.wasabiBucket, wasabiRegion: currentConfig.wasabiRegion, wasabiEndpoint: currentConfig.wasabiEndpoint, enabled: !!(s3 && currentBucket) }; } // Salva e atualiza a configuração (no banco de dados e no arquivo de redundância local) async function updateConfig(newConfig) { try { const db = require('./database'); // Se a secretKey vier como asteriscos, mantém a antiga const finalSecretKey = newConfig.wasabiSecretKey === '********' ? currentConfig.wasabiSecretKey : newConfig.wasabiSecretKey; const configToSave = { wasabiAccessKey: newConfig.wasabiAccessKey || '', wasabiSecretKey: finalSecretKey || '', wasabiBucket: newConfig.wasabiBucket || '', wasabiRegion: newConfig.wasabiRegion || 'us-east-1', wasabiEndpoint: newConfig.wasabiEndpoint || 's3.wasabisys.com' }; // 1. Salva no banco de dados (com suporte a ON CONFLICT UPSERT do PostgreSQL e SQLite) const settingsMap = { 'wasabi_access_key': configToSave.wasabiAccessKey, 'wasabi_secret_key': configToSave.wasabiSecretKey, 'wasabi_bucket': configToSave.wasabiBucket, 'wasabi_region': configToSave.wasabiRegion, 'wasabi_endpoint': configToSave.wasabiEndpoint }; for (const [key, val] of Object.entries(settingsMap)) { await db.run( `INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, [key, val] ); } console.log('💾 [Wasabi] Configurações persistidas no Banco de Dados.'); // 2. Salva localmente no arquivo de redundância JSON try { await fs.mkdir(path.dirname(CONFIG_FILE), { recursive: true }); await fs.writeFile(CONFIG_FILE, JSON.stringify(configToSave, null, 2), 'utf8'); console.log('💾 [Wasabi] Cópia de segurança salva em wasabi_config.json'); } catch (fsErr) { console.warn('⚠️ [Wasabi] Falha ao gravar cópia em arquivo de config:', fsErr.message); } initS3(configToSave); return { success: true }; } catch (error) { console.error('❌ [Wasabi] Erro ao salvar configurações:', error.message); throw error; } } // Higieniza nomes para caminhos do S3 function sanitizePathSegment(segment) { if (!segment) return 'desconhecido'; return segment.trim().replace(/[\/\\?#%*:"<>|]/g, '_'); } // Salva uma imagem (original ou processada) no disco local e envia para o Wasabi async function saveImage(filename, buffer, clientName, patientName, isProcessed = false) { const targetDir = isProcessed ? PROCESSED_DIR : UPLOAD_DIR; const destPath = path.join(targetDir, filename); // 1. Salva localmente de forma temporária await fs.mkdir(path.dirname(destPath), { recursive: true }); await fs.writeFile(destPath, buffer); console.log(`💾 [Storage] Imagem salva localmente de forma temporária: ${destPath}`); // 2. Envia para o Wasabi se ativo if (s3 && currentBucket) { try { const cleanClient = sanitizePathSegment(clientName); const cleanPatient = sanitizePathSegment(patientName); const key = `${cleanClient}/${cleanPatient}/${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'; } const upload = new Upload({ client: s3, params: { Bucket: currentBucket, Key: key, Body: buffer, ContentType: contentType, }, }); await upload.done(); console.log(`☁️ [Wasabi] Upload concluído na pasta do cliente/paciente: ${key}`); // Se o upload foi bem-sucedido e o Wasabi está ativo, removemos o arquivo local imediatamente try { await fs.unlink(destPath); console.log(`🗑️ [Storage] Arquivo local temporário removido após upload no Wasabi: ${destPath}`); } catch (unlinkErr) { console.warn(`⚠️ [Storage] Falha ao remover arquivo local temporário: ${unlinkErr.message}`); } } catch (error) { console.error(`❌ [Wasabi] Falha no upload de ${filename}:`, error.message); // Mantemos o arquivo local se o Wasabi falhar como fallback resiliente } } } // Obtém o buffer da imagem (lê localmente, e se não existir, tenta baixar do Wasabi) async function getImageBuffer(filename, clientName = null, patientName = null, isProcessed = false) { const targetDir = isProcessed ? PROCESSED_DIR : UPLOAD_DIR; const localPath = path.join(targetDir, filename); // 1. Tenta ler local primeiro (para compatibilidade caso o Wasabi não estivesse ativo no salvamento inicial) if (fsSync.existsSync(localPath)) { return await fs.readFile(localPath); } // 2. Se não existir localmente, tenta obter do Wasabi if (s3 && currentBucket) { try { let cName = clientName; let pName = patientName; // Se não foram passados, busca metadados no banco if (!cName || !pName) { try { const db = require('./database'); const row = await db.get('SELECT client_name, patient_name FROM images WHERE filename = ? OR thumb_filename = ? LIMIT 1', [filename, filename]); if (row) { cName = row.client_name; pName = row.patient_name; } } catch (dbErr) { console.error('⚠️ [Storage] Erro ao buscar metadados do banco:', dbErr.message); } } const cleanClient = sanitizePathSegment(cName); const cleanPatient = sanitizePathSegment(pName); const key = `${cleanClient}/${cleanPatient}/${filename}`; console.log(`☁️ [Wasabi] Baixando do S3: ${key}`); const { GetObjectCommand } = require('@aws-sdk/client-s3'); 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); // TODO(security): Não salvamos no disco rígido local da VPS em cache para preservar privacidade e espaço. // Apenas retornamos o buffer lido na memória diretamente para o servidor. console.log(`☁️ [Wasabi] Servindo imagem diretamente do S3 sem gravar em cache local`); return buffer; } } catch (error) { console.error(`❌ [Wasabi] Falha ao recuperar do S3 para ${filename}:`, error.message); } } return null; } // Exclui uma imagem (do disco local e do Wasabi S3) async function deleteImage(filename, clientName, patientName) { // 1. Excluir localmente da pasta de uploads (imagens originais) e processed (imagens rotacionadas) const localPaths = [ path.join(UPLOAD_DIR, filename), path.join(PROCESSED_DIR, filename) ]; for (const localPath of localPaths) { try { // Usando fsSync.existsSync para checagem síncrona segura if (fsSync.existsSync(localPath)) { await fs.unlink(localPath); console.log(`🗑️ [Storage] Arquivo local excluído: ${localPath}`); } } catch (err) { console.warn(`⚠️ [Storage] Falha ao excluir arquivo local ${localPath}:`, err.message); } } // 2. Excluir do Wasabi S3 se ativo if (s3 && currentBucket) { try { const cleanClient = sanitizePathSegment(clientName); const cleanPatient = sanitizePathSegment(patientName); const key = `${cleanClient}/${cleanPatient}/${filename}`; const { DeleteObjectCommand } = require('@aws-sdk/client-s3'); await s3.send(new DeleteObjectCommand({ Bucket: currentBucket, Key: key })); console.log(`🗑️ [Wasabi] Objeto excluído do S3: ${key}`); } catch (err) { console.error(`❌ [Wasabi] Falha ao excluir objeto ${filename} do S3:`, err.message); } } } module.exports = { saveImage, getImageBuffer, deleteImage, getSafeConfig, updateConfig, loadConfigFromDb, isWasabiEnabled: () => !!(s3 && currentBucket) };