feat: adicionar thumbnails, ajuste fino flutuante e correções no dental-client
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
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)
|
||||
};
|
||||
Reference in New Issue
Block a user