Files
rx.scoreodonto.com/dental-server/storage.js
T
VPS 4 Builder 5b0d80bf22
continuous-integration/webhook Deploy concluido (rx.scoreodonto.com)
feat: reestruturacao de menus na sidebar, niveis de acesso e migracao para postgresql/dragonflydb
2026-05-26 21:08:48 +02:00

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)
};