feat: consolidate backend improvements, frontend UX fixes and Wasabi storage support
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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'))
|
||||
@@ -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);
|
||||
|
||||
Generated
+735
-3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+259
-247
@@ -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;
|
||||
|
||||
if (!silent) {
|
||||
loadingState.style.display = 'block';
|
||||
imagesGrid.style.display = 'none';
|
||||
emptyState.style.display = 'none';
|
||||
_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,81 +422,78 @@ async function loadPatients(silent = false) {
|
||||
} else {
|
||||
emptyState.style.display = 'none';
|
||||
imagesGrid.style.display = 'grid';
|
||||
_log.info('LOAD_PATIENTS', `imagesGrid display=${imagesGrid.style.display}`);
|
||||
renderPatientCards();
|
||||
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);
|
||||
loadingState.style.display = 'none';
|
||||
emptyState.style.display = 'block';
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
const date = formatDate(p.last_date);
|
||||
const doctor = p.doctor || '—';
|
||||
const remark = p.remark || '—';
|
||||
|
||||
// <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"${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>
|
||||
<div class="image-info">
|
||||
<div class="image-patient-name" title="${escapeHtml(fullName)}">${escapeHtml(fullName)}</div>
|
||||
<div class="image-meta"><span>📅 ${date}</span></div>
|
||||
<div class="image-doctor-remark">
|
||||
<div title="${escapeHtml(doctor)}">🩺 <b>Dentista:</b> ${escapeHtml(doctor)}</div>
|
||||
<div title="${escapeHtml(remark)}">📝 <b>Obs:</b> ${escapeHtml(remark)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Render completo (primeira página / refresh)
|
||||
function renderPatientCards() {
|
||||
_log.group('RENDER_CARDS');
|
||||
_log.info('RENDER_CARDS', `Renderizando ${patients.length} card(s)...`);
|
||||
imagesGrid.innerHTML = patients.map((p, index) => patientCardHTML(p, index)).join('');
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
const fullName = p.patient_name || 'Sem nome';
|
||||
const thumb = p.thumb_filename ? `/uploads/${p.thumb_filename}` : '';
|
||||
const count = p.image_count || 0;
|
||||
const date = formatDate(p.last_date);
|
||||
const doctor = p.doctor || '—';
|
||||
const remark = p.remark || '—';
|
||||
|
||||
if (index === 0) {
|
||||
_log.info('RENDER_CARDS', 'Card #0 dados:', { fullName, thumb, count, date, doctor });
|
||||
}
|
||||
|
||||
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);'}">
|
||||
<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>
|
||||
<div class="image-info">
|
||||
<div class="image-patient-name" title="${escapeHtml(fullName)}">${escapeHtml(fullName)}</div>
|
||||
<div class="image-meta"><span>📅 ${date}</span></div>
|
||||
<div class="image-doctor-remark">
|
||||
<div title="${escapeHtml(doctor)}">🩺 <b>Dentista:</b> ${escapeHtml(doctor)}</div>
|
||||
<div title="${escapeHtml(remark)}">📝 <b>Obs:</b> ${escapeHtml(remark)}</div>
|
||||
</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();
|
||||
});
|
||||
// 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,25 +642,35 @@ async function handleImageClick(imageId) {
|
||||
}
|
||||
|
||||
async function showTransformModal(imageId) {
|
||||
transformModal.classList.add('active');
|
||||
|
||||
// Default view is 'transform'
|
||||
toggleModalView('transform');
|
||||
try {
|
||||
transformModal.classList.add('active');
|
||||
|
||||
// Default view is 'transform'
|
||||
toggleModalView('transform');
|
||||
|
||||
// Find image to show details
|
||||
const image = patientImages.find(img => img.id === imageId);
|
||||
|
||||
// Check if the image has been modified (has original_image_id)
|
||||
const btnResetImage = document.getElementById('btnResetImage');
|
||||
if (image && image.original_image_id) {
|
||||
btnResetImage.style.display = 'flex';
|
||||
btnResetImage.setAttribute('data-modified', 'true');
|
||||
} else {
|
||||
btnResetImage.style.display = 'none';
|
||||
btnResetImage.setAttribute('data-modified', 'false');
|
||||
}
|
||||
// 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.original_image_id) {
|
||||
btnResetImage.style.display = 'flex';
|
||||
btnResetImage.setAttribute('data-modified', 'true');
|
||||
} else {
|
||||
btnResetImage.style.display = 'none';
|
||||
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>`;
|
||||
|
||||
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;
|
||||
// Reseta o ângulo sempre que abrir a imagem
|
||||
fineTuneAngle = 0;
|
||||
updateFineTuneDisplay();
|
||||
|
||||
// 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;
|
||||
function applyFineTuneAngle(angleDelta) {
|
||||
if (angleDelta === 0) {
|
||||
fineTuneAngle = 0;
|
||||
} else {
|
||||
fineTuneAngle += angleDelta;
|
||||
}
|
||||
|
||||
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
|
||||
fineTuneAngle = 0;
|
||||
updateFineTuneDisplay();
|
||||
|
||||
// Show action area
|
||||
document.getElementById('transformActionArea').style.display = 'block';
|
||||
|
||||
// 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 || '';
|
||||
}
|
||||
// Apply to preview image
|
||||
const previewImg = document.getElementById('transformedImgPreview');
|
||||
|
||||
// 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);
|
||||
showToast(e.message, 'error');
|
||||
// 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;
|
||||
@@ -1965,4 +1977,4 @@ window.adjustFineTune = adjustFineTune;
|
||||
window.resetFineTune = resetFineTune;
|
||||
// A aplicação é inicializada no evento DOMContentLoaded acima
|
||||
|
||||
function logout() { localStorage.removeItem('auth_token'); window.location.href = '/login'; }
|
||||
function logout() { localStorage.removeItem('auth_token'); window.location.href = '/login'; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,28 +170,54 @@
|
||||
|
||||
<!-- View 1: Transforms -->
|
||||
<div id="transformViewWrapper" class="transform-view-wrapper">
|
||||
<div id="transformOptions" class="transform-grid"></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">
|
||||
0°
|
||||
</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 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;">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;">
|
||||
<!-- 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>
|
||||
<textarea id="newImageRemark" class="form-control" rows="2" placeholder="Ex: Raio-X Dente 45..."></textarea>
|
||||
@@ -219,7 +258,7 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Novo Paciente</h2>
|
||||
<div class="modal-close" onclick="closeCreatePatientModal()">×</div>
|
||||
<div class="modal-close">×</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()">×</div>
|
||||
<div class="modal-close">×</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()">×</span>
|
||||
<span class="modal-close">×</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()">×</span>
|
||||
<span class="modal-close">×</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()">×</span>
|
||||
<span class="modal-close">×</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()">×</span>
|
||||
<span class="modal-close">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="sync-tabs">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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,9 +123,15 @@ 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})`;
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
Reference in New Issue
Block a user