339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
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 = $1', [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 ($1, $2)
|
|
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 = $1 OR thumb_filename = $2 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)
|
|
};
|