Files
rx.scoreodonto.com/dental-server/storage.js
T
VPS 4 Deploy Agent b76bb14b0a
continuous-integration/webhook Deploy concluído (VPS4)
fix: serve Wasabi image URLs instead of local /uploads/ paths — prevent broken image links
2026-05-30 17:22:11 +02:00

375 lines
12 KiB
JavaScript

const { S3Client, GetObjectCommand, DeleteObjectCommand, PutObjectCommand } = 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, '_');
}
/**
* Retorna a URL assinada de uma imagem no Wasabi (ou null se não estiver lá ou Wasabi desativado)
*/
async function getImageUrl(filename, clientName, patientName, isProcessed = false, expirySeconds = 3600) {
if (!s3 || !currentBucket || !clientName || !patientName) {
return null;
}
try {
const cleanClient = sanitizePathSegment(clientName);
const cleanPatient = sanitizePathSegment(patientName);
const key = `${cleanClient}/${cleanPatient}/${filename}`;
// 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, clientName, patientName, isProcessed = false) {
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 {
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';
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, 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) {
try {
let cName = clientName;
let pName = patientName;
if (!cName || !pName) {
try {
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}`;
const response = await s3.send(new GetObjectCommand({
Bucket: currentBucket,
Key: key
}));
if (response.Body) {
const bytes = await response.Body.transformToByteArray();
return Buffer.from(bytes);
}
} catch (error) {
if (error.name === 'NoSuchKey') {
// Tenta achar na pasta root do bucket se o padrão client/patient não foi usado no passado
try {
const response = await s3.send(new GetObjectCommand({ Bucket: currentBucket, Key: filename }));
if (response.Body) {
const bytes = await response.Body.transformToByteArray();
return Buffer.from(bytes);
}
} catch (fallbackError) {
console.error(`❌ [Wasabi] Arquivo não encontrado no S3: ${filename}`);
}
} else {
console.error(`❌ [Wasabi] Falha ao recuperar do S3 para ${filename}:`, error.message);
}
}
}
throw new Error('File not found locally or in Wasabi');
}
/**
* Exclui a imagem do disco local e do S3.
*/
async function deleteImage(filename, 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 {
let cName = clientName;
let pName = patientName;
if (!cName || !pName) {
const row = await db.get('SELECT client_name, patient_name FROM images WHERE filename = $1 LIMIT 1', [filename]);
if (row) {
cName = row.client_name;
pName = row.patient_name;
}
}
const cleanClient = sanitizePathSegment(cName);
const cleanPatient = sanitizePathSegment(pName);
const key = `${cleanClient}/${cleanPatient}/${filename}`;
await s3.send(new DeleteObjectCommand({
Bucket: currentBucket,
Key: key
}));
console.log(`🗑️ [Wasabi] Objeto excluído: ${key}`);
// Tentar deletar da root tbm por redundância de arquivos legados
await s3.send(new DeleteObjectCommand({ Bucket: currentBucket, Key: filename })).catch(() => {});
} 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, clientName = null, patientName = null) {
if (!s3 || !currentBucket) return null;
try {
let cName = clientName;
let pName = patientName;
if (!cName || !pName) {
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;
} else {
return null;
}
}
const cleanClient = sanitizePathSegment(cName);
const cleanPatient = sanitizePathSegment(pName);
const key = `${cleanClient}/${cleanPatient}/${filename}`;
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.
*/
async function getUploadPresignedUrl(filename, clientName, patientName, contentType = 'image/png') {
if (!s3 || !currentBucket) return null;
try {
const cleanClient = sanitizePathSegment(clientName);
const cleanPatient = sanitizePathSegment(patientName);
const key = `${cleanClient}/${cleanPatient}/${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;
}
}
module.exports = {
loadConfigFromDb,
saveImage,
getImageBuffer,
getImageUrl,
deleteImage,
getDownloadPresignedUrl,
getUploadPresignedUrl,
isWasabiEnabled: () => !!(s3 && currentBucket),
validateCredentials,
getWasabiStatus: () => ({
enabled: !!(s3 && currentBucket),
error: lastWasabiError
})
};