28ded7bbf9
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>
389 lines
14 KiB
JavaScript
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
|
|
})
|
|
};
|