Files
rx.scoreodonto.com/dental-server/storage.js
T
VPS 4 Deploy Agent 28ded7bbf9 feat(rx): Wasabi key tenant_id/clinica_id/paciente/arquivo + fallback legado
storage.js: escrita usa key nova (tenant/clinica/paciente) quando o device tem
tenant+clinica; leitura testa existência e cai para a key legada (clinic_name/
client_name/...) e raiz — não quebra objetos antigos antes do resync. deleteImage
apaga todas as keys. getUploadPresignedUrl/saveImage recebem tenantId/clinicaId.

Verificado em dev: presigned URL -> u_…/c_…/paciente/arquivo; leitura de imagem
existente (key legada) -> HTTP 200 via fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 17:38:42 +02:00

389 lines
14 KiB
JavaScript

const { S3Client, GetObjectCommand, DeleteObjectCommand, PutObjectCommand, HeadObjectCommand } = 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, '_');
}
// ── Chaves de objeto (multi-tenant) ───────────────────────────────────────────
// NOVA (a partir de 2026-06): tenant_id / clinica_id / paciente / arquivo.
// LEGADA (objetos antigos): clinic_name / client_name(pc) / paciente / arquivo.
// Durante a transição (antes do resync), objetos antigos seguem na key legada,
// então a LEITURA precisa testar a existência e cair pra legada quando preciso.
function newKey(tenantId, clinicaId, patientName, filename) {
return `${sanitizePathSegment(tenantId)}/${sanitizePathSegment(clinicaId)}/${sanitizePathSegment(patientName)}/${filename}`;
}
function legacyKey(clinicName, clientName, patientName, filename) {
return `${sanitizePathSegment(clinicName)}/${sanitizePathSegment(clientName)}/${sanitizePathSegment(patientName)}/${filename}`;
}
// Metadados da imagem pelo nome do arquivo (para reads que não recebem tudo).
async function metaFor(filename) {
try {
return await db.get(
'SELECT tenant_id, clinica_id, clinic_name, client_name, patient_name FROM images WHERE filename = $1 OR thumb_filename = $1 LIMIT 1',
[filename]
);
} catch { return null; }
}
// Candidatas de key (nova → legada → raiz), deduplicadas.
function candidateKeys(filename, m, fb = {}) {
const tId = m?.tenant_id, cId = m?.clinica_id;
const cn = m?.clinic_name ?? fb.clinicName, cl = m?.client_name ?? fb.clientName, pn = m?.patient_name ?? fb.patientName;
const keys = [];
if (tId && cId) keys.push(newKey(tId, cId, pn, filename));
keys.push(legacyKey(cn, cl, pn, filename));
keys.push(filename); // raiz (legados muito antigos)
return [...new Set(keys)];
}
// Retorna a 1ª key que EXISTE no bucket (HEAD), ou null.
async function pickExistingKey(keys) {
for (const Key of keys) {
try {
await s3.send(new HeadObjectCommand({ Bucket: currentBucket, Key }));
return Key;
} catch (_) { /* não existe, tenta próxima */ }
}
return null;
}
/**
* Retorna a URL assinada de uma imagem no Wasabi (ou null se não estiver lá ou Wasabi desativado)
*/
async function getImageUrl(filename, clinicName, clientName, patientName, isProcessed = false, expirySeconds = 3600) {
if (!s3 || !currentBucket) {
return null;
}
try {
const m = await metaFor(filename);
const key = await pickExistingKey(candidateKeys(filename, m, { clinicName, clientName, patientName }));
if (!key) return null;
// 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, clinicName, clientName, patientName, isProcessed = false, tenantId = null, clinicaId = null) {
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 {
// Key nova (tenant/clinica/paciente) quando temos o tenant; senão legada.
const key = (tenantId && clinicaId)
? newKey(tenantId, clinicaId, patientName, filename)
: legacyKey(clinicName, clientName, patientName, 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, clinicName = null, 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) {
// Tenta as keys candidatas (nova → legada → raiz) e baixa a 1ª que existir.
const m = await metaFor(filename);
const keys = candidateKeys(filename, m, { clinicName, clientName, patientName });
for (const key of keys) {
try {
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);
try {
await fs.mkdir(path.dirname(localPath), { recursive: true });
await fs.writeFile(localPath, buffer); // cache local
} catch (_) {}
return buffer;
}
} catch (error) {
if (error.name !== 'NoSuchKey' && error.$metadata?.httpStatusCode !== 404) {
console.error(`❌ [Wasabi] Falha ao recuperar ${key}:`, error.message);
}
// senão: não existe nessa key, tenta a próxima
}
}
console.error(`❌ [Wasabi] Arquivo não encontrado em nenhuma key: ${filename}`);
}
throw new Error('File not found locally or in Wasabi');
}
/**
* Exclui a imagem do disco local e do S3.
*/
async function deleteImage(filename, clinicName = null, 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 {
// Apaga todas as keys possíveis (nova, legada e raiz) — cobre transição.
const m = await metaFor(filename);
const keys = candidateKeys(filename, m, { clinicName, clientName, patientName });
for (const Key of keys) {
await s3.send(new DeleteObjectCommand({ Bucket: currentBucket, Key })).catch(() => {});
}
console.log(`🗑️ [Wasabi] Objeto excluído (keys: ${keys.length}): ${filename}`);
} 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, clinicName = null, clientName = null, patientName = null) {
if (!s3 || !currentBucket) return null;
try {
const m = await metaFor(filename);
const key = await pickExistingKey(candidateKeys(filename, m, { clinicName, clientName, patientName }));
if (!key) return null;
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.
* Usa a key NOVA (tenant/clinica/paciente) quando tenant+clinica vierem; senão legada.
*/
async function getUploadPresignedUrl(filename, clinicName, clientName, patientName, contentType = 'image/png', tenantId = null, clinicaId = null) {
if (!s3 || !currentBucket) return null;
try {
const key = (tenantId && clinicaId)
? newKey(tenantId, clinicaId, patientName, filename)
: legacyKey(clinicName, clientName, patientName, 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;
}
}
/**
* Verifica se o arquivo existe no storage (local ou Wasabi) sem baixar o conteúdo.
* Retorna true se encontrado em qualquer um dos dois lugares.
*/
async function fileExists(filename, clinicName = null, clientName = null, patientName = null) {
// Verificar local primeiro
const localPaths = [
path.join(UPLOAD_DIR, filename),
path.join(PROCESSED_DIR, filename),
];
for (const p of localPaths) {
if (fsSync.existsSync(p)) return true;
}
if (!s3 || !currentBucket) return false;
try {
const m = await metaFor(filename);
const key = await pickExistingKey(candidateKeys(filename, m, { clinicName, clientName, patientName }));
return !!key;
} catch (e) {
if (e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404) return false;
// Erro de rede/auth — assume não existe para forçar re-upload
return false;
}
}
module.exports = {
loadConfigFromDb,
saveImage,
getImageBuffer,
getImageUrl,
deleteImage,
fileExists,
getDownloadPresignedUrl,
getUploadPresignedUrl,
isWasabiEnabled: () => !!(s3 && currentBucket),
validateCredentials,
getWasabiStatus: () => ({
enabled: !!(s3 && currentBucket),
error: lastWasabiError
})
};