feat: consolidate backend improvements, frontend UX fixes and Wasabi storage support

This commit is contained in:
VPS 4 Deploy Agent
2026-05-30 15:23:27 +02:00
parent de3bbc9564
commit 6dfce6723b
21 changed files with 2312 additions and 361 deletions
+86
View File
@@ -0,0 +1,86 @@
const db = require('./database');
const storage = require('./storage');
const imageProcessor = require('./image-processor');
const fetch = require('node-fetch');
async function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function run() {
console.log('🚀 Iniciando recriação de thumbnails para imagens legadas no Wasabi...');
await db.initDatabase();
await storage.loadConfigFromDb();
if (!storage.isWasabiEnabled()) {
console.error('❌ Wasabi não está configurado/habilitado no sistema.');
process.exit(1);
}
const rows = await db.all(`
SELECT id, filename, client_name, patient_name
FROM images
WHERE thumb_filename IS NULL AND enabled = 1
`);
console.log(`🔍 Encontradas ${rows.length} imagens sem miniatura.`);
let successCount = 0;
let failCount = 0;
for (const row of rows) {
console.log(`\n⚙️ Processando ID ${row.id}: ${row.filename}`);
try {
// 1. Obter URL de download do Wasabi
const url = await storage.getDownloadPresignedUrl(row.filename, row.client_name, row.patient_name);
if (!url) {
console.error(`❌ Não foi possível gerar URL de download para ${row.filename}`);
failCount++;
continue;
}
// 2. Baixar a imagem original do Wasabi
const response = await fetch(url);
if (!response.ok) {
console.error(`❌ Erro ao baixar imagem do Wasabi (${response.status}): ${response.statusText}`);
failCount++;
continue;
}
const arrayBuffer = await response.arrayBuffer();
const imageBuffer = Buffer.from(arrayBuffer);
// 3. Gerar miniatura
const thumbBuffer = await imageProcessor.getThumbnail(imageBuffer, 400, 400);
// 4. Salvar miniatura no Wasabi (usando a mesma lógica de rotas/VPS)
const thumbFilename = `thumb_${row.filename}`;
await storage.saveImage(thumbFilename, thumbBuffer, row.client_name, row.patient_name, false);
// 5. Atualizar o banco de dados
await db.run('UPDATE images SET thumb_filename = $1 WHERE id = $2', [thumbFilename, row.id]);
console.log(`✅ Miniatura gerada e salva com sucesso para ID ${row.id}`);
successCount++;
// Aguardar 1 segundo para não sobrecarregar a VPS/Wasabi
await delay(1000);
} catch (err) {
console.error(`❌ Erro ao processar ID ${row.id}:`, err.message);
failCount++;
}
}
console.log(`\n🎉 Processamento concluído!`);
console.log(`✅ Sucessos: ${successCount}`);
console.log(`❌ Falhas: ${failCount}`);
process.exit(0);
}
run().catch(err => {
console.error('❌ Erro crítico:', err);
process.exit(1);
});
+4
View File
@@ -79,6 +79,7 @@ async function createTables() {
flip_vertical INT DEFAULT 0,
doctor VARCHAR(255),
remark TEXT,
thumb_filename TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
@@ -90,6 +91,9 @@ async function createTables() {
await client.query('CREATE INDEX IF NOT EXISTS idx_client_name ON images(client_name)');
await client.query('CREATE INDEX IF NOT EXISTS idx_created_at ON images(created_at)');
// Garantir que a coluna exista caso a tabela já tenha sido criada antes
await client.query('ALTER TABLE images ADD COLUMN IF NOT EXISTS thumb_filename TEXT;');
console.log('✅ Tabela images criada/verificada no PostgreSQL');
// Tabela GTOs
+4
View File
@@ -0,0 +1,4 @@
const bcrypt = require('bcrypt');
const password = 'h$tg@g5aga$ra1E3$C-yHW$-BA@DF2@Grfa!3#';
const hash = bcrypt.hashSync(password, 10);
console.log('HASH_GENERATED=' + hash);
+6
View File
@@ -0,0 +1,6 @@
import bcrypt
import sys
password = "h$tg@g5aga$ra1E3$C-yHW$-BA@DF2@Grfa!3#"
hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
print(hashed.decode('utf-8'))
+1 -1
View File
@@ -112,7 +112,7 @@ class ImageProcessor {
fit: 'inside',
withoutEnlargement: true
})
.png()
.jpeg({ quality: 50, force: true })
.toBuffer();
} catch (error) {
console.error('Erro ao gerar thumbnail:', error);
+735 -3
View File
@@ -1,14 +1,17 @@
{
"name": "dental-image-server",
"version": "2.0.0",
"version": "2.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dental-image-server",
"version": "2.0.0",
"version": "2.0.5",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.1055.0",
"@aws-sdk/lib-storage": "^3.1055.0",
"@aws-sdk/s3-request-presigner": "^3.1055.0",
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
@@ -26,6 +29,513 @@
"npm": ">=8.0.0"
}
},
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
"integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
"dependencies": {
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@aws-crypto/crc32c": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz",
"integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==",
"dependencies": {
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-crypto/sha1-browser": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz",
"integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==",
"dependencies": {
"@aws-crypto/supports-web-crypto": "^5.2.0",
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"@aws-sdk/util-locate-window": "^3.0.0",
"@smithy/util-utf8": "^2.0.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-crypto/sha256-browser": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
"integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
"dependencies": {
"@aws-crypto/sha256-js": "^5.2.0",
"@aws-crypto/supports-web-crypto": "^5.2.0",
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"@aws-sdk/util-locate-window": "^3.0.0",
"@smithy/util-utf8": "^2.0.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-crypto/sha256-js": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
"integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
"dependencies": {
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@aws-crypto/supports-web-crypto": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
"integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
"dependencies": {
"tslib": "^2.6.2"
}
},
"node_modules/@aws-crypto/util": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
"integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
"dependencies": {
"@aws-sdk/types": "^3.222.0",
"@smithy/util-utf8": "^2.0.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1055.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1055.0.tgz",
"integrity": "sha512-FxVwuw86c2Mw4p+0tOtoE+1sDTk+eOBZD/NwwK+wwx1gHkdO/EYSv231O9A1YM8HPjUrI0vZ/hP/szckBxHW0A==",
"dependencies": {
"@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.974.14",
"@aws-sdk/credential-provider-node": "^3.972.45",
"@aws-sdk/middleware-bucket-endpoint": "^3.972.16",
"@aws-sdk/middleware-expect-continue": "^3.972.13",
"@aws-sdk/middleware-flexible-checksums": "^3.974.22",
"@aws-sdk/middleware-location-constraint": "^3.972.11",
"@aws-sdk/middleware-sdk-s3": "^3.972.43",
"@aws-sdk/middleware-ssec": "^3.972.11",
"@aws-sdk/signature-v4-multi-region": "^3.996.29",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/fetch-http-handler": "^5.4.3",
"@smithy/node-http-handler": "^4.7.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.974.14",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.14.tgz",
"integrity": "sha512-ppamm04uoj3hhNO5IlQSs5D6rWX1fWkzcn6a4pZrojk8Y6ObY9wzLDdT/Eq3gv6O9hOebi9tYTNB8b8fQj9XJw==",
"dependencies": {
"@aws-sdk/types": "^3.973.9",
"@aws-sdk/xml-builder": "^3.972.26",
"@aws/lambda-invoke-store": "^0.2.2",
"@smithy/core": "^3.24.3",
"@smithy/signature-v4": "^5.4.2",
"@smithy/types": "^4.14.2",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/crc64-nvme": {
"version": "3.972.9",
"resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.9.tgz",
"integrity": "sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ==",
"dependencies": {
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.40",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.40.tgz",
"integrity": "sha512-jjT0p0Y7KZtcvExYiPCLJnqM9lkXDV1KBEg/13OE2DXv/9batzlyJHVKUEnRNJccY0O2Sul17E1su38CgdBhGQ==",
"dependencies": {
"@aws-sdk/core": "^3.974.14",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.42",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.42.tgz",
"integrity": "sha512-+3fsKtWybe5BjKEUA3/07oh7Ayfd82IED2+gyyaVfS/4PU78E3TaOQxSGOJ1t7Imefoidw/ne9QA7apX8wEnJg==",
"dependencies": {
"@aws-sdk/core": "^3.974.14",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/fetch-http-handler": "^5.4.3",
"@smithy/node-http-handler": "^4.7.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.972.44",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.44.tgz",
"integrity": "sha512-gZFw5wBefCIPg9vpT+gV5FdhfNKhYTVDZa1IsZCcn3SRoYUOJ/E05vwIogkJoonqBL0ttBGi5vhthX7xceekRg==",
"dependencies": {
"@aws-sdk/core": "^3.974.14",
"@aws-sdk/credential-provider-env": "^3.972.40",
"@aws-sdk/credential-provider-http": "^3.972.42",
"@aws-sdk/credential-provider-login": "^3.972.44",
"@aws-sdk/credential-provider-process": "^3.972.40",
"@aws-sdk/credential-provider-sso": "^3.972.44",
"@aws-sdk/credential-provider-web-identity": "^3.972.44",
"@aws-sdk/nested-clients": "^3.997.12",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/credential-provider-imds": "^4.3.2",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.44",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.44.tgz",
"integrity": "sha512-QqEGHfQeZgUDqh7zpqHufrZ8T644ELEWvB+4gUdewLyRw4IRF+6CJqeQuRWqucZdQzoQeMh7fNAD9BWxFAdNig==",
"dependencies": {
"@aws-sdk/core": "^3.974.14",
"@aws-sdk/nested-clients": "^3.997.12",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.45",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.45.tgz",
"integrity": "sha512-3YCv52ExXIRz3LAVNysevd+s7akSpg9dl39v9LJ7dOQH+s5rHi3jMZYQyxwMmglxQGMuzYRfQ0o1VSP2UOlIRw==",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.40",
"@aws-sdk/credential-provider-http": "^3.972.42",
"@aws-sdk/credential-provider-ini": "^3.972.44",
"@aws-sdk/credential-provider-process": "^3.972.40",
"@aws-sdk/credential-provider-sso": "^3.972.44",
"@aws-sdk/credential-provider-web-identity": "^3.972.44",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/credential-provider-imds": "^4.3.2",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.40",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.40.tgz",
"integrity": "sha512-cXaozlgJCOwmE6D7x4npcPdyk7kiFZdrGjN3D6tXXtItJJMNGPafDfAJn4YQmciMooG/X+b0Y6RTqdVVMx26jg==",
"dependencies": {
"@aws-sdk/core": "^3.974.14",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.972.44",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.44.tgz",
"integrity": "sha512-YePoj5kQuPmE0MHnyftXCfsO8ZSBd2kDr50XEIUrdejSbGFlayYvUuCohdb8drhGhPm6b65o7H1eC26EZhwUvA==",
"dependencies": {
"@aws-sdk/core": "^3.974.14",
"@aws-sdk/nested-clients": "^3.997.12",
"@aws-sdk/token-providers": "3.1054.0",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.44",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.44.tgz",
"integrity": "sha512-Ys/JJe++8Z2Y5meR1taMBaVcrGBA0/XsVTQR+qOKZbdNyg+8Jlv5rYZSwh8SqEHY00goSOZy7PHzZ2rLNQxDLg==",
"dependencies": {
"@aws-sdk/core": "^3.974.14",
"@aws-sdk/nested-clients": "^3.997.12",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/lib-storage": {
"version": "3.1055.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1055.0.tgz",
"integrity": "sha512-wlTeeGJYkEMmTk1wiYkhMz7mvjEoeJlWMQ7kMNJ+GOAbvHYd+u8qQ/PCt7XNPVqnYej1WZs0fskOBMTYzsqIJw==",
"dependencies": {
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"buffer": "5.6.0",
"events": "3.3.0",
"stream-browserify": "3.0.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@aws-sdk/client-s3": "^3.1055.0"
}
},
"node_modules/@aws-sdk/lib-storage/node_modules/buffer": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz",
"integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==",
"dependencies": {
"base64-js": "^1.0.2",
"ieee754": "^1.1.4"
}
},
"node_modules/@aws-sdk/middleware-bucket-endpoint": {
"version": "3.972.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.16.tgz",
"integrity": "sha512-FhasMTBDBmMN7EEa1hUeHwo5p5Mv3Dm8w0VEbdXX/6ola/uyhRuJt8zGkH09mLTmab20USTzEpPqyqEoe1MqNg==",
"dependencies": {
"@aws-sdk/core": "^3.974.14",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-expect-continue": {
"version": "3.972.13",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.13.tgz",
"integrity": "sha512-sHiqIFg8o2ipT7t40B89Vj0ubSUtY6OSt/+Ee/OXhHch5K4+81zP2+QX8Lkc/nJ2QSmCySxOke7TEbmX69fe2g==",
"dependencies": {
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-flexible-checksums": {
"version": "3.974.22",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.22.tgz",
"integrity": "sha512-ot1kZ1JGHUxcXPOARhej/n/+Odfx9VPt60pNrUq8Lf/U2blIF3+uj5v56gw76VD70dZvrfeLNo9jKz6pQJfOlA==",
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
"@aws-crypto/crc32c": "5.2.0",
"@aws-crypto/util": "5.2.0",
"@aws-sdk/core": "^3.974.14",
"@aws-sdk/crc64-nvme": "^3.972.9",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-location-constraint": {
"version": "3.972.11",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.11.tgz",
"integrity": "sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A==",
"dependencies": {
"@aws-sdk/types": "^3.973.9",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.43",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.43.tgz",
"integrity": "sha512-CBmixMY36JdAdt9ALgm7yVlvOXGUCHt9Z2kn5p9XVO5StO6HCH+cayV7YYV1CDLsXvVyebaXgBmif9wHoxCeNA==",
"dependencies": {
"@aws-sdk/core": "^3.974.14",
"@aws-sdk/signature-v4-multi-region": "^3.996.29",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-ssec": {
"version": "3.972.11",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.11.tgz",
"integrity": "sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ==",
"dependencies": {
"@aws-sdk/types": "^3.973.9",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.12",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.12.tgz",
"integrity": "sha512-Js2VYaCM269feB0cs0cGmlIhdOgT9aMqzdBx68lCy6kVCYfzr0T36ovUFDvfUmatkuBeyBJhCwaLBh7P8meH5Q==",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.974.14",
"@aws-sdk/signature-v4-multi-region": "^3.996.29",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/fetch-http-handler": "^5.4.3",
"@smithy/node-http-handler": "^4.7.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/s3-request-presigner": {
"version": "3.1055.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1055.0.tgz",
"integrity": "sha512-/qbh/nG6PQG4CxFYS9IDkUXtPsDcYCjuPaLejhyG7Cw2Ary6XqIshtrWlUjLPIc163IueHXQR/aq2WQt9f0OxA==",
"dependencies": {
"@aws-sdk/core": "^3.974.14",
"@aws-sdk/signature-v4-multi-region": "^3.996.29",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.29",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.29.tgz",
"integrity": "sha512-Few9FoQqOt/0KSvZYP+qdW0dfOhfQ9N+gl2UUDvCPW6mkPKHli9LMbKxWj+wZ5zKPaOoqxuR3Hhy3OTpndkfSw==",
"dependencies": {
"@aws-sdk/types": "^3.973.9",
"@smithy/signature-v4": "^5.4.2",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1054.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1054.0.tgz",
"integrity": "sha512-hG9YKApmZOw+drJ9Nuoaf/OvC8e5W1+3eoLeN5p2uVCZRWsv27teIS0b4kiH6Sfv3WMmamqYJxmE2WMwyp/L/A==",
"dependencies": {
"@aws-sdk/core": "^3.974.14",
"@aws-sdk/nested-clients": "^3.997.12",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.973.9",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz",
"integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==",
"dependencies": {
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/util-locate-window": {
"version": "3.965.5",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz",
"integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.26",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.26.tgz",
"integrity": "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==",
"dependencies": {
"@smithy/types": "^4.14.2",
"fast-xml-parser": "5.7.3",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz",
"integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@ioredis/commands": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
@@ -52,6 +562,128 @@
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@nodable/entities": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz",
"integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
]
},
"node_modules/@smithy/core": {
"version": "3.24.5",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz",
"integrity": "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==",
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.5.tgz",
"integrity": "sha512-yiF8xHpdkaTfzLVqFzsP6WvNghEK+qZzLYWFD13L2SsFhbXwBGlxdocKF95qjr7s5lE5NRage+EJFK4mAsx88Q==",
"dependencies": {
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.5.tgz",
"integrity": "sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ==",
"dependencies": {
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/is-array-buffer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
"integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.7.5",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz",
"integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==",
"dependencies": {
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.5.tgz",
"integrity": "sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==",
"dependencies": {
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.14.2",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz",
"integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/util-buffer-from": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
"integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
"dependencies": {
"@smithy/is-array-buffer": "^2.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@smithy/util-utf8": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
"integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
"dependencies": {
"@smithy/util-buffer-from": "^2.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
@@ -353,6 +985,11 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -811,6 +1448,14 @@
"node": ">= 0.6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/events-universal/-/events-universal-1.0.1.tgz",
@@ -915,6 +1560,41 @@
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"node_modules/fast-xml-builder": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"dependencies": {
"path-expression-matcher": "^1.5.0",
"xml-naming": "^0.1.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz",
"integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"dependencies": {
"@nodable/entities": "^2.1.0",
"fast-xml-builder": "^1.1.7",
"path-expression-matcher": "^1.5.0",
"strnum": "^2.2.3"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/finalhandler": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.1.tgz",
@@ -1725,6 +2405,20 @@
"node": ">= 0.8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -1745,7 +2439,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
@@ -2464,6 +3157,15 @@
"node": ">= 0.8"
}
},
"node_modules/stream-browserify": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz",
"integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==",
"dependencies": {
"inherits": "~2.0.4",
"readable-stream": "^3.5.0"
}
},
"node_modules/streamx": {
"version": "2.23.0",
"resolved": "https://registry.npmmirror.com/streamx/-/streamx-2.23.0.tgz",
@@ -2519,6 +3221,17 @@
"node": ">=0.10.0"
}
},
"node_modules/strnum": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
"integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
]
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmmirror.com/tar/-/tar-6.2.1.tgz",
@@ -2585,6 +3298,11 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -2713,6 +3431,20 @@
}
}
},
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+3
View File
@@ -16,6 +16,9 @@
"author": "Rcesar",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.1055.0",
"@aws-sdk/lib-storage": "^3.1055.0",
"@aws-sdk/s3-request-presigner": "^3.1055.0",
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
+1 -1
View File
@@ -44,7 +44,7 @@
<nav class="sidebar-nav">
<div class="nav-section-title">Menu Principal</div>
<a href="/" class="nav-item" id="menu-dashboard">
<i class="fa-solid fa-chart-line"></i> Dashboard
<i class="fa-solid fa-images"></i> Galeria
</a>
<a href="/?action=new-patient" class="nav-item" id="menu-new-patient" onclick="if(window.location.pathname === '/') { openCreatePatientModal(); return false; }">
<i class="fa-solid fa-user-plus"></i> Novo Paciente
+212 -200
View File
@@ -7,13 +7,17 @@ const API_URL = '/api';
// ================================================================
// ERROR LOGGER — Logs to console AND shows on screen
// Ative DEBUG=true só para diagnóstico; em produção fica silencioso
// (info/warn/group viram no-op) para não poluir o console nem custar reflows.
// ================================================================
const DEBUG = false;
const _noop = () => {};
const _log = {
info: (tag, ...args) => console.log(`%c[${tag}]`, 'color:#667eea;font-weight:bold', ...args),
warn: (tag, ...args) => console.warn(`%c[${tag}]`, 'color:#ffa500;font-weight:bold', ...args),
info: DEBUG ? (tag, ...args) => console.log(`%c[${tag}]`, 'color:#667eea;font-weight:bold', ...args) : _noop,
warn: DEBUG ? (tag, ...args) => console.warn(`%c[${tag}]`, 'color:#ffa500;font-weight:bold', ...args) : _noop,
error: (tag, ...args) => console.error(`%c[${tag}]`, 'color:#ff4444;font-weight:bold', ...args),
group: (tag) => console.group(`%c[${tag}]`, 'color:#667eea;font-weight:bold'),
end: () => console.groupEnd()
group: DEBUG ? (tag) => console.group(`%c[${tag}]`, 'color:#667eea;font-weight:bold') : _noop,
end: DEBUG ? () => console.groupEnd() : _noop
};
function showErrorBanner(msg, detail = '') {
@@ -121,7 +125,6 @@ let selectedImageId = null;
let clientsList = [];
let selectedClient = '';
let showDisabled = false;
let currentTransformations = [];
let fineTuneAngle = 0;
// ================================================================
@@ -173,9 +176,47 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadClientsList();
loadPatients();
// Registra busca, filtro de cliente, toggle "Ocultas", botão voltar e
// fechamento do modal de transformação. (Antes nunca era chamado, então
// a busca e os filtros do cabeçalho não funcionavam.)
setupEventListeners();
// Add global modal close listener
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-close') || e.target.classList.contains('close-modal')) {
const modal = e.target.closest('.modal');
if (modal) modal.classList.remove('active');
} else if (e.target.classList.contains('modal')) {
e.target.classList.remove('active');
}
});
// Add hash routing listener
window.addEventListener('hashchange', handleHashRoute);
// Call once to process initial hash
setTimeout(handleHashRoute, 200);
// Add scroll listener for infinite scroll
const contentScroll = document.querySelector('.content-scroll');
if (contentScroll) {
contentScroll.addEventListener('scroll', () => {
if (view === 'patients' && !isFetchingPatients && hasMorePatients) {
// Trigger load more when 300px from the bottom
if (contentScroll.scrollTop + contentScroll.clientHeight >= contentScroll.scrollHeight - 300) {
loadPatients(true, true);
}
}
});
}
// Atualizações automáticas removidas para economizar banda (usando apenas eventos socket)
// setInterval(loadClientsList, 5000);
// Wasabi status update removed (was: setInterval(checkWasabiStatus, 15000))
initDragAndDrop();
setInterval(loadClientsList, 5000);
checkWasabiStatus();
// Process Querystring actions (from navigation links in other pages)
const urlParams = new URLSearchParams(window.location.search);
@@ -224,7 +265,7 @@ function setupEventListeners() {
else loadPatientImages(selectedPatient.patient_name);
});
backBtn.addEventListener('click', () => showPatientsView());
backBtn.addEventListener('click', () => { window.location.hash = '#/'; });
// Transform modal close on backdrop
transformModal.addEventListener('click', e => {
@@ -255,10 +296,9 @@ async function loadClientsList() {
dbName: c.name || 'Unknown', status: 'identified'
}));
const imagesRes = await fetchWithAuth('/api/images/patients?disabled=false&search=');
const imagesRes = await fetchWithAuth('/api/images/unique-clients');
if (imagesRes.ok) {
const pts = await imagesRes.json();
const uniqueClients = [...new Set(pts.map(p => p.client_name).filter(Boolean))];
const uniqueClients = await imagesRes.json();
uniqueClients.forEach(cn => {
if (!clientsList.find(c => c.dbName === cn || c.name === cn)) {
clientsList.push({ name: cn, displayName: cn, dbName: cn, type: 'historic', socketId: null, status: 'historic' });
@@ -296,28 +336,46 @@ function updateClientsDropdown() {
// VIEW: PATIENTS LIST
// ================================================================
function showPatientsView() {
let currentPatientsPage = 1;
let isFetchingPatients = false;
let hasMorePatients = true;
function showPatientsView(pushHash = true) {
view = 'patients';
selectedPatient = null;
backBtn.style.display = 'none';
patientHeader.style.display = 'none';
if (pushHash && window.location.hash && window.location.hash !== '#/') {
window.history.pushState(null, '', '#/');
}
loadPatients();
}
async function loadPatients(silent = false) {
_log.group('LOAD_PATIENTS');
_log.info('LOAD_PATIENTS', `Iniciando carga. silent=${silent}, showDisabled=${showDisabled}, client="${selectedClient}"`);
async function loadPatients(silent = false, loadMore = false) {
if (isFetchingPatients) return;
if (loadMore && !hasMorePatients) return;
_log.group('LOAD_PATIENTS');
_log.info('LOAD_PATIENTS', `Iniciando carga. silent=${silent}, showDisabled=${showDisabled}, client="${selectedClient}", loadMore=${loadMore}`);
isFetchingPatients = true;
if (!loadMore) {
currentPatientsPage = 1;
hasMorePatients = true;
if (!silent) {
loadingState.style.display = 'block';
imagesGrid.style.display = 'none';
emptyState.style.display = 'none';
}
}
try {
const search = searchInput.value.trim();
const clientParam = selectedClient ? `&client=${encodeURIComponent(selectedClient)}` : '';
const url = `/api/images/patients?disabled=${showDisabled}&search=${encodeURIComponent(search)}${clientParam}`;
const url = `/api/images/patients?disabled=${showDisabled}&search=${encodeURIComponent(search)}${clientParam}&page=${currentPatientsPage}&limit=50`;
const res = await fetchWithAuth(url);
@@ -334,8 +392,19 @@ async function loadPatients(silent = false) {
throw new Error(`HTTP ${res.status}: ${errBody || 'Erro ao carregar pacientes'}`);
}
patients = await res.json();
_log.info('LOAD_PATIENTS', `${patients.length} paciente(s) recebido(s)`);
const newPatients = await res.json();
if (newPatients.length < 50) {
hasMorePatients = false;
}
if (loadMore) {
patients = patients.concat(newPatients);
} else {
patients = newPatients;
}
_log.info('LOAD_PATIENTS', `${newPatients.length} paciente(s) recebido(s), total: ${patients.length}`);
if (patients.length > 0) {
_log.info('LOAD_PATIENTS', 'Primeiro paciente:', {
name: patients[0].patient_name,
@@ -353,35 +422,34 @@ async function loadPatients(silent = false) {
} else {
emptyState.style.display = 'none';
imagesGrid.style.display = 'grid';
_log.info('LOAD_PATIENTS', `imagesGrid display=${imagesGrid.style.display}`);
if (loadMore) {
appendPatientCards(patients.length - newPatients.length);
} else {
renderPatientCards();
}
}
if (loadMore) {
currentPatientsPage++;
} else if (hasMorePatients) {
currentPatientsPage = 2;
}
} catch (e) {
_log.error('LOAD_PATIENTS', '❌ Exceção:', e.message, e);
if (!loadMore) {
loadingState.style.display = 'none';
emptyState.style.display = 'block';
}
showErrorBanner('Erro ao carregar pacientes', e.message);
showToast('Erro ao carregar pacientes', 'error');
} finally {
isFetchingPatients = false;
_log.end();
}
}
function renderPatientCards() {
_log.group('RENDER_CARDS');
_log.info('RENDER_CARDS', `Renderizando ${patients.length} card(s)...`);
// Inspecionar o grid antes de renderizar
const gridStyle = window.getComputedStyle(imagesGrid);
_log.info('RENDER_CARDS', 'imagesGrid computed style:', {
display: gridStyle.display,
gridTemplateColumns: gridStyle.gridTemplateColumns,
width: gridStyle.width,
height: gridStyle.height,
overflow: gridStyle.overflow
});
imagesGrid.innerHTML = patients.map((p, index) => {
// Template de um cartão de paciente (reutilizado no render completo e no append)
function patientCardHTML(p, index) {
const fullName = p.patient_name || 'Sem nome';
const thumb = p.thumb_filename ? `/uploads/${p.thumb_filename}` : '';
const count = p.image_count || 0;
@@ -389,13 +457,17 @@ function renderPatientCards() {
const doctor = p.doctor || '—';
const remark = p.remark || '—';
if (index === 0) {
_log.info('RENDER_CARDS', 'Card #0 dados:', { fullName, thumb, count, date, doctor });
}
// <img loading="lazy"> permite ao browser adiar miniaturas fora da viewport;
// sem thumb, mantém o gradiente de fallback.
const preview = thumb
? `<img class="preview-img" src="${thumb}" alt="" loading="lazy" decoding="async">`
: '';
const previewStyle = thumb ? '' : ' style="background: linear-gradient(135deg,#667eea22,#764ba222);"';
return `
<div class="image-card patient-card" onclick="openPatientByIndex(${index})">
<div class="image-preview" style="${thumb ? `background-image: url('${thumb}'); background-size: cover; background-position: center;` : 'background: linear-gradient(135deg,#667eea22,#764ba222);'}">
<div class="image-preview"${previewStyle}>
${preview}
<button type="button" class="delete-patient-btn" onclick="event.stopPropagation(); deletePatientImages('${escapeHtml(fullName)}')" title="Excluir todas as imagens de ${escapeHtml(fullName)}">🗑️</button>
<div class="patient-count-badge">${count} imagem${count !== 1 ? 'ns' : ''}</div>
</div>
@@ -408,26 +480,20 @@ function renderPatientCards() {
</div>
</div>
</div>`;
}).join('');
}
// Inspecionar o primeiro card renderizado
requestAnimationFrame(() => {
const firstCard = imagesGrid.querySelector('.image-card');
if (firstCard) {
const cs = window.getComputedStyle(firstCard);
const preview = firstCard.querySelector('.image-preview');
const pcs = preview ? window.getComputedStyle(preview) : null;
_log.info('RENDER_CARDS', 'Primeiro card computado:', {
cardW: cs.width, cardH: cs.height,
previewH: pcs ? pcs.height : 'n/a',
previewBg: pcs ? pcs.backgroundImage.substring(0, 60) : 'n/a'
});
} else {
_log.error('RENDER_CARDS', '❌ Nenhum card encontrado no DOM após render!');
}
_log.info('RENDER_CARDS', `Total de cards no DOM: ${imagesGrid.querySelectorAll('.image-card').length}`);
_log.end();
});
// Render completo (primeira página / refresh)
function renderPatientCards() {
imagesGrid.innerHTML = patients.map((p, index) => patientCardHTML(p, index)).join('');
}
// Scroll infinito: anexa apenas os cartões novos, sem repintar os já visíveis
function appendPatientCards(startIndex) {
const html = patients
.slice(startIndex)
.map((p, i) => patientCardHTML(p, startIndex + i))
.join('');
imagesGrid.insertAdjacentHTML('beforeend', html);
}
// Exclui todas as imagens de um determinado paciente (S3 e Local)
@@ -466,7 +532,7 @@ async function deletePatientImages(patientName) {
// VIEW: PATIENT IMAGES
// ================================================================
function openPatient(patient) {
function openPatient(patient, pushHash = true) {
selectedPatient = patient;
view = 'patient-images';
backBtn.style.display = 'inline-flex';
@@ -476,9 +542,30 @@ function openPatient(patient) {
patientTitle.textContent = fullName;
patientMeta.textContent = `${patient.image_count || 0} imagem(ns) · ${patient.doctor || '—'}`;
if (pushHash) {
const newHash = `#/patient/${encodeURIComponent(patient.patient_name)}`;
if (window.location.hash !== newHash) {
window.history.pushState(null, '', newHash);
}
}
loadPatientImages(patient.patient_name);
}
function handleHashRoute() {
const hash = decodeURIComponent(window.location.hash);
if (hash.startsWith('#/patient/')) {
const pName = hash.split('#/patient/')[1];
if (pName && (!selectedPatient || selectedPatient.patient_name !== pName)) {
openPatient({ patient_name: pName, image_count: '?', doctor: 'Carregando...' }, false);
}
} else if (hash === '#/' || hash === '') {
if (view !== 'patients') {
showPatientsView(false);
}
}
}
async function loadPatientImages(patientName) {
_log.group('LOAD_IMAGES');
_log.info('LOAD_IMAGES', `Carregando imagens de: "${patientName}"`);
@@ -527,7 +614,7 @@ function renderPatientImages() {
const thumbUrl = image.thumb_filename ? `/uploads/${image.thumb_filename}` : `/uploads/${image.filename}`;
return `
<div class="image-card ${!image.enabled ? 'disabled' : ''}" data-id="${image.id}" onclick="handleImageClick(${image.id})">
<div class="image-preview" style="background-image: url('${thumbUrl}'); background-size: cover; background-position: center;"></div>
<div class="image-preview"><img class="preview-img" src="${thumbUrl}" alt="" loading="lazy" decoding="async"></div>
<div class="image-info">
<div class="image-meta"><span>📅 ${formatDate(image.created_at)}</span></div>
<div class="image-guid">${escapeHtml(image.image_guid || image.filename)}</div>
@@ -555,6 +642,7 @@ async function handleImageClick(imageId) {
}
async function showTransformModal(imageId) {
try {
transformModal.classList.add('active');
// Default view is 'transform'
@@ -563,9 +651,19 @@ async function showTransformModal(imageId) {
// Find image to show details
const image = patientImages.find(img => img.id === imageId);
if (!image) {
throw new Error('Imagem não encontrada na lista carregada.');
}
const imageUrl = image.filename.startsWith('http') ? image.filename : `/uploads/${image.filename}`;
// Atribui a imagem às tags do DOM para exibição no painel
document.getElementById('originalImgPreview').src = imageUrl;
document.getElementById('transformedImgPreview').src = imageUrl;
// Check if the image has been modified (has original_image_id)
const btnResetImage = document.getElementById('btnResetImage');
if (image && image.original_image_id) {
if (image.original_image_id) {
btnResetImage.style.display = 'flex';
btnResetImage.setAttribute('data-modified', 'true');
} else {
@@ -573,7 +671,6 @@ async function showTransformModal(imageId) {
btnResetImage.setAttribute('data-modified', 'false');
}
if (image) {
document.getElementById('transformModalTitle').innerText = `${escapeHtml(image.patient_name || 'Desconhecido')} - ${formatDate(image.created_at)}`;
document.getElementById('transformExtraInfo').innerHTML = `
@@ -583,59 +680,13 @@ async function showTransformModal(imageId) {
document.getElementById('transformExtraInfo').style.display = 'none'; // Keep hidden by default
loadGtosForPatient(image.patient_name);
} else {
document.getElementById('transformModalTitle').innerText = `Opções da Imagem`;
document.getElementById('transformExtraInfo').innerHTML = '';
document.getElementById('transformExtraInfo').style.display = 'none';
}
document.getElementById('transformOptions').innerHTML = `
<div style="grid-column:1/-1; text-align:center; padding:40px 0; color:#888;">
<div class="spinner" style="margin:0 auto 16px;"></div>
<p>Gerando variações...</p>
</div>`;
// Reseta o ângulo sempre que abrir a imagem
fineTuneAngle = 0;
updateFineTuneDisplay();
try {
const image = patientImages.find(img => img.id === imageId);
if (!image) {
throw new Error('Imagem não encontrada localmente');
}
// Se existir thumb_filename, usar ele; caso contrário, fallback para a imagem cheia
const imageUrl = image.thumb_filename ? `/uploads/${image.thumb_filename}` : `/uploads/${image.filename}`;
const transformations = [
{ name: 'Original', rotation: 0, flipH: false, flipV: false, css: 'transform: none;' },
{ name: 'Flip Horizontal', rotation: 0, flipH: true, flipV: false, css: 'transform: scaleX(-1);' },
{ name: 'Flip Vertical', rotation: 0, flipH: false, flipV: true, css: 'transform: scaleY(-1);' },
{ name: 'Rotação +90°', rotation: 90, flipH: false, flipV: false, css: 'transform: rotate(90deg);' },
{ name: 'Rotação -90°', rotation: -90, flipH: false, flipV: false, css: 'transform: rotate(-90deg);' }
];
currentTransformations = transformations;
const container = document.getElementById('transformOptions');
container.innerHTML = transformations.map((t, i) => `
<div class="transform-option" data-index="${i}" onclick="selectTransform(${i})">
<img src="${imageUrl}" alt="${escapeHtml(t.name)}" style="${t.css}" loading="lazy">
<div class="transform-name">${escapeHtml(t.name)}</div>
</div>
`).join('');
// ---------------------------------------------------------------
// Detectar orientação da imagem original → aplicar layout correto
// ---------------------------------------------------------------
const probe = new Image();
probe.onload = () => {
const orientation = probe.naturalWidth >= probe.naturalHeight ? 'landscape' : 'portrait';
container.classList.remove('grid-landscape', 'grid-portrait');
container.classList.add(`grid-${orientation}`);
_log.info('TRANSFORM', `Orientação detectada: ${orientation} (${probe.naturalWidth}×${probe.naturalHeight})`);
};
probe.onerror = () => {
_log.warn('TRANSFORM', 'Não foi possível detectar orientação — usando fallback');
};
probe.src = imageUrl;
// Exibe área de ação inferior (onde ficam os botões de salvar e observação)
document.getElementById('transformActionArea').style.display = 'block';
} catch (e) {
_log.error('TRANSFORM', '❌ Exceção:', e.message, e);
@@ -643,82 +694,33 @@ async function showTransformModal(imageId) {
transformModal.classList.remove('active');
}
}
// As transformações base foram removidas. Usamos apenas o ajuste fino.
function selectTransform(index) {
const el = document.querySelector(`.transform-option[data-index="${index}"]`);
if (el.classList.contains('selected')) {
// If clicking the already selected one, clear selection
clearTransformSelection();
return;
}
const container = document.getElementById('transformOptions');
container.classList.add('selection-mode');
document.querySelectorAll('.transform-option').forEach(option => {
option.classList.remove('selected');
const optIndex = parseInt(option.getAttribute('data-index'));
if (optIndex === 0 || optIndex === index) {
option.style.display = 'block';
} else {
option.style.display = 'none';
}
});
el.classList.add('selected');
// Reset fine tune angle when selecting a new transform
function applyFineTuneAngle(angleDelta) {
if (angleDelta === 0) {
fineTuneAngle = 0;
} else {
fineTuneAngle += angleDelta;
}
updateFineTuneDisplay();
// Show action area
document.getElementById('transformActionArea').style.display = 'block';
// Apply to preview image
const previewImg = document.getElementById('transformedImgPreview');
// Pre-fill remark if we have an image
const image = patientImages.find(img => img.id === selectedImageId);
if (image && !document.getElementById('newImageRemark').value) {
document.getElementById('newImageRemark').value = image.remark || '';
}
// Agora temos apenas o fineTuneAngle, sem outras transformações base (flip)
previewImg.style.transform = `rotate(${fineTuneAngle}deg)`;
}
function clearTransformSelection() {
const container = document.getElementById('transformOptions');
container.classList.remove('selection-mode');
// Restore original transform styles on all images
document.querySelectorAll('.transform-option').forEach(option => {
option.classList.remove('selected');
option.style.display = 'block';
const optIndex = parseInt(option.getAttribute('data-index'));
const transform = currentTransformations[optIndex];
const imgEl = option.querySelector('img');
if (imgEl && transform) {
imgEl.style.transform = transform.css;
}
const nameEl = option.querySelector('.transform-name');
if (nameEl && transform) {
nameEl.innerText = transform.name;
}
});
fineTuneAngle = 0;
updateFineTuneDisplay();
document.getElementById('transformActionArea').style.display = 'none';
resetFineTune();
document.getElementById('newImageRemark').value = '';
}
async function saveSelectedTransform() {
const selectedEl = document.querySelector('.transform-option.selected');
if (!selectedEl) {
showToast('Selecione uma orientação primeiro.', 'error');
if (fineTuneAngle === 0) {
showToast('Nenhuma edição aplicada.', 'error');
return;
}
const index = parseInt(selectedEl.getAttribute('data-index'));
const transform = currentTransformations[index];
const remark = document.getElementById('newImageRemark').value.trim();
const btn = document.getElementById('btnSaveTransform');
@@ -728,9 +730,9 @@ async function saveSelectedTransform() {
try {
const payload = {
rotation: transform.rotation + fineTuneAngle,
flipH: transform.flipH,
flipV: transform.flipV,
rotation: fineTuneAngle,
flipH: false,
flipV: false,
remark: remark
};
@@ -751,34 +753,18 @@ async function saveSelectedTransform() {
}
function adjustFineTune(offset) {
const selectedEl = document.querySelector('.transform-option.selected');
if (!selectedEl) return;
const index = parseInt(selectedEl.getAttribute('data-index'));
const transform = currentTransformations[index];
fineTuneAngle += offset;
updateFineTuneDisplay();
// Update the image style using the combined transform
const imgEl = selectedEl.querySelector('img');
if (imgEl) {
let totalRotation = transform.rotation + fineTuneAngle;
let transformStr = `rotate(${totalRotation}deg)`;
if (transform.flipH) transformStr += ` scaleX(-1)`;
if (transform.flipV) transformStr += ` scaleY(-1)`;
imgEl.style.transform = transformStr;
}
// Update display name inside the card
const nameEl = selectedEl.querySelector('.transform-name');
if (nameEl) {
nameEl.innerText = `${transform.name} (${fineTuneAngle > 0 ? '+' : ''}${fineTuneAngle}°)`;
}
// Update the image style
const previewImg = document.getElementById('transformedImgPreview');
previewImg.style.transform = `rotate(${fineTuneAngle}deg)`;
}
function resetFineTune() {
adjustFineTune(-fineTuneAngle);
fineTuneAngle = 0;
updateFineTuneDisplay();
document.getElementById('transformedImgPreview').style.transform = `rotate(0deg)`;
}
function updateFineTuneDisplay() {
@@ -846,7 +832,6 @@ function openPatientByIndex(index) {
window.openPatient = openPatient;
window.openPatientByIndex = openPatientByIndex;
window.handleImageClick = handleImageClick;
window.selectTransform = selectTransform;
window.toggleImageEnabled = toggleImageEnabled;
// ================================================================
@@ -1111,7 +1096,6 @@ async function resetImageToOriginal() {
// Global scope exposures
window.handleImageClick = handleImageClick;
window.toggleImageEnabled = toggleImageEnabled;
window.selectTransform = selectTransform;
window.openCreatePatientModal = openCreatePatientModal;
window.closeCreatePatientModal = closeCreatePatientModal;
window.submitCreatePatient = submitCreatePatient;
@@ -1425,6 +1409,28 @@ async function loadPluginsConfig() {
}
}
async function checkWasabiStatus() {
try {
const res = await fetchWithAuth('/api/system/wasabi-status');
if (res.ok) {
const status = await res.json();
const banner = document.getElementById('wasabi-alert-banner');
const errorMsg = document.getElementById('wasabi-alert-error');
if (status.enabled && status.error) {
// Existe um erro crítico, exibe o banner
if (errorMsg) errorMsg.textContent = status.error;
if (banner) banner.style.display = 'flex';
} else {
// Tudo certo, esconde o banner
if (banner) banner.style.display = 'none';
}
}
} catch (e) {
console.error('Falha ao verificar status do Wasabi:', e);
}
}
async function updatePluginsConfig(event) {
event.preventDefault();
@@ -1461,13 +1467,19 @@ async function updatePluginsConfig(event) {
// Recarregar o status
await loadPluginsConfig();
checkWasabiStatus(); // Atualizar o banner na mesma hora
// Fechar após 1 segundo
setTimeout(closePluginsModal, 1200);
} catch (e) {
console.error(e);
// Se for um erro 400 de validação da key, usamos um Alert para chamar a atenção
if (e.message.includes('Chave') || e.message.includes('bucket') || e.message.includes('Erro 403') || e.message.includes('conexão')) {
alert("⚠️ " + e.message);
} else {
showToast(e.message, 'error');
}
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
+6 -1
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clientes Conectados - Dental Server</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap">
<link rel="stylesheet" href="/style.css">
<style>
.clients-container {
@@ -204,7 +207,7 @@
<nav class="sidebar-nav">
<div class="nav-section-title">Menu Principal</div>
<a href="/" class="nav-item" id="menu-dashboard">
<i class="fa-solid fa-chart-line"></i> Dashboard
<i class="fa-solid fa-images"></i> Galeria
</a>
<a href="/?action=new-patient" class="nav-item" id="menu-new-patient" onclick="if(window.location.pathname === '/') { openCreatePatientModal(); return false; }">
<i class="fa-solid fa-user-plus"></i> Novo Paciente
@@ -267,6 +270,8 @@
</div>
</div>
<div id="stats" class="stats"></div>
<div class="clients-list-card" style="margin-top: 24px;">
<div id="clientsList">
<div class="empty-state">
+3
View File
@@ -6,6 +6,9 @@
<title>Baixar Cliente - RF Dental</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap">
<link rel="stylesheet" href="/style.css">
<style>
:root {
+67 -28
View File
@@ -7,9 +7,22 @@
<meta name="description" content="Sistema de gerenciamento de imagens de raio-X dental">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<!-- Overlay Global de Alerta Wasabi -->
<div id="wasabi-alert-banner" class="wasabi-alert-banner" style="display: none;">
<span class="wasabi-alert-icon">⚠️</span>
<div class="wasabi-alert-content">
<strong>Atenção:</strong> Falha de autenticação com o Wasabi S3 (<span id="wasabi-alert-error">Erro 403</span>). As imagens não poderão ser salvas ou visualizadas. Por favor, verifique suas configurações de armazenamento imediatamente.
</div>
<button class="wasabi-alert-btn" onclick="openSettingsTab()">Configurar Agora</button>
</div>
<!-- App Layout Container -->
<div class="app-container">
<!-- Sidebar -->
<aside class="sidebar">
@@ -22,8 +35,8 @@
<nav class="sidebar-nav">
<div class="nav-section-title">Menu Principal</div>
<a href="/" class="nav-item active" id="menu-dashboard" onclick="if(window.location.pathname === '/') { showView('gallery'); return false; }">
<i class="fa-solid fa-chart-line"></i> Dashboard
<a href="/" class="nav-item active" id="menu-dashboard" onclick="if(window.location.pathname === '/') { showPatientsView(); return false; }">
<i class="fa-solid fa-images"></i> Galeria
</a>
<a href="#" class="nav-item" id="menu-new-patient" onclick="openCreatePatientModal(); return false;">
<i class="fa-solid fa-user-plus"></i> Novo Paciente
@@ -157,27 +170,53 @@
<!-- View 1: Transforms -->
<div id="transformViewWrapper" class="transform-view-wrapper">
<div id="transformOptions" class="transform-grid"></div>
<div id="newTransformLayout" class="new-transform-layout">
<!-- Painel Original -->
<div class="transform-panel panel-original">
<h3 class="panel-title">Original</h3>
<div class="img-container">
<img id="originalImgPreview" src="" alt="Original">
</div>
</div>
<!-- Painel Preview -->
<div class="transform-panel panel-preview">
<h3 class="panel-title">Preview</h3>
<div class="img-container">
<img id="transformedImgPreview" src="" alt="Transformed">
</div>
</div>
<!-- Barra de ferramentas lateral -->
<div class="transform-sidebar">
<h3 class="sidebar-title">Ajuste</h3>
<div class="sidebar-buttons" id="sidebarTransformButtons" style="display: flex; flex-direction: column; gap: 8px; align-items: center; width: 100%;">
<button type="button" class="sidebar-btn" onclick="adjustFineTune(-5)" title="Rotacionar -5°" style="margin-bottom: 4px;">
<span class="icon"></span>
<span class="label">-5°</span>
</button>
<button type="button" class="sidebar-btn" onclick="adjustFineTune(-1)" title="Rotacionar -1°" style="margin-bottom: 4px;">
<span class="icon"></span>
<span class="label">-1°</span>
</button>
<div id="fineTuneValDisplay" class="fine-tune-display" style="min-width: 40px; padding: 6px; font-size: 1rem; margin: 8px 0;"></div>
<button type="button" class="sidebar-btn" onclick="adjustFineTune(1)" title="Rotacionar +1°" style="margin-top: 4px;">
<span class="icon"></span>
<span class="label">+1°</span>
</button>
<button type="button" class="sidebar-btn" onclick="adjustFineTune(5)" title="Rotacionar +5°" style="margin-top: 4px;">
<span class="icon"></span>
<span class="label">+5°</span>
</button>
<button type="button" class="btn btn-danger btn-small" id="btnResetFineTune" onclick="resetFineTune()" style="display: none; margin-top: 12px; width: 100%;" title="Resetar ajuste">Reset</button>
</div>
</div>
</div>
<div id="transformActionArea" class="transform-action-area" style="display: none;">
<!-- Ajuste Fino de Rotação -->
<div class="fine-tune-section">
<span class="fine-tune-title">
📐 Ajuste Fino de Ângulo (Sensor Inclinado)
</span>
<div class="fine-tune-controls">
<button type="button" class="btn btn-secondary btn-small" onclick="adjustFineTune(-5)" title="Rotacionar -5°">🔄 -5°</button>
<button type="button" class="btn btn-secondary btn-small" onclick="adjustFineTune(-1)" title="Rotacionar -1°">🔄 -1°</button>
<div id="fineTuneValDisplay" class="fine-tune-display">
</div>
<button type="button" class="btn btn-secondary btn-small" onclick="adjustFineTune(1)" title="Rotacionar +1°">🔄 +1°</button>
<button type="button" class="btn btn-secondary btn-small" onclick="adjustFineTune(5)" title="Rotacionar +5°">🔄 +5°</button>
<button type="button" class="btn btn-danger btn-small" id="btnResetFineTune" onclick="resetFineTune()" style="display: none;" title="Resetar ajuste fino">Reset</button>
</div>
</div>
<!-- Apenas a observação e os botões de salvar ficaram na área inferior -->
<div class="form-group">
<label>Nova Observação / Detalhes (Opcional):</label>
@@ -219,7 +258,7 @@
<div class="modal-content">
<div class="modal-header">
<h2>Novo Paciente</h2>
<div class="modal-close" onclick="closeCreatePatientModal()">&times;</div>
<div class="modal-close">&times;</div>
</div>
<div class="modal-body">
<form id="create-patient-form" onsubmit="submitCreatePatient(event)">
@@ -269,7 +308,7 @@
<div class="modal-content">
<div class="modal-header">
<h2>Nova GTO</h2>
<div class="modal-close" onclick="closeCreateGtoModal()">&times;</div>
<div class="modal-close">&times;</div>
</div>
<div class="modal-body">
<form id="create-gto-form" onsubmit="submitCreateGto(event)">
@@ -297,7 +336,7 @@
<div class="modal-content">
<div class="modal-header">
<h2>⚙️ Configurações da Conta</h2>
<span class="close-modal" onclick="closeSettingsModal()">&times;</span>
<span class="modal-close">&times;</span>
</div>
<div class="modal-body">
<form id="settingsForm" onsubmit="updateCredentials(event)">
@@ -338,7 +377,7 @@
<div class="modal-content">
<div class="modal-header">
<h2>👥 Gerenciamento de Usuários</h2>
<span class="modal-close" onclick="closeUserManagementModal()">&times;</span>
<span class="modal-close">&times;</span>
</div>
<div class="modal-body" style="display: flex; gap: 24px; flex-direction: row; flex-wrap: wrap; align-items: flex-start;">
<!-- Formulário de Cadastro (Esquerda) -->
@@ -395,7 +434,7 @@
<div class="modal-content">
<div class="modal-header">
<h2>🔌 Configuração do Wasabi Storage</h2>
<span class="modal-close" onclick="closePluginsModal()">&times;</span>
<span class="modal-close">&times;</span>
</div>
<div class="modal-body">
<form id="pluginsForm" onsubmit="updatePluginsConfig(event)">
@@ -445,7 +484,7 @@
<div class="modal-content">
<div class="modal-header">
<h2>🔄 Sincronização e Envio de Imagens</h2>
<span class="modal-close" onclick="closeSyncModal()">&times;</span>
<span class="modal-close">&times;</span>
</div>
<div class="modal-body">
<div class="sync-tabs">
+3
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Resetar Sistema - DentalSys</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap">
<link rel="stylesheet" href="/style.css">
<style>
body {
+250 -29
View File
@@ -2,7 +2,8 @@
DENTAL IMAGE MANAGER - PREMIUM STYLES (Mobile-First)
================================================================ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap');
/* Fontes carregadas via <link rel="preconnect/stylesheet"> no <head> de cada
página (evita o @import render-blocking, que serializa o download). */
:root {
/* Premium Color Palette */
@@ -133,15 +134,16 @@ body {
}
.nav-item {
padding: 12px 16px;
border-radius: 10px;
padding: 8px 12px;
border-radius: 8px;
color: var(--dark-color);
opacity: 0.7;
text-decoration: none;
font-weight: 500;
font-size: 0.92rem;
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
transition: all 0.3s ease;
}
@@ -168,16 +170,17 @@ body {
}
.sidebar-footer {
padding: 0 24px;
padding: 0 16px;
margin-bottom: 12px;
}
.logout-btn {
width: 100%;
padding: 12px;
padding: 10px;
background: transparent;
border: 1px solid var(--glass-border);
color: #a0aec0;
border-radius: 10px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
@@ -185,6 +188,7 @@ body {
gap: 8px;
font-family: 'Inter', sans-serif;
font-weight: 500;
font-size: 0.92rem;
transition: all 0.3s;
}
@@ -420,6 +424,17 @@ body {
transition: transform 0.3s ease;
}
/* Miniatura como <img> para permitir lazy-load nativo (loading="lazy"),
preenchendo o .image-preview com o mesmo enquadramento do background anterior. */
.image-preview .preview-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.image-card:hover .image-preview {
transform: scale(1.03);
}
@@ -574,7 +589,7 @@ body {
}
.modal-body {
padding: 32px;
padding: 24px;
overflow-y: auto;
flex: 1;
}
@@ -584,7 +599,7 @@ body {
================================================================ */
.form-group {
margin-bottom: 24px;
margin-bottom: 16px;
}
.form-group label {
@@ -600,10 +615,10 @@ body {
.form-group select,
.form-group textarea {
width: 100%;
padding: 14px 16px;
padding: 10px 12px;
border: 2px solid var(--border-color);
border-radius: var(--radius);
font-size: 1rem;
font-size: 0.95rem;
font-family: inherit;
transition: all 0.2s ease;
background: var(--bg-color);
@@ -621,47 +636,168 @@ body {
}
/* ================================================================
TRANSFORM GRID (Editor de Imagens)
NEW TRANSFORM LAYOUT (Editor de Imagens)
================================================================ */
.transform-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
.new-transform-layout {
display: flex;
gap: 24px;
align-items: stretch;
justify-content: center;
padding: 16px 0;
min-height: 400px;
}
.transform-option {
@media (max-width: 768px) {
.new-transform-layout {
flex-direction: column;
align-items: center;
}
}
.transform-panel {
flex: 1;
background: var(--bg-surface);
border: 2px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 16px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: var(--shadow-sm);
min-width: 0; /* allows flex shrinking properly */
max-width: 45%;
}
.transform-option:hover {
border-color: var(--primary-light);
transform: translateY(-4px);
box-shadow: var(--shadow);
@media (max-width: 768px) {
.transform-panel {
max-width: 100%;
width: 100%;
}
}
.transform-option.selected {
.transform-panel.panel-preview {
border-color: var(--primary-color);
background: rgba(79, 70, 229, 0.05);
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1);
}
.transform-option img {
.panel-title {
font-size: 1rem;
font-weight: 700;
color: var(--text-secondary);
margin-bottom: 16px;
margin-top: 0;
text-align: center;
}
.transform-panel .img-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
position: relative;
}
.transform-panel img {
max-width: 100%;
height: 180px;
max-height: 350px;
object-fit: contain;
border-radius: var(--radius-sm);
margin-bottom: 12px;
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Sidebar Options */
.transform-sidebar {
width: 80px;
display: flex;
flex-direction: column;
align-items: center;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
padding: 16px 8px;
box-shadow: var(--shadow-sm);
flex-shrink: 0;
}
@media (max-width: 768px) {
.transform-sidebar {
width: 100%;
flex-direction: row;
border-radius: var(--radius-lg);
justify-content: space-around;
padding: 12px;
}
}
.sidebar-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin-bottom: 16px;
margin-top: 0;
}
@media (max-width: 768px) {
.sidebar-title { display: none; }
}
.sidebar-buttons {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
@media (max-width: 768px) {
.sidebar-buttons {
flex-direction: row;
justify-content: center;
}
}
.sidebar-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-color);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
margin: 0 auto;
}
.sidebar-btn:hover {
background: var(--primary-light);
border-color: var(--primary-color);
color: white;
transform: translateY(-2px);
}
.sidebar-btn.active {
background: var(--primary-color);
border-color: var(--primary-dark);
color: white;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
.sidebar-btn .icon {
font-size: 1.2rem;
margin-bottom: 2px;
}
.sidebar-btn .label {
font-size: 0.65rem;
font-weight: 600;
line-height: 1;
}
/* ================================================================
@@ -996,3 +1132,88 @@ body.is-admin .admin-only {
}
}
/* Wasabi Alert Banner */
.wasabi-alert-banner {
background-color: #fee2e2;
border-bottom: 1px solid #f87171;
color: #991b1b;
padding: 12px 24px;
display: flex;
align-items: center;
gap: 16px;
z-index: 10000;
position: sticky;
top: 0;
width: 100%;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.wasabi-alert-icon {
font-size: 24px;
}
.wasabi-alert-content {
flex-grow: 1;
font-size: 14px;
line-height: 1.5;
}
.wasabi-alert-btn {
background-color: #ef4444;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
}
/* ================================================================
SYNC TABS & MODALS
================================================================ */
.sync-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
border-bottom: 2px solid var(--border-color);
padding-bottom: 12px;
}
.sync-tab-btn {
padding: 10px 16px;
background: transparent;
border: none;
border-radius: 8px;
color: var(--text-secondary);
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.95rem;
}
.sync-tab-btn:hover {
background: var(--bg-surface);
color: var(--primary-color);
}
.sync-tab-btn.active {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
color: white;
box-shadow: var(--shadow-sm);
}
.sync-tab-content {
display: none;
animation: fadeIn 0.3s ease;
}
.sync-tab-content.active {
display: block;
}
.wasabi-alert-btn:hover {
background-color: #dc2626;
}
+78
View File
@@ -0,0 +1,78 @@
const db = require('./database');
const storage = require('./storage');
const imageProcessor = require('./image-processor');
const fetch = require('node-fetch');
async function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function run() {
console.log('🚀 Iniciando compressão EXTREMA de TODAS as miniaturas...');
await db.initDatabase();
await storage.loadConfigFromDb();
// Buscar todas as imagens que possuem miniatura
const rows = await db.all(`
SELECT id, filename, thumb_filename, client_name, patient_name
FROM images
WHERE thumb_filename IS NOT NULL AND enabled = 1
`);
console.log(`🔍 Encontradas ${rows.length} imagens com miniatura para otimização.`);
let successCount = 0;
let failCount = 0;
for (const row of rows) {
console.log(`\n⚙️ Otimizando miniatura do ID ${row.id}: ${row.filename}`);
try {
// 1. Obter URL de download do Wasabi (baixar a ORIGINAL, não a miniatura pesada)
const url = await storage.getDownloadPresignedUrl(row.filename, row.client_name, row.patient_name);
if (!url) {
console.error(`❌ Não foi possível gerar URL para a imagem original ${row.filename}`);
failCount++;
continue;
}
// 2. Baixar a imagem original do Wasabi
const response = await fetch(url);
if (!response.ok) {
console.error(`❌ Erro ao baixar imagem original do Wasabi (${response.status})`);
failCount++;
continue;
}
const arrayBuffer = await response.arrayBuffer();
const imageBuffer = Buffer.from(arrayBuffer);
// 3. Gerar miniatura (agora com JPEG super comprimido)
const thumbBuffer = await imageProcessor.getThumbnail(imageBuffer, 400, 400);
// 4. Salvar miniatura substituindo a antiga
await storage.saveImage(row.thumb_filename, thumbBuffer, row.client_name, row.patient_name, false);
console.log(`✅ Miniatura ultra-leve gerada e salva com sucesso para ID ${row.id} (${thumbBuffer.length} bytes)`);
successCount++;
// Aguardar 500ms
await delay(500);
} catch (err) {
console.error(`❌ Erro ao processar ID ${row.id}:`, err.message);
failCount++;
}
}
console.log(`\n🎉 Processamento concluído!`);
console.log(`✅ Sucessos: ${successCount}`);
console.log(`❌ Falhas: ${failCount}`);
process.exit(0);
}
run().catch(err => {
console.error('❌ Erro crítico:', err);
process.exit(1);
});
+17 -2
View File
@@ -72,13 +72,28 @@ router.post('/verify-client-credentials', async (req, res) => {
// Obter a API KEY do ambiente ou usar uma padrão (fallback local temporário)
const serverApiKey = process.env.CLIENT_API_KEY || 'rf-dental-secure-key-2026';
// Se o usuário mandou apenas o Token/API Key (fluxo do Client Windows)
if (token) {
if (token === serverApiKey) {
return res.json({ success: true, message: 'API Key válida' });
}
// Se não for a API Key padrão, tentamos verificar se é um JWT
// Verificar se é um machine_token (UUID tem hífen)
if (email && password && token.includes('-')) {
console.log(`[AUTH] Tentando validar machine_token: ${token} para email: ${email}`);
const device = await db.get(`SELECT * FROM clinics_devices WHERE machine_token = $1`, [token]);
if (device && device.email === email && device.is_active) {
const validPassword = await verifyPassword(password, device.password_hash);
console.log(`[AUTH] validPassword=${validPassword}`);
if (validPassword) {
return res.json({ success: true, message: 'Machine Token e Credenciais válidas', clinic: { name: device.clinic_name, pc: device.pc_name } });
}
} else {
console.log(`[AUTH] Falha: device=${!!device}, is_active=${device?.is_active}`);
}
return res.status(401).json({ error: 'Credenciais da clínica ou Token inválidos' });
}
// Se não for a API Key padrão nem machine_token, tentamos verificar se é um JWT
try {
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = require('../auth');
+110 -33
View File
@@ -4,6 +4,75 @@ const db = require('../database');
const imageProcessor = require('../image-processor');
const path = require('path');
const fs = require('fs').promises;
const storage = require('../storage');
// ================================================================
// GET /api/images/unique-clients - Buscar lista leve de clínicas
// ================================================================
router.get('/unique-clients', async (req, res) => {
try {
const clients = await db.all('SELECT DISTINCT client_name FROM images WHERE client_name IS NOT NULL');
res.json(clients.map(c => c.client_name));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ================================================================
// POST /api/images/upload - Envio Manual via Web
// ================================================================
router.post('/upload', async (req, res) => {
try {
const { imageBase64, patientData } = req.body;
if (!imageBase64 || !patientData || !patientData.name) {
return res.status(400).json({ error: 'Dados incompletos: imageBase64 e patientData.name são obrigatórios.' });
}
const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, "");
const imageBuffer = Buffer.from(base64Data, 'base64');
await imageProcessor.validateImage(imageBuffer);
const clientName = 'Upload Manual';
const patientName = patientData.name;
const timestamp = Date.now();
const guid = Math.random().toString(36).substring(2, 15);
const filename = `${timestamp}_${guid}.png`;
await storage.saveImage(filename, imageBuffer, clientName, patientName, false);
let thumbFilename = null;
try {
const thumbBuffer = await imageProcessor.getThumbnail(imageBuffer, 400, 400);
thumbFilename = `thumb_${filename}`;
await storage.saveImage(thumbFilename, thumbBuffer, clientName, patientName, false);
} catch (e) {
console.error('Erro ao gerar thumbnail manual:', e);
}
const doctor = patientData.doctor || '';
const remark = patientData.remark || '';
const result = await db.run(
`INSERT INTO images (
image_guid, patient_name, client_name, original_filename, filename, thumb_filename, enabled, doctor, remark
) VALUES ($1, $2, $3, $4, $5, $6, 1, $7, $8)`,
[guid, patientName, clientName, 'manual_upload.png', filename, thumbFilename, doctor, remark]
);
// Notificar clientes socket para atualizar galeria automaticamente
if (req.app.get('io') || global.io) {
const io = req.app.get('io') || global.io;
if (io) io.emit('new-image', { filename: filename, thumb: thumbFilename, patientName: patientName });
}
res.json({ success: true });
} catch (error) {
console.error('Erro no upload manual:', error);
res.status(500).json({ error: error.message });
}
});
// ================================================================
// GET /api/images - Listar imagens
@@ -54,10 +123,16 @@ router.get('/patients', async (req, res) => {
let params = [enabledVal];
if (clientName) {
where += ` AND client_name = $${paramIndex++}`;
where += ` AND client_name = $${paramIndex}`;
params.push(clientName);
paramIndex++;
}
// Pagination
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const offset = (page - 1) * limit;
if (search) {
where += ` AND (patient_name LIKE $${paramIndex} OR image_guid LIKE $${paramIndex + 1})`;
params.push(`%${search}%`, `%${search}%`);
@@ -73,14 +148,14 @@ router.get('/patients', async (req, res) => {
MAX(client_name) AS client_name,
COUNT(*) AS image_count,
MAX(created_at) AS last_date,
(SELECT filename FROM images i2
(SELECT COALESCE(thumb_filename, filename) FROM images i2
WHERE i2.patient_name = images.patient_name AND i2.enabled = ${enabledVal}
ORDER BY i2.created_at DESC LIMIT 1) AS thumb_filename
FROM images
${where}
GROUP BY patient_name
ORDER BY MAX(created_at) DESC
LIMIT 200`,
LIMIT ${limit} OFFSET ${offset}`,
params
);
@@ -118,6 +193,28 @@ router.get('/by-patient', async (req, res) => {
}
});
// ================================================================
// DELETE /api/images/by-patient - Excluir todas imagens de um paciente
// ================================================================
router.delete('/by-patient', async (req, res) => {
try {
const patientName = req.query.name;
if (!patientName) return res.status(400).json({ error: 'Parâmetro name obrigatório' });
const images = await db.all('SELECT * FROM images WHERE patient_name = $1', [patientName]);
for (const image of images) {
await storage.deleteImage(image.filename, image.client_name, image.patient_name);
}
await db.run('DELETE FROM images WHERE patient_name = $1', [patientName]);
res.json({ success: true, count: images.length });
} catch (error) {
console.error('Erro ao excluir imagens do paciente:', error);
res.status(500).json({ error: error.message });
}
});
// ================================================================
// GET /api/images/:id - Buscar imagem por ID
// ================================================================
@@ -139,7 +236,7 @@ router.get('/:id', async (req, res) => {
router.get('/stats', async (req, res) => {
try {
const total = await db.get('SELECT COUNT(*) as count FROM images');
const enabled = await db.get('SELECT COUNT(*) as count FROM images WHERE enabled = true');
const enabled = await db.get('SELECT COUNT(*) as count FROM images WHERE enabled = 1');
const withTooth = await db.get('SELECT COUNT(*) as count FROM images WHERE tooth_number IS NOT NULL');
res.json({
@@ -168,19 +265,11 @@ router.get('/:id/transformations', async (req, res) => {
const originalId = image.original_image_id || image.id;
const originalImage = await db.get('SELECT * FROM images WHERE id = $1', [originalId]);
const originalPath = path.join(__dirname, '../uploads', originalImage.filename);
let originalBuffer;
try {
originalBuffer = await fs.readFile(originalPath);
originalBuffer = await storage.getImageBuffer(originalImage.filename, originalImage.client_name, originalImage.patient_name, false);
} catch (e) {
if (e.code === 'ENOENT') {
// Fallback for images corrupted by previous bug
const fallbackPath = path.join(__dirname, '../processed', originalImage.filename);
originalBuffer = await fs.readFile(fallbackPath);
} else {
throw e;
}
originalBuffer = await storage.getImageBuffer(originalImage.filename, originalImage.client_name, originalImage.patient_name, true);
}
const transformations = [
@@ -232,27 +321,20 @@ router.post('/:id/transform', async (req, res) => {
const realOriginal = await db.get('SELECT * FROM images WHERE id = $1', [realOriginalId]);
// Processar a imagem
const originalPath = path.join(__dirname, '../uploads', realOriginal.filename);
let originalBuffer;
try {
originalBuffer = await fs.readFile(originalPath);
originalBuffer = await storage.getImageBuffer(realOriginal.filename, realOriginal.client_name, realOriginal.patient_name, false);
} catch (e) {
if (e.code === 'ENOENT') {
const fallbackPath = path.join(__dirname, '../processed', realOriginal.filename);
originalBuffer = await fs.readFile(fallbackPath);
} else {
throw e;
}
originalBuffer = await storage.getImageBuffer(realOriginal.filename, realOriginal.client_name, realOriginal.patient_name, true);
}
let processed = await imageProcessor.rotate(originalBuffer, rotation);
processed = await imageProcessor.flip(processed, flipH, flipV);
// Salvar nova versão em processed
// Salvar nova versão em processed (local e nuvem)
const timestamp = Date.now();
const processedFilename = `${realOriginal.image_guid}_${timestamp}.png`;
const processedPath = path.join(__dirname, '../processed', processedFilename);
await fs.writeFile(processedPath, processed);
await storage.saveImage(processedFilename, processed, realOriginal.client_name, realOriginal.patient_name, true);
// Desabilitar a imagem active atual para que não fique duplicada na interface
await db.run('UPDATE images SET enabled = 0 WHERE id = $1', [req.params.id]);
@@ -305,7 +387,7 @@ router.put('/:id/patient-info', async (req, res) => {
patient_name_resumed = $1,
tooth_number = $2,
tooth_side = $3,
enabled = true
enabled = 1
WHERE id = $4`,
[patientNameResumed, toothNumber, toothSide, req.params.id]
);
@@ -361,13 +443,8 @@ router.delete('/:id', async (req, res) => {
});
}
// Apagar arquivo
const filePath = path.join(__dirname, '../processed', image.filename);
try {
await fs.unlink(filePath);
} catch (e) {
console.warn('Arquivo não encontrado para deletar:', filePath);
}
// Apagar arquivo local e do S3
await storage.deleteImage(image.filename, image.client_name, image.patient_name);
// Apagar do banco
await db.run('DELETE FROM images WHERE id = $1', [req.params.id]);
+36 -4
View File
@@ -4,6 +4,7 @@ const db = require('../database');
const { verifyPassword } = require('../auth');
const fs = require('fs').promises;
const path = require('path');
const storage = require('../storage');
// ================================================================
// DELETE /api/system/factory-reset - Limpar sistema inteiro
@@ -110,6 +111,18 @@ router.get('/storage-config', async (req, res) => {
}
});
// ================================================================
// GET /api/system/wasabi-status - Retorna status em tempo real
// ================================================================
router.get('/wasabi-status', (req, res) => {
try {
const status = storage.getWasabiStatus();
res.json(status);
} catch (error) {
res.status(500).json({ error: 'Erro ao verificar status do Wasabi.' });
}
});
// ================================================================
// POST /api/system/storage-config - Salvar configurações de armazenamento
// ================================================================
@@ -139,12 +152,31 @@ router.post('/storage-config', async (req, res) => {
}
}
const finalAccessKey = wasabiAccessKey.trim();
const finalBucket = wasabiBucket.trim();
const finalRegion = (wasabiRegion || 'us-east-1').trim();
const finalEndpoint = (wasabiEndpoint || 's3.wasabisys.com').trim();
// 1. Validar as credenciais contra os servidores do Wasabi ANTES de salvar
const validation = await storage.validateCredentials(
finalAccessKey,
finalSecret,
finalBucket,
finalRegion,
finalEndpoint
);
if (!validation.valid) {
return res.status(400).json({ error: validation.message });
}
// 2. Se for válido, salvar no banco
const configJson = JSON.stringify({
wasabiAccessKey: wasabiAccessKey.trim(),
wasabiAccessKey: finalAccessKey,
wasabiSecretKey: finalSecret,
wasabiBucket: wasabiBucket.trim(),
wasabiRegion: (wasabiRegion || 'us-east-1').trim(),
wasabiEndpoint: (wasabiEndpoint || 's3.wasabisys.com').trim()
wasabiBucket: finalBucket,
wasabiRegion: finalRegion,
wasabiEndpoint: finalEndpoint
});
await db.run(`
@@ -0,0 +1,78 @@
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
const db = require('./database');
const storage = require('./storage');
require('dotenv').config();
async function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function run() {
try {
console.log("⏳ Iniciando o banco de dados...");
// Hack para inicializar banco usando código do projeto
db.initDatabase = require('./database').initDatabase || (async () => {
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.DB_HOST || '10.99.0.3',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'postgres',
database: process.env.DB_NAME || 'dental_images',
password: process.env.DB_PASSWORD
});
db.all = async (query, params) => { const r = await pool.query(query, params); return r.rows; };
db.run = async (query, params) => { await pool.query(query, params); return {}; };
});
await db.initDatabase();
await storage.loadConfigFromDb();
console.log("🔍 Buscando imagens sem thumbnail...");
const images = await db.all("SELECT * FROM images WHERE thumb_filename IS NULL AND filename LIKE '%.png'");
console.log(`Encontradas ${images.length} imagens para processar.`);
for (let i = 0; i < images.length; i++) {
const img = images[i];
console.log(`[${i+1}/${images.length}] Processando ${img.filename}...`);
try {
// Tenta pegar o buffer (S3 ou local)
const buffer = await storage.getImageBuffer(img.filename, img.client_name, img.patient_name, false);
if (!buffer) {
console.log(`⚠️ Imagem não encontrada no storage: ${img.filename}`);
continue;
}
// Gera o thumbnail usando Sharp (já está no package.json do servidor)
const thumbBuffer = await sharp(buffer)
.resize(300, 300, { fit: 'inside' })
.jpeg({ quality: 80 })
.toBuffer();
const thumbFilename = img.filename.replace('.png', '_thumb.jpg');
// Salva o thumbnail no storage
await storage.saveImage(thumbFilename, thumbBuffer, img.client_name, img.patient_name, false);
// Atualiza o banco de dados
await db.run("UPDATE images SET thumb_filename = $1 WHERE id = $2", [thumbFilename, img.id]);
console.log(`✅ Thumbnail gerado: ${thumbFilename}`);
// Pequena pausa para não estourar memória / limite da API da AWS
await delay(200);
} catch (err) {
console.error(`❌ Erro ao processar ${img.filename}: ${err.message}`);
}
}
console.log("🎉 Concluído!");
process.exit(0);
} catch (err) {
console.error("❌ Falha fatal:", err);
process.exit(1);
}
}
run();
+219 -13
View File
@@ -20,6 +20,7 @@ const adminClinicsRoutes = require('./routes/admin-clinics');
const { router: clientAuthRoutes, requireClientAuth } = require('./routes/client-auth');
const { checkInstallation } = require('./installer/check-installation');
const redis = require('./redis');
const storage = require('./storage');
// ================================================================
// CONFIGURAÇÃO DO SERVIDOR
@@ -279,6 +280,36 @@ app.post('/api/socket/test-connection', authenticateToken, requireAdmin, async (
}
});
// ================================================================
// SINCRONIZAÇÃO MANUAL
// ================================================================
app.post('/api/system/trigger-sync', authenticateToken, requireAdmin, (req, res) => {
try {
const sockets = Array.from(io.sockets.sockets.values());
const identifiedWindowsClients = connectedClients.filter(c => {
return (c.type === 'client' || c.type === 'windows') && sockets.some(s => s.id === c.socketId);
});
let devicesTriggered = 0;
identifiedWindowsClients.forEach(client => {
const socket = io.sockets.sockets.get(client.socketId);
if (socket) {
socket.emit('force-sync');
devicesTriggered++;
}
});
res.json({
success: true,
devicesTriggered: devicesTriggered
});
} catch (error) {
console.error('Erro ao acionar sincronização:', error);
res.status(500).json({ error: 'Erro interno ao acionar sincronização.' });
}
});
// API de Instalação - Status
app.get('/api/install/status', async (req, res) => {
try {
@@ -573,6 +604,36 @@ app.use('/api/admin/clinics', adminClinicsRoutes);
// Serve static files e rotas estáticas
app.use('/login', express.static('auth'));
app.use(express.static('public'));
// Rota de proxy para arquivos do S3 (ou locais via fallback)
// Rota de proxy para arquivos do S3 (ou locais via fallback)
app.get('/uploads/:filename', async (req, res, next) => {
try {
// Adicionar headers de cache estritos (cache por 1 ano, pois as imagens nunca mudam o mesmo filename)
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
const filename = req.params.filename;
const fsSync = require('fs');
const localUpload = path.join(UPLOAD_DIR, filename);
const localProcessed = path.join(PROCESSED_DIR, filename);
// Se a imagem existe fisicamente na VPS, serve daqui e ignora o Wasabi.
if (fsSync.existsSync(localUpload) || fsSync.existsSync(localProcessed)) {
return next();
}
if (storage.isWasabiEnabled()) {
const presignedUrl = await storage.getDownloadPresignedUrl(filename);
if (presignedUrl) {
// Redirecionamento 302 para o navegador baixar diretamente do Wasabi
return res.redirect(302, presignedUrl);
}
}
next(); // Passa pro express.static se Wasabi estiver desativado ou falhou
} catch (err) {
next(); // Se der erro, tenta o fallback do express.static local
}
});
app.use('/uploads', express.static(UPLOAD_DIR));
app.use('/uploads', express.static(PROCESSED_DIR));
@@ -609,8 +670,10 @@ app.get('/download-client', async (req, res) => {
// ================================================================
// Middleware de Autenticação para o Socket.IO
io.use((socket, next) => {
io.use(async (socket, next) => {
const token = socket.handshake.auth?.token || socket.handshake.query?.token;
const email = socket.handshake.auth?.email;
const password = socket.handshake.auth?.password;
// Obter a API KEY do ambiente ou usar uma padrão (fallback local temporário)
const serverApiKey = process.env.CLIENT_API_KEY || 'rf-dental-secure-key-2026';
@@ -626,6 +689,31 @@ io.use((socket, next) => {
return next(); // Token válido, prosseguir
}
// 1.5 Verificar se é um machine_token (usado pelo novo app desktop 1.2.4+)
if (email && password && token.includes('-')) {
try {
const device = await db.get(`SELECT * FROM clinics_devices WHERE machine_token = $1`, [token]);
if (device && device.email === email && device.is_active) {
const { verifyPassword } = require('./auth');
const validPassword = await verifyPassword(password, device.password_hash);
if (validPassword) {
console.log(`✅ Conexão Socket.IO autorizada via machine_token - IP: ${socket.handshake.address}`);
socket.clientType = 'sensor';
socket.user = { role: 'client_device', clinicId: device.id, clinicName: device.clinic_name, pcName: device.pc_name };
// Atualiza o último IP e data
await db.run(`UPDATE clinics_devices SET last_ip = $1 WHERE id = $2`, [socket.handshake.address, device.id]);
return next(); // Credenciais válidas
}
}
} catch (e) {
console.error('Erro ao validar machine_token no socket:', e);
}
console.log(`❌ Conexão Socket.IO rejeitada (Machine Token inválido) - IP: ${socket.handshake.address}`);
return next(new Error('Authentication error: Invalid machine_token or credentials'));
}
// 2. Se não for a API_KEY, tentar validar como JWT (Painel Web ou Novo App Desktop)
try {
const user = jwt.verify(token, JWT_SECRET);
@@ -775,7 +863,7 @@ io.on('connection', (socket) => {
// Verificar se a imagem já foi inserida no banco
const existing = await db.get(
"SELECT id, filename FROM images WHERE original_filename = $1 AND enabled = true LIMIT 1",
"SELECT id, filename FROM images WHERE original_filename = $1 AND enabled = 1 LIMIT 1",
[data.metadata.fileName]
);
if (existing) {
@@ -801,16 +889,26 @@ io.on('connection', (socket) => {
// Validar imagem
await imageProcessor.validateImage(imageBuffer);
// Salvar imagem original
const timestamp = Date.now();
const filename = `${timestamp}_${data.metadata.guid}.png`;
const filePath = path.join(UPLOAD_DIR, filename);
await fs.writeFile(filePath, imageBuffer);
// Buscar nome do cliente se identificado
const clientInfo = connectedClients.find(c => c.socketId === socket.id);
const clientName = clientInfo ? clientInfo.name : 'Cliente não identificado';
const patientName = data.patientData ? `${data.patientData.firstName || ''} ${data.patientData.lastName || ''}`.trim() || 'Desconhecido' : 'Desconhecido';
// Salvar imagem original local e na nuvem
const timestamp = Date.now();
const filename = `${timestamp}_${data.metadata.guid}.png`;
await storage.saveImage(filename, imageBuffer, clientName, patientName, false);
// Gerar e salvar miniatura (Thumbnail)
let thumbFilename = null;
try {
const thumbBuffer = await imageProcessor.getThumbnail(imageBuffer, 400, 400);
thumbFilename = `thumb_${filename}`;
await storage.saveImage(thumbFilename, thumbBuffer, clientName, patientName, false);
} catch (thumbErr) {
console.error('Erro ao gerar thumbnail:', thumbErr);
// Continua mesmo se falhar o thumbnail, usando fallback no front
}
// Determinar data de criação
const formatLocalDateTime = (date) => {
@@ -831,8 +929,9 @@ io.on('connection', (socket) => {
enabled,
doctor,
remark,
thumb_filename,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
[
data.metadata.guid,
data.patientData.name || 'Paciente não identificado',
@@ -842,12 +941,13 @@ io.on('connection', (socket) => {
1,
data.patientData.doctor || null,
data.patientData.remark || null,
thumbFilename,
createdAt
]
);
console.log('✅ Imagem salva no banco. ID:', result.lastID);
console.log('✅ Arquivo salvo em:', filePath);
console.log('✅ Arquivo submetido ao storage (Wasabi/Local):', filename);
// Confirmar para o cliente
const ackData = {
@@ -891,6 +991,111 @@ io.on('connection', (socket) => {
}
});
// Novo evento: Client solicita Presigned URLs para envio direto ao Wasabi
socket.on('request-presigned-urls', async (data, callback) => {
try {
if (!storage.isWasabiEnabled()) {
if (typeof callback === 'function') callback({ success: false, error: 'Wasabi não configurado no servidor.' });
return;
}
const clientInfo = connectedClients.find(c => c.socketId === socket.id);
const clientName = clientInfo ? clientInfo.name : 'Cliente não identificado';
const patientName = data.patientData ? `${data.patientData.firstName || ''} ${data.patientData.lastName || ''}`.trim() || 'Desconhecido' : 'Desconhecido';
const timestamp = Date.now();
const filename = `${timestamp}_${data.metadata.guid}.png`;
const thumbFilename = `${timestamp}_${data.metadata.guid}_thumb.jpg`;
const originalUrl = await storage.getUploadPresignedUrl(filename, clientName, patientName, 'image/png');
const thumbnailUrl = await storage.getUploadPresignedUrl(thumbFilename, clientName, patientName, 'image/jpeg');
if (!originalUrl || !thumbnailUrl) {
if (typeof callback === 'function') callback({ success: false, error: 'Falha ao gerar URLs no S3.' });
return;
}
if (typeof callback === 'function') {
callback({
success: true,
originalUrl,
thumbnailUrl,
filename,
thumbFilename
});
}
} catch (error) {
console.error('❌ Erro ao gerar presigned URLs:', error);
if (typeof callback === 'function') callback({ success: false, error: error.message });
}
});
// Novo evento: Client notifica que concluiu o envio direto ao Wasabi
socket.on('image-upload-direct', async (data, callback) => {
try {
console.log('📥 [image-upload-direct] Notificação de upload recebida de:', socket.id);
const clientInfo = connectedClients.find(c => c.socketId === socket.id);
const clientName = clientInfo ? clientInfo.name : 'Cliente não identificado';
// Determinar data de criação
const formatLocalDateTime = (date) => {
const pad = (num) => String(num).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
const createdAt = (data.patientData && data.patientData.photographTime) || formatLocalDateTime(new Date());
// Salvar no banco de dados com a referência para a thumbnail
const result = await db.run(
`INSERT INTO images (
image_guid,
patient_name,
client_name,
original_filename,
filename,
enabled,
doctor,
remark,
thumb_filename,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
[
data.metadata.guid,
data.patientData.name || 'Paciente não identificado',
clientName,
data.metadata.fileName,
data.filename,
1,
data.patientData.doctor || null,
data.patientData.remark || null,
data.thumbFilename,
createdAt
]
);
console.log('✅ Imagem salva no banco via direct upload. ID:', result.lastID);
const ackData = {
success: true,
imageId: result.lastID,
message: 'Imagem salva com sucesso (Direct Upload)',
filename: data.filename,
originalFilename: data.metadata.fileName,
savedAt: new Date().toISOString()
};
socket.emit('image-received', ackData);
io.emit('new-image-saved', { imageId: result.lastID, filename: data.filename, thumbFilename: data.thumbFilename });
if (typeof callback === 'function') callback(ackData);
} catch (error) {
console.error('❌ Erro no image-upload-direct:', error);
if (typeof callback === 'function') callback({ success: false, error: error.message });
}
});
socket.on('check-image-exists', async (data, callback) => {
try {
if (!data || !data.fileName) {
@@ -899,7 +1104,7 @@ io.on('connection', (socket) => {
}
const existing = await db.get(
"SELECT id, filename FROM images WHERE original_filename = $1 AND enabled = true LIMIT 1",
"SELECT id, filename FROM images WHERE original_filename = $1 AND enabled = 1 LIMIT 1",
[data.fileName]
);
@@ -1015,7 +1220,8 @@ async function start() {
if (process.env.DB_TYPE === 'sqlite' || (process.env.DB_HOST && process.env.DB_NAME)) {
try {
await db.initDatabase();
console.log('✅ Banco de dados inicializado');
await storage.loadConfigFromDb();
console.log('✅ Banco de dados e Storage inicializados');
} catch (error) {
console.log('⚠️ Erro ao inicializar o banco:', error.message);
}
+347
View File
@@ -0,0 +1,347 @@
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, '_');
}
/**
* 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,
deleteImage,
getDownloadPresignedUrl,
getUploadPresignedUrl,
isWasabiEnabled: () => !!(s3 && currentBucket),
validateCredentials,
getWasabiStatus: () => ({
enabled: !!(s3 && currentBucket),
error: lastWasabiError
})
};