21f33c88a4
continuous-integration/webhook Deploy concluído (VPS4)
- Adiciona coluna clinic_name em images (ALTER TABLE IF NOT EXISTS) - Todas as funções de storage recebem clinicName como parâmetro: saveImage, getImageBuffer, getImageUrl, deleteImage, getDownloadPresignedUrl, getUploadPresignedUrl - Chave Wasabi passa de pc_name/patient/arquivo → clinic_name/pc_name/patient/arquivo - server.js: usa socket.user.clinicName (preenchido via machine_token auth do DB) como fonte primária do nome da clínica nos 3 handlers de upload e na rota thumbnail - Todos os INSERTs em images passam a gravar clinic_name - Corrige bug oculto: wipe de clínica agora apaga o prefixo correto no Wasabi - Scripts utilitários atualizados: backfill-wasabi-thumbs, recreate-all-thumbs, migrate-to-wasabi - client-monitor.js: envia clinicName no client-identify (fallback para clientes legados) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
387 lines
13 KiB
JavaScript
387 lines
13 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, clinicName, clientName, patientName, isProcessed = false, expirySeconds = 3600) {
|
|
if (!s3 || !currentBucket || !clientName || !patientName) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const cleanClinic = sanitizePathSegment(clinicName);
|
|
const cleanClient = sanitizePathSegment(clientName);
|
|
const cleanPatient = sanitizePathSegment(patientName);
|
|
const key = `${cleanClinic}/${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, clinicName, 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 cleanClinic = sanitizePathSegment(clinicName);
|
|
const cleanClient = sanitizePathSegment(clientName);
|
|
const cleanPatient = sanitizePathSegment(patientName);
|
|
const key = `${cleanClinic}/${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, 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) {
|
|
try {
|
|
let clName = clinicName;
|
|
let cName = clientName;
|
|
let pName = patientName;
|
|
|
|
if (!cName || !pName) {
|
|
try {
|
|
const row = await db.get('SELECT clinic_name, client_name, patient_name FROM images WHERE filename = $1 OR thumb_filename = $2 LIMIT 1', [filename, filename]);
|
|
if (row) {
|
|
clName = row.clinic_name;
|
|
cName = row.client_name;
|
|
pName = row.patient_name;
|
|
}
|
|
} catch (dbErr) {
|
|
console.error('⚠️ [Storage] Erro ao buscar metadados do banco:', dbErr.message);
|
|
}
|
|
}
|
|
|
|
const cleanClinic = sanitizePathSegment(clName);
|
|
const cleanClient = sanitizePathSegment(cName);
|
|
const cleanPatient = sanitizePathSegment(pName);
|
|
const key = `${cleanClinic}/${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, 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 {
|
|
let clName = clinicName;
|
|
let cName = clientName;
|
|
let pName = patientName;
|
|
|
|
if (!cName || !pName) {
|
|
const row = await db.get('SELECT clinic_name, client_name, patient_name FROM images WHERE filename = $1 LIMIT 1', [filename]);
|
|
if (row) {
|
|
clName = row.clinic_name;
|
|
cName = row.client_name;
|
|
pName = row.patient_name;
|
|
}
|
|
}
|
|
|
|
const cleanClinic = sanitizePathSegment(clName);
|
|
const cleanClient = sanitizePathSegment(cName);
|
|
const cleanPatient = sanitizePathSegment(pName);
|
|
const key = `${cleanClinic}/${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, clinicName = null, clientName = null, patientName = null) {
|
|
if (!s3 || !currentBucket) return null;
|
|
|
|
try {
|
|
let clName = clinicName;
|
|
let cName = clientName;
|
|
let pName = patientName;
|
|
|
|
if (!cName || !pName) {
|
|
const row = await db.get('SELECT clinic_name, client_name, patient_name FROM images WHERE filename = $1 OR thumb_filename = $2 LIMIT 1', [filename, filename]);
|
|
if (row) {
|
|
clName = row.clinic_name;
|
|
cName = row.client_name;
|
|
pName = row.patient_name;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const cleanClinic = sanitizePathSegment(clName);
|
|
const cleanClient = sanitizePathSegment(cName);
|
|
const cleanPatient = sanitizePathSegment(pName);
|
|
const key = `${cleanClinic}/${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, clinicName, clientName, patientName, contentType = 'image/png') {
|
|
if (!s3 || !currentBucket) return null;
|
|
|
|
try {
|
|
const cleanClinic = sanitizePathSegment(clinicName);
|
|
const cleanClient = sanitizePathSegment(clientName);
|
|
const cleanPatient = sanitizePathSegment(patientName);
|
|
const key = `${cleanClinic}/${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
|
|
})
|
|
};
|