feat: adicionar thumbnails, ajuste fino flutuante e correções no dental-client
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"serverUrl": "https://rx.scoreodonto.com",
|
||||
"monitorPath": "C:\\ProgramData\\RF\\Dental Sensor\\Images",
|
||||
"clientName": "COMPUTADOR-LOCAL",
|
||||
"clientType": "windows",
|
||||
"apiKey": "rf-dental-secure-key-2026",
|
||||
"adminPassword": "admin"
|
||||
}
|
||||
Generated
+2628
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "dental-client",
|
||||
"version": "1.0.0",
|
||||
"description": "Cliente Windows para envio de imagens dentais",
|
||||
"main": "client-monitor.js",
|
||||
"scripts": {
|
||||
"start": "node client-monitor.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"socket.io-client": "^4.7.5",
|
||||
"chokidar": "^3.5.3"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
dental_images.db
|
||||
uploads
|
||||
processed
|
||||
@@ -0,0 +1,89 @@
|
||||
const db = require('./database');
|
||||
const storage = require('./storage');
|
||||
const sharp = require('sharp');
|
||||
|
||||
// Limit of concurrency
|
||||
const CONCURRENCY = 15;
|
||||
|
||||
async function runBackfill() {
|
||||
console.log('🚀 Iniciando backfill de thumbnails concorrido...');
|
||||
|
||||
// 1. Inicializar banco de dados
|
||||
await db.initDatabase();
|
||||
console.log('✅ Banco de dados inicializado.');
|
||||
|
||||
// 2. Carregar configurações do Wasabi a partir do banco
|
||||
await storage.loadConfigFromDb();
|
||||
console.log('✅ Configuração do Wasabi carregada.');
|
||||
|
||||
// 3. Buscar todas as imagens que não possuem thumb_filename
|
||||
const query = "SELECT id, filename, client_name, patient_name FROM images WHERE (thumb_filename IS NULL OR thumb_filename = '') AND enabled = 1";
|
||||
const images = await db.all(query);
|
||||
console.log(`🔎 Encontradas ${images.length} imagens pendentes de thumbnail.`);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
// Worker function to process a single image
|
||||
async function processImage(img) {
|
||||
try {
|
||||
// a. Baixar imagem original do Wasabi
|
||||
const buffer = await storage.getImageBuffer(img.filename, img.client_name, img.patient_name, false);
|
||||
if (!buffer) {
|
||||
throw new Error(`Não foi possível obter o buffer para ${img.filename}`);
|
||||
}
|
||||
|
||||
// b. Gerar thumbnail WebP
|
||||
const thumbBuffer = await sharp(buffer)
|
||||
.resize({ width: 400, height: 400, fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 80, effort: 4 })
|
||||
.toBuffer();
|
||||
|
||||
// c. Salvar thumbnail no Wasabi
|
||||
const thumbFilename = `thumb_${Date.now()}_${img.id}.webp`;
|
||||
await storage.saveImage(thumbFilename, thumbBuffer, img.client_name, img.patient_name, false);
|
||||
|
||||
// d. Atualizar no banco de dados
|
||||
await db.run('UPDATE images SET thumb_filename = ? WHERE id = ?', [thumbFilename, img.id]);
|
||||
|
||||
console.log(`✅ [ID ${img.id}] Thumbnail salvo com sucesso: ${thumbFilename} (Paciente: ${img.patient_name})`);
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
console.error(`❌ [ID ${img.id}] Erro no processamento da imagem:`, err.message);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrency queue
|
||||
const queue = [...images];
|
||||
const workers = [];
|
||||
|
||||
async function worker() {
|
||||
while (queue.length > 0) {
|
||||
const img = queue.shift();
|
||||
if (!img) break;
|
||||
await processImage(img);
|
||||
}
|
||||
}
|
||||
|
||||
// Start workers
|
||||
for (let i = 0; i < Math.min(CONCURRENCY, images.length); i++) {
|
||||
workers.push(worker());
|
||||
}
|
||||
|
||||
// Wait for all to complete
|
||||
await Promise.all(workers);
|
||||
|
||||
console.log('\n=========================================');
|
||||
console.log('📊 Resumo do Backfill Concorrido:');
|
||||
console.log(`- Sucesso: ${successCount}`);
|
||||
console.log(`- Falhas : ${failCount}`);
|
||||
console.log('=========================================');
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runBackfill().catch(err => {
|
||||
console.error('❌ Erro fatal no script de backfill:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -45,6 +45,14 @@ async function initSQLite() {
|
||||
);
|
||||
`);
|
||||
|
||||
sqliteDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
console.log('✅ Tabela settings SQLite criada/verificada');
|
||||
|
||||
sqliteDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -63,6 +71,7 @@ async function initSQLite() {
|
||||
flip_vertical INTEGER DEFAULT 0,
|
||||
doctor TEXT,
|
||||
remark TEXT,
|
||||
thumb_filename TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
@@ -95,6 +104,9 @@ async function initSQLite() {
|
||||
try {
|
||||
sqliteDb.exec("ALTER TABLE images ADD COLUMN remark TEXT");
|
||||
} catch (e) {}
|
||||
try {
|
||||
sqliteDb.exec("ALTER TABLE images ADD COLUMN thumb_filename TEXT");
|
||||
} catch (e) {}
|
||||
|
||||
console.log('✅ Tabelas SQLite users e images criadas/verificadas');
|
||||
|
||||
@@ -187,6 +199,15 @@ async function createTables() {
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Tabela de configurações
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
console.log('✅ Tabela settings PostgreSQL criada/verificada');
|
||||
console.log('✅ Tabela users criada/verificada');
|
||||
|
||||
// Tabela de imagens
|
||||
@@ -208,6 +229,7 @@ async function createTables() {
|
||||
flip_vertical SMALLINT DEFAULT 0,
|
||||
doctor VARCHAR(255) NULL,
|
||||
remark TEXT NULL,
|
||||
thumb_filename VARCHAR(500) NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
@@ -262,6 +284,35 @@ async function createTables() {
|
||||
await client.query(`ALTER TABLE images ADD COLUMN remark TEXT`);
|
||||
} catch (e) { /* já existe */ }
|
||||
|
||||
try {
|
||||
await client.query(`ALTER TABLE images ADD COLUMN thumb_filename VARCHAR(500)`);
|
||||
console.log('✅ Coluna thumb_filename adicionada');
|
||||
} catch (e) { /* já existe */ }
|
||||
|
||||
// Inserir usuários padrões se a tabela estiver vazia
|
||||
try {
|
||||
const userCountRes = await client.query('SELECT COUNT(*) as count FROM users');
|
||||
const userCount = parseInt(userCountRes.rows[0].count, 10);
|
||||
if (userCount === 0) {
|
||||
console.log('⚠️ Nenhum usuário encontrado no PostgreSQL. Criando usuários padrões...');
|
||||
const bcrypt = require('bcrypt');
|
||||
const hash1 = await bcrypt.hash('Rc362514', 10);
|
||||
const hash2 = await bcrypt.hash('admin1234', 10);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3)`,
|
||||
['rcesar', 'rcesar@rcesar.com', hash1]
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3)`,
|
||||
['admin', 'admin@dental.local', hash2]
|
||||
);
|
||||
console.log('✅ Usuários padrões (rcesar e admin) criados com sucesso no PostgreSQL!');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('⚠️ Erro ao inicializar usuários padrões no PostgreSQL:', e.message);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar tabelas:', error);
|
||||
} finally {
|
||||
@@ -341,8 +392,10 @@ function run(query, params = []) {
|
||||
try {
|
||||
let pgQuery = convertSqlForPg(query);
|
||||
|
||||
// Auto-append RETURNING id for INSERT if not present
|
||||
if (pgQuery.trim().toUpperCase().startsWith('INSERT') && !pgQuery.toUpperCase().includes('RETURNING ID')) {
|
||||
// Auto-append RETURNING id for INSERT if not present (except for settings table)
|
||||
if (pgQuery.trim().toUpperCase().startsWith('INSERT') &&
|
||||
!pgQuery.toUpperCase().includes('RETURNING') &&
|
||||
!pgQuery.toUpperCase().includes('SETTINGS')) {
|
||||
pgQuery += ' RETURNING id';
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express-session": "^1.17.3"
|
||||
"express-session": "^1.17.3",
|
||||
"@aws-sdk/client-s3": "^3.500.0",
|
||||
"@aws-sdk/lib-storage": "^3.500.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ImageOptimizer = void 0;
|
||||
const sharp_1 = __importDefault(require("sharp"));
|
||||
class ImageOptimizer {
|
||||
/**
|
||||
* Optimizes an image: converts to WebP, strips metadata,
|
||||
* and applies a 1:1 Smart Crop if needed.
|
||||
*/
|
||||
static async optimize(buffer, category = 'media', options = {}) {
|
||||
try {
|
||||
let pipeline = (0, sharp_1.default)(buffer);
|
||||
const metadata = await pipeline.metadata();
|
||||
if (!metadata.format)
|
||||
return { buffer, mimetype: 'application/octet-stream' };
|
||||
// 1. Identify Target Size
|
||||
// Default sizes based on category
|
||||
let targetSize = options.size || 1024;
|
||||
if (category === 'partners' || category === 'users')
|
||||
targetSize = 400;
|
||||
if (category === 'benefits')
|
||||
targetSize = 800;
|
||||
// 2. Smart Crop 1:1 if aspect=1:1 is requested or implied by category
|
||||
const shouldCrop = options.aspect === '1:1' || ['partners', 'users'].includes(category);
|
||||
if (shouldCrop) {
|
||||
pipeline = pipeline.resize({
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
fit: sharp_1.default.fit.cover,
|
||||
position: sharp_1.default.strategy.entropy // Smart focal point detection
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Resize without cropping if it's too large
|
||||
pipeline = pipeline.resize({
|
||||
width: targetSize,
|
||||
withoutEnlargement: true,
|
||||
fit: sharp_1.default.fit.inside
|
||||
});
|
||||
}
|
||||
// 3. Convert to WebP & Strip Metadata
|
||||
const optimizedBuffer = await pipeline
|
||||
.webp({ quality: 85, effort: 4 })
|
||||
.toBuffer();
|
||||
return {
|
||||
buffer: optimizedBuffer,
|
||||
mimetype: 'image/webp'
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[SmartVision] Optimization failed:', err);
|
||||
return { buffer, mimetype: 'image/jpeg' }; // Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.ImageOptimizer = ImageOptimizer;
|
||||
//# sourceMappingURL=Optimizer.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"Optimizer.js","sourceRoot":"","sources":["Optimizer.ts"],"names":[],"mappings":";;;;;;AAAA,kDAA0B;AAO1B,MAAa,cAAc;IACvB;;;OAGG;IACH,MAAM,CAAC,KAAK,CAAC,QAAQ,CACjB,MAAc,EACd,WAAmB,OAAO,EAC1B,UAA8C,EAAE;QAEhD,IAAI,CAAC;YACD,IAAI,QAAQ,GAAG,IAAA,eAAK,EAAC,MAAM,CAAC,CAAC;YAC7B,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,QAAQ,EAAE,CAAC;YAE3C,IAAI,CAAC,QAAQ,CAAC,MAAM;gBAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,0BAA0B,EAAE,CAAC;YAE9E,0BAA0B;YAC1B,kCAAkC;YAClC,IAAI,UAAU,GAAG,OAAO,CAAC,IAAI,IAAI,IAAI,CAAC;YACtC,IAAI,QAAQ,KAAK,UAAU,IAAI,QAAQ,KAAK,OAAO;gBAAE,UAAU,GAAG,GAAG,CAAC;YACtE,IAAI,QAAQ,KAAK,UAAU;gBAAE,UAAU,GAAG,GAAG,CAAC;YAE9C,sEAAsE;YACtE,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,KAAK,KAAK,IAAI,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;YAExF,IAAI,UAAU,EAAE,CAAC;gBACb,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC;oBACvB,KAAK,EAAE,UAAU;oBACjB,MAAM,EAAE,UAAU;oBAClB,GAAG,EAAE,eAAK,CAAC,GAAG,CAAC,KAAK;oBACpB,QAAQ,EAAE,eAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,8BAA8B;iBAClE,CAAC,CAAC;YACP,CAAC;iBAAM,CAAC;gBACJ,4CAA4C;gBAC5C,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC;oBACvB,KAAK,EAAE,UAAU;oBACjB,kBAAkB,EAAE,IAAI;oBACxB,GAAG,EAAE,eAAK,CAAC,GAAG,CAAC,MAAM;iBACxB,CAAC,CAAC;YACP,CAAC;YAED,sCAAsC;YACtC,MAAM,eAAe,GAAG,MAAM,QAAQ;iBACjC,IAAI,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;iBAChC,QAAQ,EAAE,CAAC;YAEhB,OAAO;gBACH,MAAM,EAAE,eAAe;gBACvB,QAAQ,EAAE,YAAY;aACzB,CAAC;QACN,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACX,OAAO,CAAC,KAAK,CAAC,oCAAoC,EAAE,GAAG,CAAC,CAAC;YACzD,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE,CAAC,CAAC,WAAW;QAC1D,CAAC;IACL,CAAC;CACJ;AAvDD,wCAuDC"}
|
||||
@@ -0,0 +1,63 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
export interface OptimizationResult {
|
||||
buffer: Buffer;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
export class ImageOptimizer {
|
||||
/**
|
||||
* Optimizes an image: converts to WebP, strips metadata,
|
||||
* and applies a 1:1 Smart Crop if needed.
|
||||
*/
|
||||
static async optimize(
|
||||
buffer: Buffer,
|
||||
category: string = 'media',
|
||||
options: { aspect?: string; size?: number } = {}
|
||||
): Promise<OptimizationResult> {
|
||||
try {
|
||||
let pipeline = sharp(buffer);
|
||||
const metadata = await pipeline.metadata();
|
||||
|
||||
if (!metadata.format) return { buffer, mimetype: 'application/octet-stream' };
|
||||
|
||||
// 1. Identify Target Size
|
||||
// Default sizes based on category
|
||||
let targetSize = options.size || 1024;
|
||||
if (category === 'partners' || category === 'users') targetSize = 400;
|
||||
if (category === 'benefits') targetSize = 800;
|
||||
|
||||
// 2. Smart Crop 1:1 if aspect=1:1 is requested or implied by category
|
||||
const shouldCrop = options.aspect === '1:1' || ['partners', 'users'].includes(category);
|
||||
|
||||
if (shouldCrop) {
|
||||
pipeline = pipeline.resize({
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
fit: sharp.fit.cover,
|
||||
position: sharp.strategy.entropy // Smart focal point detection
|
||||
});
|
||||
} else {
|
||||
// Resize without cropping if it's too large
|
||||
pipeline = pipeline.resize({
|
||||
width: targetSize,
|
||||
withoutEnlargement: true,
|
||||
fit: sharp.fit.inside
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Convert to WebP & Strip Metadata
|
||||
const optimizedBuffer = await pipeline
|
||||
.webp({ quality: 85, effort: 4 })
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
buffer: optimizedBuffer,
|
||||
mimetype: 'image/webp'
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[SmartVision] Optimization failed:', err);
|
||||
return { buffer, mimetype: 'image/jpeg' }; // Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const Optimizer_1 = require("./backend/Optimizer");
|
||||
const manifest_json_1 = __importDefault(require("./manifest.json"));
|
||||
const plugin = {
|
||||
manifest: manifest_json_1.default,
|
||||
async activate(ctx) {
|
||||
// Register for the upload:transform hook
|
||||
ctx.hooks.register('upload:transform', async (payload) => {
|
||||
const { buffer, mimetype, category } = payload;
|
||||
// Only process common image formats
|
||||
const imageTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
|
||||
if (!imageTypes.includes(mimetype)) {
|
||||
return null; // Skip non-images
|
||||
}
|
||||
ctx.logger.info(`[SmartVision] Optimizing image for category: ${category}`);
|
||||
const result = await Optimizer_1.ImageOptimizer.optimize(buffer, category);
|
||||
return {
|
||||
buffer: result.buffer,
|
||||
mimetype: result.mimetype
|
||||
};
|
||||
});
|
||||
ctx.logger.info('SmartVision Optimizer activated');
|
||||
},
|
||||
async deactivate(ctx) {
|
||||
ctx.logger.info('SmartVision Optimizer deactivated');
|
||||
}
|
||||
};
|
||||
exports.default = plugin;
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;AACA,mDAAqD;AACrD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,yCAAyC;QACzC,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,kBAAkB,EAAE,KAAK,EAAE,OAAY,EAAE,EAAE;YAC1D,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;YAE/C,oCAAoC;YACpC,MAAM,UAAU,GAAG,CAAC,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,YAAY,CAAC,CAAC;YAC3E,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;gBACjC,OAAO,IAAI,CAAC,CAAC,kBAAkB;YACnC,CAAC;YAED,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,gDAAgD,QAAQ,EAAE,CAAC,CAAC;YAE5E,MAAM,MAAM,GAAG,MAAM,0BAAc,CAAC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;YAE/D,OAAO;gBACH,MAAM,EAAE,MAAM,CAAC,MAAM;gBACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;aAC5B,CAAC;QACN,CAAC,CAAC,CAAC;QAEH,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,iCAAiC,CAAC,CAAC;IACvD,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB;QAC/B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,mCAAmC,CAAC,CAAC;IACzD,CAAC;CACJ,CAAC;AAEF,kBAAe,MAAM,CAAC"}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
|
||||
import { ImageOptimizer } from './backend/Optimizer';
|
||||
import manifest from './manifest.json';
|
||||
|
||||
const plugin: PluginInstance = {
|
||||
manifest: manifest as any,
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
// Register for the upload:transform hook
|
||||
ctx.hooks.register('upload:transform', async (payload: any) => {
|
||||
const { buffer, mimetype, category } = payload;
|
||||
|
||||
// Only process common image formats
|
||||
const imageTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
|
||||
if (!imageTypes.includes(mimetype)) {
|
||||
return null; // Skip non-images
|
||||
}
|
||||
|
||||
ctx.logger.info(`[SmartVision] Optimizing image for category: ${category}`);
|
||||
|
||||
const result = await ImageOptimizer.optimize(buffer, category);
|
||||
|
||||
return {
|
||||
buffer: result.buffer,
|
||||
mimetype: result.mimetype
|
||||
};
|
||||
});
|
||||
|
||||
ctx.logger.info('SmartVision Optimizer activated');
|
||||
},
|
||||
async deactivate(ctx: PluginContext): Promise<void> {
|
||||
ctx.logger.info('SmartVision Optimizer deactivated');
|
||||
}
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "SmartVision",
|
||||
"displayName": "SmartVision Optimizer",
|
||||
"version": "1.0.0",
|
||||
"description": "Otimização inteligente de imagens com WebP e Smart Crop 1:1.",
|
||||
"author": "Clube67 Team",
|
||||
"category": "integration",
|
||||
"enabled": true,
|
||||
"canDisable": true,
|
||||
"dependencies": [
|
||||
"uploads"
|
||||
],
|
||||
"hooks": {
|
||||
"subscribes": [
|
||||
"upload:transform"
|
||||
],
|
||||
"emits": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const client_s3_1 = require("@aws-sdk/client-s3");
|
||||
const lib_storage_1 = require("@aws-sdk/lib-storage");
|
||||
let storageProvider;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
storageProvider = require('../../dist/core/StorageProvider').storageProvider;
|
||||
}
|
||||
else {
|
||||
storageProvider = require('../../backend/src/core/StorageProvider').storageProvider;
|
||||
}
|
||||
const sharp_1 = __importDefault(require("sharp"));
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const VALID_CATEGORIES = ['documents', 'exports', 'posts', 'cards', 'partners', 'whatsapp', 'media'];
|
||||
// Mapa de extensão → mimetype para o proxy
|
||||
const MIME_MAP = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
||||
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
||||
pdf: 'application/pdf', mp4: 'video/mp4', ogg: 'audio/ogg',
|
||||
mp3: 'audio/mpeg', doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
class UnifiedStorageProviderPlugin {
|
||||
constructor() {
|
||||
this.s3 = null;
|
||||
this.bucket = '';
|
||||
this.region = 'us-east-1';
|
||||
this.localFallbackDir = path_1.default.resolve(process.cwd(), 'storage', 'local_fallback');
|
||||
this.routesRegistered = false;
|
||||
}
|
||||
async activate(ctx) {
|
||||
this.ctx = ctx;
|
||||
const cfg = ctx.config.get('UnifiedStorageProvider') || {};
|
||||
if (!fs_1.default.existsSync(this.localFallbackDir)) {
|
||||
fs_1.default.mkdirSync(this.localFallbackDir, { recursive: true });
|
||||
}
|
||||
const accessKey = cfg.wasabiAccessKey || process.env.WASABI_ACCESS_KEY;
|
||||
const secretKey = cfg.wasabiSecretKey || process.env.WASABI_SECRET_KEY;
|
||||
this.region = (cfg.wasabiRegion || process.env.WASABI_REGION || 'us-east-1');
|
||||
this.bucket = (cfg.wasabiBucket || process.env.WASABI_BUCKET || '');
|
||||
const rawEndpoint = (cfg.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com');
|
||||
const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`;
|
||||
ctx.logger.info({ cfgKeys: Object.keys(cfg), hasKey: !!accessKey, hasBucket: !!this.bucket }, '[UnifiedStorage] activate debug');
|
||||
if (!accessKey || !secretKey || !this.bucket) {
|
||||
ctx.logger.warn('UnifiedStorageProvider: credenciais ou bucket não configurados — configure via admin');
|
||||
}
|
||||
else {
|
||||
this.s3 = new client_s3_1.S3Client({
|
||||
region: this.region,
|
||||
endpoint,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
storageProvider.register(this);
|
||||
ctx.logger.info(`UnifiedStorageProvider ativo — bucket: ${this.bucket} endpoint: ${endpoint}`);
|
||||
this.startSyncWorker();
|
||||
}
|
||||
if (!this.routesRegistered) {
|
||||
// ── Proxy: serve arquivos do Wasabi (ou fallback local) ────────────────
|
||||
ctx.app.get('/api/storage/view/*', async (req, res) => {
|
||||
const filePath = req.params[0];
|
||||
if (!filePath)
|
||||
return res.status(400).send('Path missing');
|
||||
try {
|
||||
// 1. Fallback local
|
||||
const localPath = path_1.default.join(this.localFallbackDir, filePath);
|
||||
if (fs_1.default.existsSync(localPath)) {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
||||
res.setHeader('Content-Type', MIME_MAP[ext] || 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||
res.setHeader('X-Storage-Source', 'local_fallback');
|
||||
return res.send(fs_1.default.readFileSync(localPath));
|
||||
}
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return res.status(503).send('Storage não inicializado');
|
||||
}
|
||||
const result = await this.s3.send(new client_s3_1.GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: filePath,
|
||||
}));
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
||||
res.setHeader('Content-Type', result.ContentType || MIME_MAP[ext] || 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||
res.setHeader('X-Storage-Source', 'wasabi');
|
||||
if (result.Body) {
|
||||
const bytes = await result.Body.transformToByteArray();
|
||||
return res.send(Buffer.from(bytes));
|
||||
}
|
||||
return res.status(404).send('Body vazio');
|
||||
}
|
||||
catch {
|
||||
return res.status(404).send('Arquivo não encontrado');
|
||||
}
|
||||
});
|
||||
// ── POST /api/storage/wasabi/list-buckets ──────────────────────────────
|
||||
// Lista buckets usando credenciais do body (sem exigir S3 pré-configurado)
|
||||
ctx.app.post('/api/storage/wasabi/list-buckets', async (req, res) => {
|
||||
const { accessKey, secretKey, region, endpoint } = req.body;
|
||||
if (!accessKey || !secretKey) {
|
||||
return res.status(400).json({ error: 'Access Key e Secret Key são obrigatórios' });
|
||||
}
|
||||
try {
|
||||
const ep = (endpoint || 's3.wasabisys.com').startsWith('http')
|
||||
? endpoint || 's3.wasabisys.com'
|
||||
: `https://${endpoint || 's3.wasabisys.com'}`;
|
||||
const s3t = new client_s3_1.S3Client({
|
||||
region: region || 'us-east-1',
|
||||
endpoint: ep,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const data = await s3t.send(new client_s3_1.ListBucketsCommand({}));
|
||||
const buckets = (data.Buckets ?? []).map(b => ({ name: b.Name, createdAt: b.CreationDate }));
|
||||
return res.json({ buckets });
|
||||
}
|
||||
catch (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ── POST /api/storage/wasabi/create-bucket ─────────────────────────────
|
||||
ctx.app.post('/api/storage/wasabi/create-bucket', async (req, res) => {
|
||||
const { accessKey, secretKey, region, endpoint, name } = req.body;
|
||||
if (!accessKey || !secretKey)
|
||||
return res.status(400).json({ error: 'Credenciais obrigatórias' });
|
||||
if (!name)
|
||||
return res.status(400).json({ error: 'Campo "name" obrigatório' });
|
||||
const safeName = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
const effectiveRegion = region || 'us-east-1';
|
||||
const ep = (endpoint || 's3.wasabisys.com').startsWith('http')
|
||||
? endpoint || 's3.wasabisys.com'
|
||||
: `https://${endpoint || 's3.wasabisys.com'}`;
|
||||
try {
|
||||
const s3t = new client_s3_1.S3Client({
|
||||
region: effectiveRegion,
|
||||
endpoint: ep,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const params = { Bucket: safeName };
|
||||
if (effectiveRegion !== 'us-east-1') {
|
||||
params.CreateBucketConfiguration = { LocationConstraint: effectiveRegion };
|
||||
}
|
||||
await s3t.send(new client_s3_1.CreateBucketCommand(params));
|
||||
ctx.logger.info(`[Wasabi] Bucket criado: ${safeName}`);
|
||||
return res.status(201).json({ ok: true, name: safeName });
|
||||
}
|
||||
catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
this.routesRegistered = true;
|
||||
}
|
||||
setInterval(() => { storageProvider.updateHealth(); }, 30000);
|
||||
}
|
||||
async deactivate(ctx) {
|
||||
this.s3 = null;
|
||||
ctx.logger.info('UnifiedStorageProvider desativado');
|
||||
}
|
||||
async checkHealth() {
|
||||
return (await this.checkDetailedHealth()).healthy;
|
||||
}
|
||||
async checkDetailedHealth() {
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Credenciais Wasabi não configuradas. Acesse Admin > Plugins > UnifiedStorageProvider.',
|
||||
};
|
||||
}
|
||||
try {
|
||||
await this.s3.send(new client_s3_1.HeadBucketCommand({ Bucket: this.bucket }));
|
||||
return { healthy: true, configured: true, reason: 'ok', message: `Wasabi OK — bucket: ${this.bucket}` };
|
||||
}
|
||||
catch (err) {
|
||||
const code = err?.Code || err?.code || err?.name || '';
|
||||
const httpStatus = err?.$metadata?.httpStatusCode ?? 0;
|
||||
if (code === 'InvalidAccessKeyId' || code === 'SignatureDoesNotMatch') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'auth_error',
|
||||
message: 'Chaves de acesso inválidas. Verifique Access Key e Secret Key no painel do Wasabi.',
|
||||
};
|
||||
}
|
||||
if (httpStatus === 403 || code === 'AccessDenied') {
|
||||
// 403 pode ser pagamento atrasado ou permissão negada
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'billing_error',
|
||||
message: 'Acesso negado ao Wasabi (HTTP 403). Verifique se a conta está ativa e o pagamento em dia.',
|
||||
};
|
||||
}
|
||||
if (httpStatus === 404 || code === 'NoSuchBucket') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'bucket_not_found',
|
||||
message: `Bucket "${this.bucket}" não encontrado. Verifique o nome do bucket no painel Wasabi.`,
|
||||
};
|
||||
}
|
||||
if (code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'NetworkingError') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'network_error',
|
||||
message: 'Sem conectividade com o Wasabi. Verifique a rede e o endpoint configurado.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'unknown',
|
||||
message: `Erro ao verificar Wasabi: ${err?.message ?? code}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
async deletePrefix(prefix) {
|
||||
let deletedCount = 0;
|
||||
// 1. Limpa do Wasabi
|
||||
if (this.s3 && this.bucket) {
|
||||
try {
|
||||
let isTruncated = true;
|
||||
let continuationToken;
|
||||
while (isTruncated) {
|
||||
const listParams = {
|
||||
Bucket: this.bucket,
|
||||
Prefix: prefix,
|
||||
MaxKeys: 1000,
|
||||
};
|
||||
if (continuationToken) {
|
||||
listParams.ContinuationToken = continuationToken;
|
||||
}
|
||||
const listResult = await this.s3.send(new client_s3_1.ListObjectsV2Command(listParams));
|
||||
const objects = listResult.Contents ?? [];
|
||||
if (objects.length > 0) {
|
||||
const deleteParams = {
|
||||
Bucket: this.bucket,
|
||||
Delete: {
|
||||
Objects: objects.map((obj) => ({ Key: obj.Key })),
|
||||
Quiet: true,
|
||||
},
|
||||
};
|
||||
await this.s3.send(new client_s3_1.DeleteObjectsCommand(deleteParams));
|
||||
deletedCount += objects.length;
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] ${objects.length} objetos deletados do Wasabi com prefixo ${prefix}`);
|
||||
}
|
||||
isTruncated = listResult.IsTruncated ?? false;
|
||||
continuationToken = listResult.NextContinuationToken;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao deletar prefixo ${prefix} do Wasabi: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// 2. Limpa do fallback local
|
||||
const localPath = path_1.default.join(this.localFallbackDir, prefix);
|
||||
if (fs_1.default.existsSync(localPath)) {
|
||||
try {
|
||||
const stat = fs_1.default.statSync(localPath);
|
||||
if (stat.isDirectory()) {
|
||||
fs_1.default.rmSync(localPath, { recursive: true, force: true });
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] Pasta local do fallback removida: ${localPath}`);
|
||||
}
|
||||
else {
|
||||
fs_1.default.unlinkSync(localPath);
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] Arquivo local do fallback removido: ${localPath}`);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao remover fallback local para prefixo ${prefix}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return { deleted: deletedCount };
|
||||
}
|
||||
async upload(payload) {
|
||||
const { category, file } = payload;
|
||||
if (!VALID_CATEGORIES.includes(category))
|
||||
throw new Error(`Categoria inválida: ${category}`);
|
||||
let buffer = file.buffer;
|
||||
let originalName = file.originalname;
|
||||
let mimetype = file.mimetype;
|
||||
// Otimiza imagens grandes (>150 KB) para WebP
|
||||
if (mimetype.startsWith('image/') && !mimetype.includes('webp') && buffer.length > 150 * 1024) {
|
||||
try {
|
||||
buffer = await (0, sharp_1.default)(buffer).resize(1200, 1200, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 75 }).toBuffer();
|
||||
mimetype = 'image/webp';
|
||||
originalName = originalName.replace(/\.[^.]+$/, '.webp');
|
||||
this.ctx.logger.debug(`[Optimizer] ${originalName} convertida para webp`);
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.warn(`[Optimizer] falha: ${err.message}`);
|
||||
}
|
||||
}
|
||||
const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const fileName = `${Date.now()}-${safeBase}`;
|
||||
// Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file}
|
||||
const relativePath = payload.customPath
|
||||
?? ((category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
|
||||
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
|
||||
: `${category}/${fileName}`);
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
try {
|
||||
await new lib_storage_1.Upload({
|
||||
client: this.s3,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: relativePath,
|
||||
Body: buffer,
|
||||
ContentType: mimetype,
|
||||
},
|
||||
}).done();
|
||||
this.ctx.logger.debug(`[Wasabi] upload ok: ${relativePath}`);
|
||||
return { provider: 'wasabi', path: relativePath };
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.error(`[Wasabi] upload falhou (${err.message}) — salvando local`);
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
}
|
||||
async getBuffer(relativePath) {
|
||||
// 1. Fallback local
|
||||
const localPath = path_1.default.join(this.localFallbackDir, relativePath);
|
||||
if (fs_1.default.existsSync(localPath)) {
|
||||
return fs_1.default.readFileSync(localPath);
|
||||
}
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket)
|
||||
return null;
|
||||
try {
|
||||
const result = await this.s3.send(new client_s3_1.GetObjectCommand({ Bucket: this.bucket, Key: relativePath }));
|
||||
if (!result.Body)
|
||||
return null;
|
||||
const bytes = await result.Body.transformToByteArray();
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/** Salva buffer no diretório de fallback local, criando subpastas se necessário */
|
||||
saveToLocalFallback(relativePath, buffer, originalName, _category) {
|
||||
const dest = path_1.default.join(this.localFallbackDir, relativePath);
|
||||
fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
|
||||
fs_1.default.writeFileSync(dest, buffer);
|
||||
this.ctx.logger.debug(`[Fallback] salvo local: ${dest}`);
|
||||
return { provider: 'local_fallback', path: relativePath };
|
||||
}
|
||||
/** Sincroniza arquivos do fallback local para o Wasabi a cada 2 minutos */
|
||||
startSyncWorker() {
|
||||
setInterval(async () => {
|
||||
if (!this.s3 || !this.bucket)
|
||||
return;
|
||||
await this.syncDir(this.localFallbackDir);
|
||||
}, 120000);
|
||||
}
|
||||
async syncDir(dir) {
|
||||
if (!fs_1.default.existsSync(dir))
|
||||
return;
|
||||
for (const entry of fs_1.default.readdirSync(dir)) {
|
||||
const fullPath = path_1.default.join(dir, entry);
|
||||
const stat = fs_1.default.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
await this.syncDir(fullPath);
|
||||
continue;
|
||||
}
|
||||
const relativePath = path_1.default.relative(this.localFallbackDir, fullPath).replace(/\\/g, '/');
|
||||
try {
|
||||
await new lib_storage_1.Upload({
|
||||
client: this.s3,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: relativePath,
|
||||
Body: fs_1.default.readFileSync(fullPath),
|
||||
},
|
||||
}).done();
|
||||
fs_1.default.unlinkSync(fullPath);
|
||||
this.ctx.logger.info(`[SyncWorker] enviado e removido: ${relativePath}`);
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.warn(`[SyncWorker] falha em ${relativePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const instance = new UnifiedStorageProviderPlugin();
|
||||
exports.default = instance;
|
||||
//# sourceMappingURL=index.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,426 @@
|
||||
import {
|
||||
S3Client,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
ListObjectsV2Command,
|
||||
ListBucketsCommand,
|
||||
CreateBucketCommand,
|
||||
DeleteObjectsCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
|
||||
import type { StorageUploadPayload, StorageProviderInterface } from '../../backend/src/core/StorageProvider';
|
||||
|
||||
let storageProvider: any;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
storageProvider = require('../../dist/core/StorageProvider').storageProvider;
|
||||
} else {
|
||||
storageProvider = require('../../backend/src/core/StorageProvider').storageProvider;
|
||||
}
|
||||
import sharp from 'sharp';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const VALID_CATEGORIES = ['documents', 'exports', 'posts', 'cards', 'partners', 'whatsapp', 'media'];
|
||||
|
||||
// Mapa de extensão → mimetype para o proxy
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
||||
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
||||
pdf: 'application/pdf', mp4: 'video/mp4', ogg: 'audio/ogg',
|
||||
mp3: 'audio/mpeg', doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
|
||||
class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInterface {
|
||||
manifest: any;
|
||||
private s3: S3Client | null = null;
|
||||
private bucket: string = '';
|
||||
private region: string = 'us-east-1';
|
||||
private localFallbackDir: string = path.resolve(process.cwd(), 'storage', 'local_fallback');
|
||||
private ctx!: PluginContext;
|
||||
private routesRegistered = false;
|
||||
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
this.ctx = ctx;
|
||||
const cfg = ctx.config.get('UnifiedStorageProvider') || {};
|
||||
|
||||
if (!fs.existsSync(this.localFallbackDir)) {
|
||||
fs.mkdirSync(this.localFallbackDir, { recursive: true });
|
||||
}
|
||||
|
||||
const accessKey = cfg.wasabiAccessKey || process.env.WASABI_ACCESS_KEY;
|
||||
const secretKey = cfg.wasabiSecretKey || process.env.WASABI_SECRET_KEY;
|
||||
this.region = (cfg.wasabiRegion || process.env.WASABI_REGION || 'us-east-1') as string;
|
||||
this.bucket = (cfg.wasabiBucket || process.env.WASABI_BUCKET || '') as string;
|
||||
|
||||
const rawEndpoint = (cfg.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com') as string;
|
||||
const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`;
|
||||
|
||||
ctx.logger.info({ cfgKeys: Object.keys(cfg), hasKey: !!accessKey, hasBucket: !!this.bucket }, '[UnifiedStorage] activate debug');
|
||||
|
||||
if (!accessKey || !secretKey || !this.bucket) {
|
||||
ctx.logger.warn('UnifiedStorageProvider: credenciais ou bucket não configurados — configure via admin');
|
||||
} else {
|
||||
this.s3 = new S3Client({
|
||||
region: this.region,
|
||||
endpoint,
|
||||
credentials: { accessKeyId: accessKey as string, secretAccessKey: secretKey as string },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
storageProvider.register(this);
|
||||
ctx.logger.info(`UnifiedStorageProvider ativo — bucket: ${this.bucket} endpoint: ${endpoint}`);
|
||||
|
||||
this.startSyncWorker();
|
||||
}
|
||||
|
||||
if (!this.routesRegistered) {
|
||||
// ── Proxy: serve arquivos do Wasabi (ou fallback local) ────────────────
|
||||
ctx.app.get('/api/storage/view/*', async (req, res) => {
|
||||
const filePath = req.params[0];
|
||||
if (!filePath) return res.status(400).send('Path missing');
|
||||
|
||||
try {
|
||||
// 1. Fallback local
|
||||
const localPath = path.join(this.localFallbackDir, filePath);
|
||||
if (fs.existsSync(localPath)) {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
||||
res.setHeader('Content-Type', MIME_MAP[ext] || 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||
res.setHeader('X-Storage-Source', 'local_fallback');
|
||||
return res.send(fs.readFileSync(localPath));
|
||||
}
|
||||
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return res.status(503).send('Storage não inicializado');
|
||||
}
|
||||
|
||||
const result = await this.s3.send(new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: filePath,
|
||||
}));
|
||||
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
||||
res.setHeader('Content-Type', result.ContentType || MIME_MAP[ext] || 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||
res.setHeader('X-Storage-Source', 'wasabi');
|
||||
|
||||
if (result.Body) {
|
||||
const bytes = await (result.Body as any).transformToByteArray();
|
||||
return res.send(Buffer.from(bytes));
|
||||
}
|
||||
return res.status(404).send('Body vazio');
|
||||
} catch {
|
||||
return res.status(404).send('Arquivo não encontrado');
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/storage/wasabi/list-buckets ──────────────────────────────
|
||||
// Lista buckets usando credenciais do body (sem exigir S3 pré-configurado)
|
||||
ctx.app.post('/api/storage/wasabi/list-buckets', async (req, res) => {
|
||||
const { accessKey, secretKey, region, endpoint } = req.body as Record<string, string>;
|
||||
if (!accessKey || !secretKey) {
|
||||
return res.status(400).json({ error: 'Access Key e Secret Key são obrigatórios' });
|
||||
}
|
||||
try {
|
||||
const ep = (endpoint || 's3.wasabisys.com').startsWith('http')
|
||||
? endpoint || 's3.wasabisys.com'
|
||||
: `https://${endpoint || 's3.wasabisys.com'}`;
|
||||
const s3t = new S3Client({
|
||||
region: region || 'us-east-1',
|
||||
endpoint: ep,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const data = await s3t.send(new ListBucketsCommand({}));
|
||||
const buckets = (data.Buckets ?? []).map(b => ({ name: b.Name, createdAt: b.CreationDate }));
|
||||
return res.json({ buckets });
|
||||
} catch (err: any) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/storage/wasabi/create-bucket ─────────────────────────────
|
||||
ctx.app.post('/api/storage/wasabi/create-bucket', async (req, res) => {
|
||||
const { accessKey, secretKey, region, endpoint, name } = req.body as Record<string, string>;
|
||||
if (!accessKey || !secretKey) return res.status(400).json({ error: 'Credenciais obrigatórias' });
|
||||
if (!name) return res.status(400).json({ error: 'Campo "name" obrigatório' });
|
||||
|
||||
const safeName = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
const effectiveRegion = region || 'us-east-1';
|
||||
const ep = (endpoint || 's3.wasabisys.com').startsWith('http')
|
||||
? endpoint || 's3.wasabisys.com'
|
||||
: `https://${endpoint || 's3.wasabisys.com'}`;
|
||||
|
||||
try {
|
||||
const s3t = new S3Client({
|
||||
region: effectiveRegion,
|
||||
endpoint: ep,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const params: any = { Bucket: safeName };
|
||||
if (effectiveRegion !== 'us-east-1') {
|
||||
params.CreateBucketConfiguration = { LocationConstraint: effectiveRegion };
|
||||
}
|
||||
await s3t.send(new CreateBucketCommand(params));
|
||||
ctx.logger.info(`[Wasabi] Bucket criado: ${safeName}`);
|
||||
return res.status(201).json({ ok: true, name: safeName });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
this.routesRegistered = true;
|
||||
}
|
||||
|
||||
setInterval(() => { storageProvider.updateHealth(); }, 30000);
|
||||
}
|
||||
|
||||
async deactivate(ctx: PluginContext): Promise<void> {
|
||||
this.s3 = null;
|
||||
ctx.logger.info('UnifiedStorageProvider desativado');
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<boolean> {
|
||||
return (await this.checkDetailedHealth()).healthy;
|
||||
}
|
||||
|
||||
async checkDetailedHealth(): Promise<import('../../backend/src/core/StorageProvider').StorageHealthStatus> {
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Credenciais Wasabi não configuradas. Acesse Admin > Plugins > UnifiedStorageProvider.',
|
||||
};
|
||||
}
|
||||
try {
|
||||
await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||
return { healthy: true, configured: true, reason: 'ok', message: `Wasabi OK — bucket: ${this.bucket}` };
|
||||
} catch (err: any) {
|
||||
const code: string = err?.Code || err?.code || err?.name || '';
|
||||
const httpStatus: number = err?.$metadata?.httpStatusCode ?? 0;
|
||||
|
||||
if (code === 'InvalidAccessKeyId' || code === 'SignatureDoesNotMatch') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'auth_error',
|
||||
message: 'Chaves de acesso inválidas. Verifique Access Key e Secret Key no painel do Wasabi.',
|
||||
};
|
||||
}
|
||||
if (httpStatus === 403 || code === 'AccessDenied') {
|
||||
// 403 pode ser pagamento atrasado ou permissão negada
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'billing_error',
|
||||
message: 'Acesso negado ao Wasabi (HTTP 403). Verifique se a conta está ativa e o pagamento em dia.',
|
||||
};
|
||||
}
|
||||
if (httpStatus === 404 || code === 'NoSuchBucket') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'bucket_not_found',
|
||||
message: `Bucket "${this.bucket}" não encontrado. Verifique o nome do bucket no painel Wasabi.`,
|
||||
};
|
||||
}
|
||||
if (code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'NetworkingError') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'network_error',
|
||||
message: 'Sem conectividade com o Wasabi. Verifique a rede e o endpoint configurado.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'unknown',
|
||||
message: `Erro ao verificar Wasabi: ${err?.message ?? code}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async deletePrefix(prefix: string): Promise<{ deleted: number }> {
|
||||
let deletedCount = 0;
|
||||
|
||||
// 1. Limpa do Wasabi
|
||||
if (this.s3 && this.bucket) {
|
||||
try {
|
||||
let isTruncated = true;
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
while (isTruncated) {
|
||||
const listParams: any = {
|
||||
Bucket: this.bucket,
|
||||
Prefix: prefix,
|
||||
MaxKeys: 1000,
|
||||
};
|
||||
if (continuationToken) {
|
||||
listParams.ContinuationToken = continuationToken;
|
||||
}
|
||||
|
||||
const listResult = await this.s3.send(new ListObjectsV2Command(listParams));
|
||||
const objects = listResult.Contents ?? [];
|
||||
|
||||
if (objects.length > 0) {
|
||||
const deleteParams = {
|
||||
Bucket: this.bucket,
|
||||
Delete: {
|
||||
Objects: objects.map((obj) => ({ Key: obj.Key! })),
|
||||
Quiet: true,
|
||||
},
|
||||
};
|
||||
await this.s3.send(new DeleteObjectsCommand(deleteParams));
|
||||
deletedCount += objects.length;
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] ${objects.length} objetos deletados do Wasabi com prefixo ${prefix}`);
|
||||
}
|
||||
|
||||
isTruncated = listResult.IsTruncated ?? false;
|
||||
continuationToken = listResult.NextContinuationToken;
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao deletar prefixo ${prefix} do Wasabi: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Limpa do fallback local
|
||||
const localPath = path.join(this.localFallbackDir, prefix);
|
||||
if (fs.existsSync(localPath)) {
|
||||
try {
|
||||
const stat = fs.statSync(localPath);
|
||||
if (stat.isDirectory()) {
|
||||
fs.rmSync(localPath, { recursive: true, force: true });
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] Pasta local do fallback removida: ${localPath}`);
|
||||
} else {
|
||||
fs.unlinkSync(localPath);
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] Arquivo local do fallback removido: ${localPath}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao remover fallback local para prefixo ${prefix}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { deleted: deletedCount };
|
||||
}
|
||||
|
||||
async upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> {
|
||||
const { category, file } = payload;
|
||||
if (!VALID_CATEGORIES.includes(category)) throw new Error(`Categoria inválida: ${category}`);
|
||||
|
||||
let buffer = file.buffer;
|
||||
let originalName = file.originalname;
|
||||
let mimetype = file.mimetype;
|
||||
|
||||
// Otimiza imagens grandes (>150 KB) para WebP
|
||||
if (mimetype.startsWith('image/') && !mimetype.includes('webp') && buffer.length > 150 * 1024) {
|
||||
try {
|
||||
buffer = await sharp(buffer).resize(1200, 1200, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 75 }).toBuffer();
|
||||
mimetype = 'image/webp';
|
||||
originalName = originalName.replace(/\.[^.]+$/, '.webp');
|
||||
this.ctx.logger.debug(`[Optimizer] ${originalName} convertida para webp`);
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.warn(`[Optimizer] falha: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const fileName = `${Date.now()}-${safeBase}`;
|
||||
|
||||
// Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file}
|
||||
const relativePath = payload.customPath
|
||||
?? ((category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
|
||||
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
|
||||
: `${category}/${fileName}`);
|
||||
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
|
||||
try {
|
||||
await new Upload({
|
||||
client: this.s3,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: relativePath,
|
||||
Body: buffer,
|
||||
ContentType: mimetype,
|
||||
},
|
||||
}).done();
|
||||
|
||||
this.ctx.logger.debug(`[Wasabi] upload ok: ${relativePath}`);
|
||||
return { provider: 'wasabi', path: relativePath };
|
||||
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.error(`[Wasabi] upload falhou (${err.message}) — salvando local`);
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
}
|
||||
|
||||
async getBuffer(relativePath: string): Promise<Buffer | null> {
|
||||
// 1. Fallback local
|
||||
const localPath = path.join(this.localFallbackDir, relativePath);
|
||||
if (fs.existsSync(localPath)) {
|
||||
return fs.readFileSync(localPath);
|
||||
}
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket) return null;
|
||||
try {
|
||||
const result = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: relativePath }));
|
||||
if (!result.Body) return null;
|
||||
const bytes = await (result.Body as any).transformToByteArray();
|
||||
return Buffer.from(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Salva buffer no diretório de fallback local, criando subpastas se necessário */
|
||||
private saveToLocalFallback(relativePath: string, buffer: Buffer, originalName: string, _category: string): { provider: string; path: string } {
|
||||
const dest = path.join(this.localFallbackDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, buffer);
|
||||
this.ctx.logger.debug(`[Fallback] salvo local: ${dest}`);
|
||||
return { provider: 'local_fallback', path: relativePath };
|
||||
}
|
||||
|
||||
/** Sincroniza arquivos do fallback local para o Wasabi a cada 2 minutos */
|
||||
private startSyncWorker(): void {
|
||||
setInterval(async () => {
|
||||
if (!this.s3 || !this.bucket) return;
|
||||
await this.syncDir(this.localFallbackDir);
|
||||
}, 120_000);
|
||||
}
|
||||
|
||||
private async syncDir(dir: string): Promise<void> {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const fullPath = path.join(dir, entry);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
await this.syncDir(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(this.localFallbackDir, fullPath).replace(/\\/g, '/');
|
||||
|
||||
try {
|
||||
await new Upload({
|
||||
client: this.s3!,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: relativePath,
|
||||
Body: fs.readFileSync(fullPath),
|
||||
},
|
||||
}).done();
|
||||
|
||||
fs.unlinkSync(fullPath);
|
||||
this.ctx.logger.info(`[SyncWorker] enviado e removido: ${relativePath}`);
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.warn(`[SyncWorker] falha em ${relativePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new UnifiedStorageProviderPlugin();
|
||||
export default instance;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "UnifiedStorageProvider",
|
||||
"displayName": "Wasabi Storage",
|
||||
"version": "1.1.0",
|
||||
"description": "Armazenamento de mídias do WhatsApp via Wasabi (S3 compatível)",
|
||||
"author": "NewWhats",
|
||||
"category": "core",
|
||||
"enabled": true,
|
||||
"canDisable": false,
|
||||
"dependencies": [],
|
||||
"backend": {
|
||||
"routePrefix": "/api/storage",
|
||||
"hasMigrations": false
|
||||
},
|
||||
"configSchema": [
|
||||
{ "key": "wasabiAccessKey", "label": "Access Key", "type": "password", "required": true, "group": "Credenciais Wasabi", "tooltip": "Chave de acesso gerada em Account → Access Keys no painel Wasabi." },
|
||||
{ "key": "wasabiSecretKey", "label": "Secret Key", "type": "password", "required": true, "group": "Credenciais Wasabi", "tooltip": "Chave secreta correspondente à Access Key — exibida só uma vez no momento da criação." },
|
||||
{ "key": "wasabiBucket", "label": "Bucket Ativo", "type": "text", "required": true, "group": "Bucket", "tooltip": "Nome do bucket onde as mídias do WhatsApp serão armazenadas." },
|
||||
{ "key": "wasabiRegion", "label": "Region", "type": "text", "required": false, "group": "Avançado", "tooltip": "Região do bucket. Padrão: us-east-1. Exemplos: us-east-2, eu-central-1." },
|
||||
{ "key": "wasabiEndpoint", "label": "Endpoint", "type": "text", "required": false, "group": "Avançado", "tooltip": "Endpoint S3. Padrão: s3.wasabisys.com. Só altere se usar região diferente." }
|
||||
]
|
||||
}
|
||||
+743
-16
@@ -98,6 +98,7 @@ let clientsList = [];
|
||||
let selectedClient = '';
|
||||
let showDisabled = false;
|
||||
let currentTransformations = [];
|
||||
let fineTuneAngle = 0;
|
||||
|
||||
// ================================================================
|
||||
// DOM REFS
|
||||
@@ -149,6 +150,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadClientsList();
|
||||
loadPatients();
|
||||
setupEventListeners();
|
||||
initDragAndDrop();
|
||||
setInterval(loadClientsList, 5000);
|
||||
} catch (error) {
|
||||
_log.error('INIT', 'Erro fatal na inicialização:', error.message, error);
|
||||
@@ -229,8 +231,12 @@ function updateClientsDropdown() {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.name;
|
||||
let lbl = c.name;
|
||||
if (c.name === 'Upload Web') {
|
||||
lbl = 'Upload Web 💻 (Imagens do Painel)';
|
||||
} else {
|
||||
if (c.status === 'identified') lbl += ' ✅';
|
||||
else if (c.status === 'historic') lbl += ' 📚';
|
||||
}
|
||||
opt.textContent = lbl;
|
||||
clientFilter.appendChild(opt);
|
||||
});
|
||||
@@ -341,6 +347,7 @@ function renderPatientCards() {
|
||||
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">
|
||||
@@ -374,6 +381,38 @@ function renderPatientCards() {
|
||||
});
|
||||
}
|
||||
|
||||
// Exclui todas as imagens de um determinado paciente (S3 e Local)
|
||||
async function deletePatientImages(patientName) {
|
||||
if (!patientName) return;
|
||||
|
||||
// TODO(security): Usando confirm() para manter coerência com o restante das confirmações do painel do projeto
|
||||
const ok = confirm(`⚠️ ATENÇÃO: Tem certeza que deseja excluir permanentemente todas as imagens do paciente "${patientName}"?\n\nEsta ação apagará os arquivos locais, do Wasabi S3 e do banco de dados, e NÃO poderá ser desfeita.`);
|
||||
if (!ok) return;
|
||||
|
||||
showToast('Excluindo imagens do paciente...', 'info');
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth(`/api/images/by-patient?name=${encodeURIComponent(patientName)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || 'Erro ao excluir imagens do paciente.');
|
||||
}
|
||||
|
||||
showToast('✅ Todas as imagens do paciente foram excluídas!', 'success');
|
||||
|
||||
// Atualizar listagens
|
||||
await loadPatients();
|
||||
await loadClientsList();
|
||||
|
||||
} catch (e) {
|
||||
console.error('Erro na exclusão do paciente:', e);
|
||||
showToast(`Falha ao excluir imagens: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// VIEW: PATIENT IMAGES
|
||||
// ================================================================
|
||||
@@ -435,9 +474,11 @@ async function loadPatientImages(patientName) {
|
||||
}
|
||||
|
||||
function renderPatientImages() {
|
||||
imagesGrid.innerHTML = patientImages.map(image => `
|
||||
imagesGrid.innerHTML = patientImages.map(image => {
|
||||
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('/uploads/${image.filename}'); background-size: cover; background-position: center;"></div>
|
||||
<div class="image-preview" style="background-image: url('${thumbUrl}'); background-size: cover; background-position: center;"></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>
|
||||
@@ -451,8 +492,8 @@ function renderPatientImages() {
|
||||
</div>
|
||||
</div>
|
||||
${!image.enabled ? '<div class="image-badge">Desabilitada</div>' : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
@@ -506,19 +547,30 @@ async function showTransformModal(imageId) {
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth(`/api/images/${imageId}/transformations`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) { localStorage.removeItem('auth_token'); window.location.href = '/login'; return; }
|
||||
throw new Error('Erro ao gerar transformações');
|
||||
const image = patientImages.find(img => img.id === imageId);
|
||||
if (!image) {
|
||||
throw new Error('Imagem não encontrada localmente');
|
||||
}
|
||||
|
||||
const transformations = await res.json();
|
||||
// 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="${t.base64}" alt="${escapeHtml(t.name)}" loading="lazy">
|
||||
<div class="transform-image-wrapper">
|
||||
<img src="${imageUrl}" alt="${escapeHtml(t.name)}" style="${t.css}" loading="lazy">
|
||||
</div>
|
||||
<div class="transform-name">${escapeHtml(t.name)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
@@ -526,7 +578,6 @@ async function showTransformModal(imageId) {
|
||||
// ---------------------------------------------------------------
|
||||
// Detectar orientação da imagem original → aplicar layout correto
|
||||
// ---------------------------------------------------------------
|
||||
if (transformations.length > 0 && transformations[0].base64) {
|
||||
const probe = new Image();
|
||||
probe.onload = () => {
|
||||
const orientation = probe.naturalWidth >= probe.naturalHeight ? 'landscape' : 'portrait';
|
||||
@@ -537,12 +588,11 @@ async function showTransformModal(imageId) {
|
||||
probe.onerror = () => {
|
||||
_log.warn('TRANSFORM', 'Não foi possível detectar orientação — usando fallback');
|
||||
};
|
||||
probe.src = transformations[0].base64;
|
||||
}
|
||||
probe.src = imageUrl;
|
||||
|
||||
} catch (e) {
|
||||
_log.error('TRANSFORM', '❌ Exceção:', e.message, e);
|
||||
showToast('Erro ao gerar variações', 'error');
|
||||
showToast('Erro ao carregar variações', 'error');
|
||||
transformModal.classList.remove('active');
|
||||
}
|
||||
}
|
||||
@@ -571,6 +621,17 @@ function selectTransform(index) {
|
||||
|
||||
el.classList.add('selected');
|
||||
|
||||
// Reset fine tune angle when selecting a new transform
|
||||
fineTuneAngle = 0;
|
||||
updateFineTuneDisplay();
|
||||
|
||||
// Mover o contêiner de ajuste fino para dentro do card selecionado
|
||||
const ftContainer = document.getElementById('fineTuneContainer');
|
||||
if (ftContainer) {
|
||||
el.appendChild(ftContainer);
|
||||
ftContainer.style.display = 'flex';
|
||||
}
|
||||
|
||||
// Show action area
|
||||
document.getElementById('transformActionArea').style.display = 'block';
|
||||
|
||||
@@ -585,11 +646,38 @@ function clearTransformSelection() {
|
||||
const container = document.getElementById('transformOptions');
|
||||
container.classList.remove('selection-mode');
|
||||
|
||||
// Mover o contêiner de ajuste fino de volta para o Wrapper e esconder
|
||||
const ftContainer = document.getElementById('fineTuneContainer');
|
||||
if (ftContainer) {
|
||||
const viewWrapper = document.getElementById('transformViewWrapper');
|
||||
if (viewWrapper) {
|
||||
viewWrapper.appendChild(ftContainer);
|
||||
} else {
|
||||
document.body.appendChild(ftContainer);
|
||||
}
|
||||
ftContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// 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';
|
||||
document.getElementById('newImageRemark').value = '';
|
||||
}
|
||||
@@ -612,7 +700,7 @@ async function saveSelectedTransform() {
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
rotation: transform.rotation,
|
||||
rotation: transform.rotation + fineTuneAngle,
|
||||
flipH: transform.flipH,
|
||||
flipV: transform.flipV,
|
||||
remark: remark
|
||||
@@ -621,7 +709,7 @@ async function saveSelectedTransform() {
|
||||
const res = await fetchWithAuth(`/api/images/${selectedImageId}/transform`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rotation: transform.rotation, flipH: transform.flipH, flipV: transform.flipV })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error('Erro ao salvar');
|
||||
|
||||
@@ -634,6 +722,49 @@ 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}°)`;
|
||||
}
|
||||
}
|
||||
|
||||
function resetFineTune() {
|
||||
adjustFineTune(-fineTuneAngle);
|
||||
}
|
||||
|
||||
function updateFineTuneDisplay() {
|
||||
const displayEl = document.getElementById('fineTuneValDisplay');
|
||||
if (displayEl) {
|
||||
displayEl.textContent = `${fineTuneAngle > 0 ? '+' : ''}${fineTuneAngle}°`;
|
||||
}
|
||||
|
||||
const resetEl = document.getElementById('btnResetFineTune');
|
||||
if (resetEl) {
|
||||
resetEl.style.display = fineTuneAngle !== 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleImageEnabled(imageId) {
|
||||
try {
|
||||
const res = await fetchWithAuth(`/api/images/${imageId}/toggle-enabled`, { method: 'PUT' });
|
||||
@@ -1044,4 +1175,600 @@ async function updateCredentials(event) {
|
||||
window.openSettingsModal = openSettingsModal;
|
||||
window.closeSettingsModal = closeSettingsModal;
|
||||
window.updateCredentials = updateCredentials;
|
||||
|
||||
// ================================================================
|
||||
// CONFIGURAÇÕES DE PLUGINS (Wasabi Storage)
|
||||
// ================================================================
|
||||
|
||||
function openPluginsModal() {
|
||||
const modal = document.getElementById('pluginsModal');
|
||||
if (modal) modal.style.display = 'block';
|
||||
|
||||
// Limpar campos
|
||||
document.getElementById('wasabiAccessKey').value = '';
|
||||
document.getElementById('wasabiSecretKey').value = '';
|
||||
document.getElementById('wasabiBucket').value = '';
|
||||
document.getElementById('wasabiRegion').value = '';
|
||||
document.getElementById('wasabiEndpoint').value = '';
|
||||
|
||||
const alertBox = document.getElementById('pluginsStatusAlert');
|
||||
alertBox.style.display = 'none';
|
||||
|
||||
// Carregar configurações atuais
|
||||
loadPluginsConfig();
|
||||
}
|
||||
|
||||
function closePluginsModal() {
|
||||
const modal = document.getElementById('pluginsModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
async function loadPluginsConfig() {
|
||||
try {
|
||||
const res = await fetchWithAuth('/api/system/storage-config');
|
||||
if (!res.ok) throw new Error('Falha ao carregar configurações');
|
||||
|
||||
const config = await res.json();
|
||||
|
||||
document.getElementById('wasabiAccessKey').value = config.wasabiAccessKey || '';
|
||||
document.getElementById('wasabiSecretKey').value = config.wasabiSecretKey || '';
|
||||
document.getElementById('wasabiBucket').value = config.wasabiBucket || '';
|
||||
document.getElementById('wasabiRegion').value = config.wasabiRegion || '';
|
||||
document.getElementById('wasabiEndpoint').value = config.wasabiEndpoint || '';
|
||||
|
||||
const alertBox = document.getElementById('pluginsStatusAlert');
|
||||
if (config.enabled) {
|
||||
alertBox.className = 'alert-success';
|
||||
alertBox.style.background = '#eafaf1';
|
||||
alertBox.style.color = '#27ae60';
|
||||
alertBox.style.border = '1px solid #2ecc7133';
|
||||
alertBox.innerHTML = '🟢 <b>Status:</b> Wasabi Storage está ATIVO e conectado.';
|
||||
} else {
|
||||
alertBox.className = 'alert-warning';
|
||||
alertBox.style.background = '#fffbeb';
|
||||
alertBox.style.color = '#d97706';
|
||||
alertBox.style.border = '1px solid #f59e0b33';
|
||||
alertBox.innerHTML = '🟡 <b>Status:</b> Wasabi Storage está DESATIVADO (utilizando armazenamento local).';
|
||||
}
|
||||
alertBox.style.display = 'block';
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Erro ao carregar configurações do Wasabi.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePluginsConfig(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const wasabiAccessKey = document.getElementById('wasabiAccessKey').value.trim();
|
||||
const wasabiSecretKey = document.getElementById('wasabiSecretKey').value;
|
||||
const wasabiBucket = document.getElementById('wasabiBucket').value.trim();
|
||||
const wasabiRegion = document.getElementById('wasabiRegion').value.trim();
|
||||
const wasabiEndpoint = document.getElementById('wasabiEndpoint').value.trim();
|
||||
|
||||
const btn = document.getElementById('btnSavePlugins');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner" style="width:14px;height:14px;display:inline-block;"></span> Salvando...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth('/api/system/storage-config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
wasabiAccessKey,
|
||||
wasabiSecretKey,
|
||||
wasabiBucket,
|
||||
wasabiRegion,
|
||||
wasabiEndpoint
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || 'Erro ao salvar configurações.');
|
||||
}
|
||||
|
||||
showToast('Configurações do Wasabi salvas com sucesso!', 'success');
|
||||
|
||||
// Recarregar o status
|
||||
await loadPluginsConfig();
|
||||
|
||||
// Fechar após 1 segundo
|
||||
setTimeout(closePluginsModal, 1200);
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerSync() {
|
||||
showToast('🔄 Enviando sinal de sincronização para dispositivos...', 'info');
|
||||
try {
|
||||
const res = await fetchWithAuth('/api/system/trigger-sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || 'Erro ao acionar sincronização.');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.devicesTriggered > 0) {
|
||||
showToast(`✅ Sinal enviado para ${data.devicesTriggered} dispositivo(s) conectado(s)!`, 'success');
|
||||
} else {
|
||||
showToast('⚠️ Nenhum dispositivo Windows conectado no momento.', 'warning');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Global scope
|
||||
window.openPluginsModal = openPluginsModal;
|
||||
window.closePluginsModal = closePluginsModal;
|
||||
window.updatePluginsConfig = updatePluginsConfig;
|
||||
window.triggerSync = triggerSync;
|
||||
|
||||
// ================================================================
|
||||
// ÁREA DE SINCRONIZAÇÃO E UPLOAD MANUAL
|
||||
// ================================================================
|
||||
|
||||
let selectedUploadFiles = [];
|
||||
|
||||
function openSyncModal() {
|
||||
const modal = document.getElementById('syncModal');
|
||||
if (!modal) return;
|
||||
|
||||
modal.classList.add('active');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Resetar abas
|
||||
switchSyncTab('devices');
|
||||
|
||||
// Limpar formulário de upload
|
||||
resetManualUploadForm();
|
||||
|
||||
// Carregar dados
|
||||
loadSyncDevices();
|
||||
refreshUploadPatients();
|
||||
|
||||
// Escuta de clique fora para fechar modal
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) closeSyncModal();
|
||||
};
|
||||
}
|
||||
|
||||
function closeSyncModal() {
|
||||
const modal = document.getElementById('syncModal');
|
||||
if (!modal) return;
|
||||
|
||||
modal.classList.remove('active');
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function switchSyncTab(tabName) {
|
||||
const btnDevices = document.getElementById('tabBtnDevices');
|
||||
const btnUpload = document.getElementById('tabBtnUpload');
|
||||
const tabDevices = document.getElementById('tab-devices');
|
||||
const tabUpload = document.getElementById('tab-upload');
|
||||
|
||||
if (!btnDevices || !btnUpload || !tabDevices || !tabUpload) return;
|
||||
|
||||
if (tabName === 'devices') {
|
||||
btnDevices.classList.add('active');
|
||||
btnUpload.classList.remove('active');
|
||||
tabDevices.classList.add('active');
|
||||
tabUpload.classList.remove('active');
|
||||
loadSyncDevices();
|
||||
} else {
|
||||
btnDevices.classList.remove('active');
|
||||
btnUpload.classList.add('active');
|
||||
tabDevices.classList.remove('active');
|
||||
tabUpload.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSyncDevices() {
|
||||
const loader = document.getElementById('syncDevicesLoading');
|
||||
const empty = document.getElementById('syncDevicesEmpty');
|
||||
const list = document.getElementById('syncDeviceList');
|
||||
|
||||
if (!loader || !empty || !list) return;
|
||||
|
||||
loader.style.display = 'block';
|
||||
empty.style.display = 'none';
|
||||
list.style.display = 'none';
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth('/api/socket/status');
|
||||
if (!res.ok) throw new Error('Falha ao obter status dos dispositivos.');
|
||||
const data = await res.json();
|
||||
|
||||
list.replaceChildren();
|
||||
|
||||
const windowsClients = (data.identified || []).filter(c => c.type === 'client' || c.type === 'windows');
|
||||
|
||||
if (windowsClients.length === 0) {
|
||||
loader.style.display = 'none';
|
||||
empty.style.display = 'block';
|
||||
list.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
windowsClients.forEach(client => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'sync-device-card';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'device-card-left';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'device-card-title';
|
||||
title.textContent = `💻 ${client.name || 'Dispositivo Sem Nome'}`;
|
||||
|
||||
const subtitle = document.createElement('div');
|
||||
subtitle.className = 'device-card-subtitle';
|
||||
subtitle.textContent = `SO: ${client.system || 'Windows'} | Versão: ${client.version || 'N/A'} | ID: ${client.socketId}`;
|
||||
|
||||
left.appendChild(title);
|
||||
left.appendChild(subtitle);
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'device-card-badge badge-connected';
|
||||
badge.textContent = 'ONLINE';
|
||||
|
||||
card.appendChild(left);
|
||||
card.appendChild(badge);
|
||||
|
||||
list.appendChild(card);
|
||||
});
|
||||
|
||||
loader.style.display = 'none';
|
||||
empty.style.display = 'none';
|
||||
list.style.display = 'flex';
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loader.style.display = 'none';
|
||||
empty.style.display = 'block';
|
||||
const p = empty.querySelector('p');
|
||||
if (p) p.textContent = 'Erro ao carregar dispositivos: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerSyncFromModal() {
|
||||
const btn = document.getElementById('btnTriggerSyncModal');
|
||||
if (!btn) return;
|
||||
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '🔄 Enviando sinal...';
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth('/api/system/trigger-sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || 'Erro ao acionar sincronização.');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.devicesTriggered > 0) {
|
||||
showToast(`✅ Sinal enviado para ${data.devicesTriggered} dispositivo(s) conectado(s)!`, 'success');
|
||||
} else {
|
||||
showToast('⚠️ Nenhum dispositivo Windows conectado para sincronizar.', 'warning');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUploadPatients() {
|
||||
const select = document.getElementById('uploadPatientSelect');
|
||||
if (!select) return;
|
||||
|
||||
// Manter as primeiras duas opções
|
||||
while (select.options.length > 2) {
|
||||
select.remove(2);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth('/api/images/patients?disabled=false&search=');
|
||||
if (!res.ok) throw new Error('Erro ao listar pacientes.');
|
||||
|
||||
const patients = await res.json();
|
||||
patients.forEach(p => {
|
||||
if (!p || !p.patient_name) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.patient_name;
|
||||
opt.textContent = p.patient_name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Erro ao carregar lista de pacientes.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNewPatientFields() {
|
||||
const select = document.getElementById('uploadPatientSelect');
|
||||
const fields = document.getElementById('newPatientFields');
|
||||
|
||||
if (!select || !fields) return;
|
||||
|
||||
if (select.value === 'NEW_PATIENT') {
|
||||
fields.style.display = 'block';
|
||||
document.getElementById('uploadNewPatientFirstName').required = true;
|
||||
document.getElementById('uploadNewPatientLastName').required = true;
|
||||
} else {
|
||||
fields.style.display = 'none';
|
||||
document.getElementById('uploadNewPatientFirstName').required = false;
|
||||
document.getElementById('uploadNewPatientLastName').required = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetManualUploadForm() {
|
||||
selectedUploadFiles = [];
|
||||
|
||||
const form = document.getElementById('manualUploadForm');
|
||||
if (form) form.reset();
|
||||
|
||||
const previews = document.getElementById('uploadPreviewsContainer');
|
||||
if (previews) previews.replaceChildren();
|
||||
|
||||
const progressContainer = document.getElementById('uploadProgressContainer');
|
||||
if (progressContainer) progressContainer.style.display = 'none';
|
||||
|
||||
const fields = document.getElementById('newPatientFields');
|
||||
if (fields) fields.style.display = 'none';
|
||||
|
||||
const btnSubmit = document.getElementById('btnSubmitManualUpload');
|
||||
if (btnSubmit) btnSubmit.disabled = true;
|
||||
}
|
||||
|
||||
// Inicializar Drag and Drop
|
||||
function initDragAndDrop() {
|
||||
const zone = document.getElementById('uploadDragDropZone');
|
||||
const fileInput = document.getElementById('uploadFileInput');
|
||||
|
||||
if (!zone || !fileInput) return;
|
||||
|
||||
// Evento de clique na zona abre o seletor de arquivos
|
||||
zone.addEventListener('click', () => fileInput.click());
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
zone.addEventListener(eventName, e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
zone.addEventListener(eventName, () => {
|
||||
zone.classList.add('drag-active');
|
||||
}, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
zone.addEventListener(eventName, () => {
|
||||
zone.classList.remove('drag-active');
|
||||
}, false);
|
||||
});
|
||||
|
||||
zone.addEventListener('drop', e => {
|
||||
const dt = e.dataTransfer;
|
||||
if (dt) handleUploadFiles(dt.files);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', e => {
|
||||
if (e.target && e.target.files) handleUploadFiles(e.target.files);
|
||||
});
|
||||
}
|
||||
|
||||
function handleUploadFiles(files) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
if (!file.type.match('image.*')) {
|
||||
showToast(`O arquivo "${file.name}" não é uma imagem válida!`, 'warning');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
showToast(`A imagem "${file.name}" excede o limite de tamanho de 10MB!`, 'warning');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (selectedUploadFiles.some(f => f.name === file.name && f.size === file.size)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectedUploadFiles.push(file);
|
||||
}
|
||||
|
||||
renderUploadPreviews();
|
||||
}
|
||||
|
||||
function renderUploadPreviews() {
|
||||
const container = document.getElementById('uploadPreviewsContainer');
|
||||
const btnSubmit = document.getElementById('btnSubmitManualUpload');
|
||||
if (!container) return;
|
||||
|
||||
container.replaceChildren();
|
||||
|
||||
selectedUploadFiles.forEach((file, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'preview-item';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.alt = file.name;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
if (e.target && e.target.result) img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
const btnRemove = document.createElement('button');
|
||||
btnRemove.type = 'button';
|
||||
btnRemove.className = 'preview-remove';
|
||||
btnRemove.innerHTML = '×';
|
||||
btnRemove.title = 'Remover Imagem';
|
||||
btnRemove.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
removeUploadPreview(index);
|
||||
};
|
||||
|
||||
item.appendChild(img);
|
||||
item.appendChild(btnRemove);
|
||||
container.appendChild(item);
|
||||
});
|
||||
|
||||
if (btnSubmit) {
|
||||
btnSubmit.disabled = selectedUploadFiles.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function removeUploadPreview(index) {
|
||||
selectedUploadFiles.splice(index, 1);
|
||||
renderUploadPreviews();
|
||||
}
|
||||
|
||||
function readFileAsBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = error => reject(error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleManualUploadSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (selectedUploadFiles.length === 0) {
|
||||
showToast('Selecione pelo menos uma imagem para enviar.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const select = document.getElementById('uploadPatientSelect');
|
||||
const remarkInput = document.getElementById('uploadRemark');
|
||||
const btnSubmit = document.getElementById('btnSubmitManualUpload');
|
||||
|
||||
if (!select || !btnSubmit) return;
|
||||
|
||||
let patientName = '';
|
||||
let doctor = '';
|
||||
|
||||
if (select.value === 'NEW_PATIENT') {
|
||||
const firstName = document.getElementById('uploadNewPatientFirstName').value.trim();
|
||||
const lastName = document.getElementById('uploadNewPatientLastName').value.trim();
|
||||
doctor = document.getElementById('uploadNewPatientDoctor').value.trim();
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
showToast('Nome e sobrenome são obrigatórios para novo paciente.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
patientName = `${firstName} ${lastName}`;
|
||||
} else {
|
||||
patientName = select.value;
|
||||
}
|
||||
|
||||
const remark = remarkInput ? remarkInput.value.trim() : '';
|
||||
|
||||
const progressContainer = document.getElementById('uploadProgressContainer');
|
||||
const progressText = document.getElementById('uploadProgressText');
|
||||
const progressBarFill = document.getElementById('uploadProgressBarFill');
|
||||
const progressPercent = document.getElementById('uploadProgressPercent');
|
||||
|
||||
if (progressContainer) progressContainer.style.display = 'block';
|
||||
btnSubmit.disabled = true;
|
||||
|
||||
const totalFiles = selectedUploadFiles.length;
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < totalFiles; i++) {
|
||||
const file = selectedUploadFiles[i];
|
||||
|
||||
if (progressText) progressText.textContent = `Enviando imagem ${i + 1} de ${totalFiles}...`;
|
||||
if (progressBarFill) {
|
||||
const pct = Math.round((i / totalFiles) * 100);
|
||||
progressBarFill.style.width = `${pct}%`;
|
||||
if (progressPercent) progressPercent.textContent = `${pct}%`;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await readFileAsBase64(file);
|
||||
|
||||
const response = await fetchWithAuth('/api/images/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
imageBase64: base64,
|
||||
filename: file.name,
|
||||
patientName: patientName,
|
||||
doctor: doctor || null,
|
||||
remark: remark || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error || `Erro HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
successCount++;
|
||||
} catch (e) {
|
||||
console.error(`Erro ao enviar arquivo ${file.name}:`, e);
|
||||
showToast(`Falha ao enviar a imagem ${file.name}: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (progressBarFill) {
|
||||
progressBarFill.style.width = '100%';
|
||||
if (progressPercent) progressPercent.textContent = '100%';
|
||||
}
|
||||
if (progressText) progressText.textContent = `Finalizado! ${successCount} de ${totalFiles} imagens enviadas.`;
|
||||
|
||||
setTimeout(async () => {
|
||||
closeSyncModal();
|
||||
showToast(`✅ ${successCount} imagem(ns) enviada(s) com sucesso!`, 'success');
|
||||
|
||||
await loadPatients();
|
||||
if (window.loadClientsList) {
|
||||
await window.loadClientsList();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Vinculando ao window
|
||||
window.openSyncModal = openSyncModal;
|
||||
window.closeSyncModal = closeSyncModal;
|
||||
window.switchSyncTab = switchSyncTab;
|
||||
window.triggerSyncFromModal = triggerSyncFromModal;
|
||||
window.refreshUploadPatients = refreshUploadPatients;
|
||||
window.toggleNewPatientFields = toggleNewPatientFields;
|
||||
window.handleManualUploadSubmit = handleManualUploadSubmit;
|
||||
window.initDragAndDrop = initDragAndDrop;
|
||||
window.deletePatientImages = deletePatientImages;
|
||||
window.adjustFineTune = adjustFineTune;
|
||||
window.resetFineTune = resetFineTune;
|
||||
// A aplicação é inicializada no evento DOMContentLoaded acima
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
<a href="#" class="nav-item" onclick="openSettingsModal(); return false;">
|
||||
<span class="icon">⚙️</span> Configurações
|
||||
</a>
|
||||
<a href="#" class="nav-item" onclick="openPluginsModal(); return false;">
|
||||
<span class="icon">🔌</span> Plugins / Wasabi
|
||||
</a>
|
||||
<a href="#" class="nav-item" onclick="openSyncModal(); return false;">
|
||||
<span class="icon">🔄</span> Sincronização
|
||||
</a>
|
||||
<a href="/reset" class="nav-item" style="color: #dc3545;">
|
||||
<span class="icon">⚠️</span> Zerar Sistema
|
||||
</a>
|
||||
@@ -122,7 +128,7 @@
|
||||
<!-- View 1: Transforms -->
|
||||
<div id="transformViewWrapper" style="display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%;">
|
||||
<div id="transformOptions" class="transform-grid" style="flex: 1; min-height: 0;"></div>
|
||||
<div id="transformActionArea" style="display: none; margin-top: 20px; background: #f8f9fa; padding: 15px; border-radius: 8px; flex-shrink: 0;">
|
||||
<div id="transformActionArea" style="display: none; margin-top: 15px; background: #f8f9fa; padding: 15px; border-radius: 8px; flex-shrink: 0; border: 1px solid #e3e8ee;">
|
||||
<label style="font-weight: 600; display: block; margin-bottom: 8px;">Nova Observação / Detalhes (Opcional):</label>
|
||||
<textarea id="newImageRemark" class="search-input" rows="2" style="width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 6px;" placeholder="Ex: Raio-X Dente 45..."></textarea>
|
||||
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
||||
@@ -130,6 +136,16 @@
|
||||
<button id="btnSaveTransform" class="btn btn-primary" style="flex: 2; padding: 12px; font-size: 1.1rem;" onclick="saveSelectedTransform()">Salvar Nova Imagem</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bloco de Ajuste Fino (Movido dinamicamente para o card selecionado) -->
|
||||
<div id="fineTuneContainer" style="display: none;" onclick="event.stopPropagation()">
|
||||
<button type="button" onclick="adjustFineTune(-5)" title="Rotacionar -5°">-5°</button>
|
||||
<button type="button" onclick="adjustFineTune(-1)" title="Rotacionar -1°">-1°</button>
|
||||
<div class="fine-tune-val-display" id="fineTuneValDisplay">0°</div>
|
||||
<button type="button" onclick="adjustFineTune(1)" title="Rotacionar +1°">+1°</button>
|
||||
<button type="button" onclick="adjustFineTune(5)" title="Rotacionar +5°">+5°</button>
|
||||
<button type="button" id="btnResetFineTune" onclick="resetFineTune()" style="display: none; background: #e53e3e; border-color: #e53e3e; color: white; border-radius: 15px; width: auto; padding: 0 10px; height: 32px;" title="Resetar ajuste fino">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View 2: GTO List & Link -->
|
||||
@@ -270,6 +286,169 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugins / Wasabi Modal -->
|
||||
<div id="pluginsModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>🔌 Configuração do Wasabi Storage</h2>
|
||||
<span class="modal-close" onclick="closePluginsModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="pluginsForm" onsubmit="updatePluginsConfig(event)">
|
||||
<div style="background: #f8f9fa; padding: 12px 18px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 20px; font-size: 0.95rem; color: #555;">
|
||||
Configure as credenciais e bucket do **Wasabi S3** para habilitar o armazenamento na nuvem. Se desativado, o sistema usará o armazenamento local do servidor de forma automática.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="wasabiAccessKey">Access Key *</label>
|
||||
<input type="text" id="wasabiAccessKey" placeholder="Ex: AIPL5M4G..." required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="wasabiSecretKey">Secret Key *</label>
|
||||
<input type="password" id="wasabiSecretKey" placeholder="Sua Secret Key" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="wasabiBucket">Bucket Ativo *</label>
|
||||
<input type="text" id="wasabiBucket" placeholder="Ex: meu-bucket-rx" required>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label for="wasabiRegion">Região</label>
|
||||
<input type="text" id="wasabiRegion" placeholder="Ex: us-east-1 (padrão)">
|
||||
</div>
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label for="wasabiEndpoint">Endpoint</label>
|
||||
<input type="text" id="wasabiEndpoint" placeholder="Ex: s3.wasabisys.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pluginsStatusAlert" style="display: none; padding: 10px; border-radius: 6px; font-size: 0.9rem; margin-top: 15px;"></div>
|
||||
|
||||
<div class="form-actions" style="margin-top: 25px; display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid #eee; padding-top: 15px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closePluginsModal()">Cancelar</button>
|
||||
<button type="submit" class="btn btn-primary" id="btnSavePlugins">Salvar Configurações</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync / Upload Modal -->
|
||||
<div id="syncModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 650px; height: auto; border-radius: var(--radius);">
|
||||
<div class="modal-header">
|
||||
<h2>🔄 Área de Sincronização</h2>
|
||||
<span class="modal-close" onclick="closeSyncModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="sync-tabs">
|
||||
<button type="button" id="tabBtnDevices" class="sync-tab-btn active" onclick="switchSyncTab('devices')">💻 Dispositivos Conectados</button>
|
||||
<button type="button" id="tabBtnUpload" class="sync-tab-btn" onclick="switchSyncTab('upload')">📤 Envio Manual</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Dispositivos -->
|
||||
<div id="tab-devices" class="sync-tab-content active">
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 20px; font-size: 0.95rem; color: #555; line-height: 1.5;">
|
||||
Os computadores clientes com Windows instalados e ativos enviam imagens em tempo real. Você pode solicitar que eles façam uma sincronização manual forçada com o servidor enviando um sinal.
|
||||
</div>
|
||||
|
||||
<div id="syncDevicesLoading" class="loading" style="padding: 20px 0;">
|
||||
<div class="spinner" style="width: 30px; height: 30px; margin-bottom: 10px;"></div>
|
||||
<p>Buscando dispositivos online...</p>
|
||||
</div>
|
||||
|
||||
<div id="syncDevicesEmpty" style="display: none; text-align: center; padding: 30px 20px; color: #666;">
|
||||
<span style="font-size: 2.5rem; display: block; margin-bottom: 10px;">📭</span>
|
||||
<p>Nenhum dispositivo Windows conectado no momento.</p>
|
||||
</div>
|
||||
|
||||
<div id="syncDeviceList" class="sync-device-list" style="display: none;">
|
||||
<!-- Preenchido via Javascript -->
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 25px; border-top: 1px solid #eee; padding-top: 15px; display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeSyncModal()">Fechar</button>
|
||||
<button type="button" class="btn btn-primary" onclick="triggerSyncFromModal()" id="btnTriggerSyncModal">
|
||||
<span class="icon">🔄</span> Enviar Sinal de Sincronização
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Envio Manual -->
|
||||
<div id="tab-upload" class="sync-tab-content">
|
||||
<form id="manualUploadForm" onsubmit="handleManualUploadSubmit(event)">
|
||||
<div class="form-group">
|
||||
<label for="uploadPatientSelect">Paciente *</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<select id="uploadPatientSelect" onchange="toggleNewPatientFields()" required style="flex: 1;">
|
||||
<option value="">-- Selecione o Paciente --</option>
|
||||
<option value="NEW_PATIENT">➕ Novo Paciente...</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary" onclick="refreshUploadPatients()" title="Atualizar Lista" style="width: 44px; padding: 0; display: flex; align-items: center; justify-content: center;">
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campos para Novo Paciente (ocultos por padrão) -->
|
||||
<div id="newPatientFields" style="display: none; background: #f8f9fa; padding: 15px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 18px; animation: slideDown 0.2s ease;">
|
||||
<h4 style="margin: 0 0 12px 0; color: var(--dark-color); font-size: 0.95rem;">Dados do Novo Paciente</h4>
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 12px;">
|
||||
<div style="flex: 1;">
|
||||
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 5px;">Nome *</label>
|
||||
<input type="text" id="uploadNewPatientFirstName" placeholder="Ex: Maria" style="width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 5px;">Sobrenome *</label>
|
||||
<input type="text" id="uploadNewPatientLastName" placeholder="Ex: Oliveira" style="width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 5px;">Dentista Responsável</label>
|
||||
<input type="text" id="uploadNewPatientDoctor" placeholder="Nome do dentista (opcional)" style="width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="uploadRemark">Observações / Detalhes (Opcional)</label>
|
||||
<textarea id="uploadRemark" placeholder="Ex: Raio-X Panorâmico" rows="2" style="width: 100%; padding: 10px; border: 2px solid var(--border-color); border-radius: var(--radius-sm); font-family: inherit; font-size: 1rem; resize: vertical;"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Zona de Drag and Drop -->
|
||||
<div class="drag-drop-zone" id="uploadDragDropZone">
|
||||
<div class="drag-drop-icon">📸</div>
|
||||
<div class="drag-drop-text">Arraste as imagens aqui ou clique para selecionar</div>
|
||||
<div class="drag-drop-subtext">Suporta arquivos JPG, JPEG e PNG (máx. 10MB por imagem)</div>
|
||||
<input type="file" id="uploadFileInput" multiple accept="image/png, image/jpeg, image/jpg" style="display: none;">
|
||||
</div>
|
||||
|
||||
<!-- Previews das Imagens Selecionadas -->
|
||||
<div class="upload-previews" id="uploadPreviewsContainer"></div>
|
||||
|
||||
<!-- Progresso de Envio -->
|
||||
<div class="upload-progress-container" id="uploadProgressContainer" style="display: none;">
|
||||
<div class="progress-bar-label">
|
||||
<span id="uploadProgressText">Enviando imagens...</span>
|
||||
<span id="uploadProgressPercent">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar-track">
|
||||
<div class="progress-bar-fill" id="uploadProgressBarFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="margin-top: 25px; border-top: 1px solid #eee; padding-top: 15px; display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeSyncModal()">Cancelar</button>
|
||||
<button type="submit" class="btn btn-primary" id="btnSubmitManualUpload" disabled>Enviar Imagens</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Script -->
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
|
||||
@@ -754,10 +754,9 @@ body {
|
||||
grid-column: auto !important;
|
||||
grid-row: auto !important;
|
||||
}
|
||||
.selection-mode .transform-option img {
|
||||
height: 35vh !important;
|
||||
max-height: 400px;
|
||||
object-fit: contain;
|
||||
.selection-mode .transform-image-wrapper {
|
||||
height: 45vh !important;
|
||||
max-height: 500px;
|
||||
}
|
||||
/* -90°: col 4, linha 2 (empilhado sob +90°) */
|
||||
.transform-grid.grid-portrait .transform-option:nth-child(5) { grid-column: 4; grid-row: 2; }
|
||||
@@ -786,6 +785,7 @@ body {
|
||||
align-items: center;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative; /* Para posicionar o ajuste fino absoluto */
|
||||
}
|
||||
|
||||
.transform-option:hover {
|
||||
@@ -798,13 +798,28 @@ body {
|
||||
background: rgba(81, 207, 102, 0.08);
|
||||
}
|
||||
|
||||
.transform-option img {
|
||||
/* Novo Wrapper para a Imagem */
|
||||
.transform-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 15vh;
|
||||
min-height: 120px;
|
||||
max-height: 250px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: rgba(0,0,0,0.02);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.transform-image-wrapper img {
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
transition: transform 0.15s ease-out; /* Transição para ajuste fino suave */
|
||||
}
|
||||
|
||||
.transform-name {
|
||||
@@ -814,6 +829,64 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Estilo do Ajuste Fino Sobreposto como Marca d'Água */
|
||||
#fineTuneContainer {
|
||||
position: absolute;
|
||||
bottom: 45px; /* Logo acima do transform-name */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 30px;
|
||||
padding: 6px 12px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.18);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: max-content;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(5px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
#fineTuneContainer button {
|
||||
background: #f1f3f5;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
#fineTuneContainer button:hover {
|
||||
background: #e9ecef;
|
||||
border-color: #adb5bd;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
#fineTuneContainer .fine-tune-val-display {
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ================================================================
|
||||
@@ -1072,3 +1145,240 @@ body {
|
||||
#toggleDisabledBtn { font-size: 0; padding: 10px; }
|
||||
#toggleDisabledBtn::before { content: '👁'; font-size: 1.1rem; }
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
SYNC & UPLOAD MODAL STYLES
|
||||
================================================================ */
|
||||
|
||||
.sync-tabs {
|
||||
display: flex;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sync-tab-btn {
|
||||
padding: 12px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.sync-tab-btn:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.sync-tab-btn.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.sync-tab-content {
|
||||
display: none;
|
||||
animation: slideDown 0.25s ease;
|
||||
}
|
||||
|
||||
.sync-tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Área de Drag & Drop */
|
||||
.drag-drop-zone {
|
||||
border: 3px dashed var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
background: #fdfdfd;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.drag-drop-zone:hover,
|
||||
.drag-drop-zone.drag-active {
|
||||
border-color: var(--primary-color);
|
||||
background: rgba(102, 126, 234, 0.04);
|
||||
}
|
||||
|
||||
.drag-drop-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.drag-drop-text {
|
||||
font-size: 1rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.drag-drop-subtext {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Preview de arquivos selecionados */
|
||||
.upload-previews {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.preview-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background: rgba(220, 53, 69, 0.85);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.preview-remove:hover {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
/* Barra de Progresso */
|
||||
.upload-progress-container {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.progress-bar-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.progress-bar-track {
|
||||
background: #e2e8f0;
|
||||
border-radius: 10px;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
background: linear-gradient(90deg, var(--primary-color) 0%, #764ba2 100%);
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
/* Lista de dispositivos online */
|
||||
.sync-device-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.sync-device-card {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sync-device-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.device-card-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.device-card-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.device-card-subtitle {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.device-card-badge {
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge-connected {
|
||||
background: rgba(81, 207, 102, 0.15);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.badge-disconnected {
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* Botão de Excluir Paciente nos cards iniciais */
|
||||
.delete-patient-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(220, 53, 69, 0.85);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 5;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 1.05rem;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
.delete-patient-btn:hover {
|
||||
background: #dc3545;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
+153
-35
@@ -4,6 +4,8 @@ const db = require('../database');
|
||||
const imageProcessor = require('../image-processor');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const storage = require('../storage');
|
||||
const sharp = require('sharp');
|
||||
|
||||
// ================================================================
|
||||
// GET /api/images - Listar imagens
|
||||
@@ -64,12 +66,12 @@ router.get('/patients', async (req, res) => {
|
||||
const rows = await db.all(
|
||||
`SELECT
|
||||
patient_name,
|
||||
doctor,
|
||||
remark,
|
||||
client_name,
|
||||
MAX(doctor) AS doctor,
|
||||
MAX(remark) AS remark,
|
||||
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
|
||||
@@ -114,6 +116,107 @@ router.get('/by-patient', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// POST /api/images/upload - Fazer upload manual de imagem
|
||||
// ================================================================
|
||||
router.post('/upload', async (req, res) => {
|
||||
try {
|
||||
const { imageBase64, filename, patientName, doctor, remark } = req.body;
|
||||
|
||||
if (!imageBase64) {
|
||||
return res.status(400).json({ error: 'imageBase64 é obrigatório' });
|
||||
}
|
||||
|
||||
// Decodificar imagem base64
|
||||
let base64Data = imageBase64;
|
||||
if (base64Data.includes(';base64,')) {
|
||||
base64Data = base64Data.split(';base64,')[1];
|
||||
}
|
||||
const imageBuffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// TODO(security): Adicionado limite de 10MB para prevenir Buffer Overflow/DoS
|
||||
if (imageBuffer.length > 10 * 1024 * 1024) {
|
||||
return res.status(400).json({ error: 'A imagem excede o limite de tamanho permitido de 10MB.' });
|
||||
}
|
||||
|
||||
// Validar imagem (garante integridade do arquivo através do sharp)
|
||||
await imageProcessor.validateImage(imageBuffer);
|
||||
|
||||
// Salvar imagem
|
||||
const imageGuid = 'web_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
|
||||
// Detectar formato
|
||||
const imgMetadata = await sharp(imageBuffer).metadata();
|
||||
const format = imgMetadata.format || 'png';
|
||||
const ext = format === 'jpeg' ? 'jpg' : format;
|
||||
const savedFilename = `${Date.now()}_${imageGuid}.${ext}`;
|
||||
|
||||
const clientName = 'Upload Web';
|
||||
const patientVal = patientName ? patientName.trim() : 'Paciente não identificado';
|
||||
|
||||
await storage.saveImage(savedFilename, imageBuffer, clientName, patientVal, false);
|
||||
|
||||
// Gerar thumbnail WebP
|
||||
let thumbFilename = null;
|
||||
try {
|
||||
const thumbBuffer = await sharp(imageBuffer)
|
||||
.resize({ width: 400, height: 400, fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 80, effort: 4 })
|
||||
.toBuffer();
|
||||
|
||||
thumbFilename = `thumb_${Date.now()}_${imageGuid}.webp`;
|
||||
await storage.saveImage(thumbFilename, thumbBuffer, clientName, patientVal, false);
|
||||
console.log(`✅ Thumbnail gerado e salvo para upload manual: ${thumbFilename}`);
|
||||
} catch (thumbErr) {
|
||||
console.error('⚠️ Falha ao gerar thumbnail para upload manual:', thumbErr.message);
|
||||
}
|
||||
|
||||
// Inserir no banco de dados
|
||||
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 = formatLocalDateTime(new Date());
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
imageGuid,
|
||||
patientVal,
|
||||
clientName,
|
||||
filename || 'upload_manual.png',
|
||||
savedFilename,
|
||||
1,
|
||||
doctor || null,
|
||||
remark || null,
|
||||
thumbFilename,
|
||||
createdAt
|
||||
]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
imageId: result.lastID,
|
||||
filename: savedFilename,
|
||||
message: 'Imagem enviada com sucesso!'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro no upload de imagem manual:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// GET /api/images/:id - Buscar imagem por ID
|
||||
// ================================================================
|
||||
@@ -164,19 +267,13 @@ 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 = ?', [originalId]);
|
||||
|
||||
const originalPath = path.join(__dirname, '../uploads', originalImage.filename);
|
||||
let originalBuffer;
|
||||
|
||||
try {
|
||||
originalBuffer = await fs.readFile(originalPath);
|
||||
} 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;
|
||||
let originalBuffer = await storage.getImageBuffer(originalImage.filename, false);
|
||||
if (!originalBuffer) {
|
||||
originalBuffer = await storage.getImageBuffer(originalImage.filename, true);
|
||||
}
|
||||
|
||||
if (!originalBuffer) {
|
||||
return res.status(404).json({ error: 'Arquivo de imagem original não encontrado' });
|
||||
}
|
||||
|
||||
const transformations = [
|
||||
@@ -228,17 +325,13 @@ router.post('/:id/transform', async (req, res) => {
|
||||
const realOriginal = await db.get('SELECT * FROM images WHERE id = ?', [realOriginalId]);
|
||||
|
||||
// Processar a imagem
|
||||
const originalPath = path.join(__dirname, '../uploads', realOriginal.filename);
|
||||
let originalBuffer;
|
||||
try {
|
||||
originalBuffer = await fs.readFile(originalPath);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
const fallbackPath = path.join(__dirname, '../processed', realOriginal.filename);
|
||||
originalBuffer = await fs.readFile(fallbackPath);
|
||||
} else {
|
||||
throw e;
|
||||
let originalBuffer = await storage.getImageBuffer(realOriginal.filename, false);
|
||||
if (!originalBuffer) {
|
||||
originalBuffer = await storage.getImageBuffer(realOriginal.filename, true);
|
||||
}
|
||||
|
||||
if (!originalBuffer) {
|
||||
return res.status(404).json({ error: 'Arquivo de imagem original não encontrado' });
|
||||
}
|
||||
|
||||
let processed = await imageProcessor.rotate(originalBuffer, rotation);
|
||||
@@ -247,8 +340,7 @@ router.post('/:id/transform', async (req, res) => {
|
||||
// Salvar nova versão em processed
|
||||
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 atual para que não fique duplicada na interface
|
||||
await db.run('UPDATE images SET enabled = 0 WHERE id = ?', [req.params.id]);
|
||||
@@ -339,6 +431,37 @@ router.get('/:id/download', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// DELETE /api/images/by-patient - Apagar todas as 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: 'Nome do paciente é obrigatório' });
|
||||
}
|
||||
|
||||
// Buscar todas as imagens do paciente no banco de dados
|
||||
const images = await db.all('SELECT filename, client_name, patient_name FROM images WHERE patient_name = ?', [patientName]);
|
||||
|
||||
// Apagar cada imagem do storage (Wasabi S3 + Local)
|
||||
for (const img of images) {
|
||||
await storage.deleteImage(img.filename, img.client_name, img.patient_name);
|
||||
}
|
||||
|
||||
// Apagar os registros do banco de dados
|
||||
await db.run('DELETE FROM images WHERE patient_name = ?', [patientName]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Todas as imagens do paciente "${patientName}" foram excluídas com sucesso do servidor e do Wasabi.`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro ao excluir imagens do paciente em lote:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// DELETE /api/images/:id - Apagar versão da imagem
|
||||
// ================================================================
|
||||
@@ -357,13 +480,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 do storage (Wasabi S3 + Local)
|
||||
await storage.deleteImage(image.filename, image.client_name, image.patient_name);
|
||||
|
||||
// Apagar do banco
|
||||
await db.run('DELETE FROM images WHERE id = ?', [req.params.id]);
|
||||
|
||||
@@ -68,4 +68,29 @@ router.delete('/factory-reset', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// GET /api/system/storage-config - Obter configurações de storage
|
||||
// ================================================================
|
||||
router.get('/storage-config', (req, res) => {
|
||||
try {
|
||||
const storage = require('../storage');
|
||||
res.json(storage.getSafeConfig());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// POST /api/system/storage-config - Atualizar configurações de storage
|
||||
// ================================================================
|
||||
router.post('/storage-config', async (req, res) => {
|
||||
try {
|
||||
const storage = require('../storage');
|
||||
const result = await storage.updateConfig(req.body);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+103
-13
@@ -5,6 +5,7 @@ const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const sharp = require('sharp');
|
||||
require('dotenv').config();
|
||||
|
||||
// Importar módulos
|
||||
@@ -12,6 +13,7 @@ const imageProcessor = require('./image-processor');
|
||||
const { authenticateToken, hashPassword, JWT_SECRET } = require('./auth');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('./database');
|
||||
const storage = require('./storage');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const imageRoutes = require('./routes/images');
|
||||
const patientsRouter = require('./routes/patients');
|
||||
@@ -401,8 +403,44 @@ app.use('/login', express.static('auth'));
|
||||
|
||||
// Serve static files
|
||||
app.use(express.static('public'));
|
||||
app.use('/uploads', express.static(UPLOAD_DIR));
|
||||
app.use('/uploads', express.static(PROCESSED_DIR));
|
||||
|
||||
app.get('/uploads/:filename', async (req, res) => {
|
||||
const filename = req.params.filename;
|
||||
|
||||
// Enviar ETag e Cache-Control imutável
|
||||
res.setHeader('ETag', `"${filename}"`);
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
|
||||
const clientEtag = req.headers['if-none-match'];
|
||||
if (clientEtag === `"${filename}"` || clientEtag === filename || clientEtag === `W/"${filename}"`) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
// Tentar primeiro obter da pasta de uploads (imagens originais)
|
||||
let buffer = await storage.getImageBuffer(filename, false);
|
||||
|
||||
// Se não encontrar, tentar da pasta de processed (imagens rotacionadas/transformadas)
|
||||
if (!buffer) {
|
||||
buffer = await storage.getImageBuffer(filename, true);
|
||||
}
|
||||
|
||||
if (!buffer) {
|
||||
return res.status(404).send('Imagem não encontrada');
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.send(buffer);
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// MIDDLEWARE DE VERIFICAÇÃO DE INSTALAÇÃO (APENAS PARA APIs)
|
||||
@@ -456,10 +494,29 @@ app.use('/api/images', imageRoutes);
|
||||
app.use('/api/gtos', authenticateToken);
|
||||
app.use('/api/gtos', gtosRouter);
|
||||
|
||||
// APIs do Sistema - requerem autenticação
|
||||
app.use('/api/system', authenticateToken);
|
||||
app.use('/api/system', systemRoutes);
|
||||
|
||||
// Endpoint para solicitar a sincronização aos dispositivos conectados
|
||||
app.post('/api/system/trigger-sync', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const windowsClients = connectedClients.filter(c => c.type === 'windows');
|
||||
|
||||
// Envia o evento 'sync-request' com timestamp para todos os dispositivos clientes identificados
|
||||
io.emit('sync-request', { timestamp: Date.now() });
|
||||
console.log(`🔄 [Sync] Sinal de sincronização enviado para ${windowsClients.length} dispositivo(s) Windows.`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
devicesTriggered: windowsClients.length,
|
||||
message: `Sinal de sincronização enviado com sucesso para ${windowsClients.length} dispositivo(s) Windows.`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro ao solicitar sincronização:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// SOCKET.IO - CONEXÕES EM TEMPO REAL
|
||||
// ================================================================
|
||||
@@ -613,16 +670,37 @@ io.on('connection', (socket) => {
|
||||
// Validar imagem
|
||||
await imageProcessor.validateImage(imageBuffer);
|
||||
|
||||
// Detectar formato da imagem e gerar extensão adequada
|
||||
const imgMetadata = await sharp(imageBuffer).metadata();
|
||||
const format = imgMetadata.format || 'png';
|
||||
const ext = format === 'jpeg' ? 'jpg' : format;
|
||||
|
||||
// Salvar imagem original
|
||||
const timestamp = Date.now();
|
||||
const filename = `${timestamp}_${data.metadata.guid}.png`;
|
||||
const filename = `${timestamp}_${data.metadata.guid}.${ext}`;
|
||||
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.name || 'Paciente não identificado';
|
||||
|
||||
await storage.saveImage(filename, imageBuffer, clientName, patientName, false);
|
||||
|
||||
// Gerar e salvar thumbnail WebP
|
||||
let thumbFilename = null;
|
||||
try {
|
||||
const thumbBuffer = await sharp(imageBuffer)
|
||||
.resize({ width: 400, height: 400, fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 80, effort: 4 })
|
||||
.toBuffer();
|
||||
|
||||
thumbFilename = `thumb_${timestamp}_${data.metadata.guid}.webp`;
|
||||
await storage.saveImage(thumbFilename, thumbBuffer, clientName, patientName, false);
|
||||
console.log(`✅ Thumbnail gerado e salvo no Wasabi: ${thumbFilename}`);
|
||||
} catch (thumbErr) {
|
||||
console.error('⚠️ Falha ao gerar thumbnail, prosseguindo sem ele:', thumbErr.message);
|
||||
}
|
||||
|
||||
// Determinar data de criação
|
||||
const formatLocalDateTime = (date) => {
|
||||
@@ -643,8 +721,9 @@ io.on('connection', (socket) => {
|
||||
enabled,
|
||||
doctor,
|
||||
remark,
|
||||
thumb_filename,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
data.metadata.guid,
|
||||
data.patientData.name || 'Paciente não identificado',
|
||||
@@ -654,6 +733,7 @@ io.on('connection', (socket) => {
|
||||
1,
|
||||
data.patientData.doctor || null,
|
||||
data.patientData.remark || null,
|
||||
thumbFilename,
|
||||
createdAt
|
||||
]
|
||||
);
|
||||
@@ -685,6 +765,7 @@ io.on('connection', (socket) => {
|
||||
doctor: data.patientData.doctor || null,
|
||||
remark: data.patientData.remark || null,
|
||||
filename: filename,
|
||||
thumb_filename: thumbFilename,
|
||||
created_at: createdAt
|
||||
});
|
||||
}
|
||||
@@ -812,28 +893,37 @@ async function initDirectories() {
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const fsSync = require('fs');
|
||||
try {
|
||||
// Inicializar diretórios
|
||||
await initDirectories();
|
||||
|
||||
// Tentar inicializar banco apenas se .env já existe
|
||||
// Inicializar banco se as variáveis já existirem no ambiente
|
||||
if (process.env.DB_TYPE === 'sqlite' || (process.env.DB_HOST && process.env.DB_NAME)) {
|
||||
try {
|
||||
await db.initDatabase();
|
||||
console.log('✅ Banco de dados inicializado a partir do ambiente');
|
||||
await storage.loadConfigFromDb();
|
||||
} catch (error) {
|
||||
console.log('❌ Falha ao inicializar o banco de dados:', error.message);
|
||||
}
|
||||
} else {
|
||||
// Fallback para arquivo .env físico
|
||||
const envPath = path.join(__dirname, '.env');
|
||||
const fsSync = require('fs');
|
||||
|
||||
if (fsSync.existsSync(envPath)) {
|
||||
try {
|
||||
// Recarregar .env se foi atualizado
|
||||
require('dotenv').config();
|
||||
|
||||
// Inicializar banco de dados apenas se configurações existem
|
||||
if (process.env.DB_TYPE === 'sqlite' || (process.env.DB_HOST && process.env.DB_NAME)) {
|
||||
await db.initDatabase();
|
||||
console.log('✅ Banco de dados inicializado');
|
||||
console.log('✅ Banco de dados inicializado a partir do .env');
|
||||
await storage.loadConfigFromDb();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('⚠️ Banco de dados não configurado. Execute a instalação em /install');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Iniciar servidor
|
||||
server.listen(PORT, () => {
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
const { S3Client } = require('@aws-sdk/client-s3');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Configurações das pastas (resolvendo caminhos corretos)
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, 'uploads');
|
||||
const PROCESSED_DIR = process.env.PROCESSED_DIR || path.join(__dirname, 'processed');
|
||||
const CONFIG_FILE = path.join(UPLOAD_DIR, 'wasabi_config.json');
|
||||
|
||||
let s3 = null;
|
||||
let currentBucket = null;
|
||||
let currentConfig = {};
|
||||
|
||||
// Função para inicializar/reconfigurar o S3 Client
|
||||
function initS3(cfg = {}) {
|
||||
const accessKey = cfg.wasabiAccessKey || process.env.WASABI_ACCESS_KEY;
|
||||
const secretKey = cfg.wasabiSecretKey || process.env.WASABI_SECRET_KEY;
|
||||
const region = cfg.wasabiRegion || process.env.WASABI_REGION || 'us-east-1';
|
||||
const bucket = cfg.wasabiBucket || process.env.WASABI_BUCKET;
|
||||
const rawEndpoint = cfg.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com';
|
||||
const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`;
|
||||
|
||||
currentConfig = {
|
||||
wasabiAccessKey: accessKey || '',
|
||||
wasabiSecretKey: secretKey || '',
|
||||
wasabiBucket: bucket || '',
|
||||
wasabiRegion: region || 'us-east-1',
|
||||
wasabiEndpoint: rawEndpoint || 's3.wasabisys.com'
|
||||
};
|
||||
|
||||
if (accessKey && secretKey && bucket) {
|
||||
s3 = new S3Client({
|
||||
region,
|
||||
endpoint,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
currentBucket = bucket;
|
||||
console.log(`✅ [Wasabi] Inicializado com sucesso — Bucket: ${bucket}, Endpoint: ${endpoint}`);
|
||||
} else {
|
||||
s3 = null;
|
||||
currentBucket = null;
|
||||
console.log('⚠️ [Wasabi] Chaves WASABI_ACCESS_KEY, WASABI_SECRET_KEY ou WASABI_BUCKET não definidas no JSON/Banco/Ambiente. Wasabi desativado.');
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializa a partir do arquivo JSON (se houver) ou das variáveis de ambiente (como fallback inicial síncrono)
|
||||
function loadConfigSync() {
|
||||
try {
|
||||
if (fsSync.existsSync(CONFIG_FILE)) {
|
||||
const data = fsSync.readFileSync(CONFIG_FILE, 'utf8');
|
||||
const cfg = JSON.parse(data);
|
||||
console.log('📖 [Wasabi] Configuração dinâmica lida de wasabi_config.json');
|
||||
initS3(cfg);
|
||||
} else {
|
||||
initS3({});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [Wasabi] Erro ao carregar arquivo de config:', error.message);
|
||||
initS3({});
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializa a partir do Banco de Dados (Chamado de forma assíncrona após db.initDatabase)
|
||||
async function loadConfigFromDb() {
|
||||
try {
|
||||
const db = require('./database');
|
||||
const keys = [
|
||||
'wasabi_access_key',
|
||||
'wasabi_secret_key',
|
||||
'wasabi_bucket',
|
||||
'wasabi_region',
|
||||
'wasabi_endpoint'
|
||||
];
|
||||
|
||||
const cfg = {};
|
||||
let hasSettings = false;
|
||||
|
||||
for (const k of keys) {
|
||||
const row = await db.get('SELECT value FROM settings WHERE key = ?', [k]);
|
||||
if (row) {
|
||||
// Mapear de snake_case do banco para camelCase do config
|
||||
const camelKey = k.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
|
||||
cfg[camelKey] = row.value;
|
||||
hasSettings = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSettings) {
|
||||
console.log('📖 [Wasabi] Configuração carregada com sucesso do Banco de Dados.');
|
||||
initS3(cfg);
|
||||
} else {
|
||||
// Se não há settings no banco, tenta carregar do fallback JSON
|
||||
loadConfigSync();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [Wasabi] Erro ao carregar do banco, usando fallback:', error.message);
|
||||
loadConfigSync();
|
||||
}
|
||||
}
|
||||
|
||||
// Inicialização imediata de fallback (síncrona)
|
||||
loadConfigSync();
|
||||
|
||||
// Retorna a configuração atual de forma segura (mascarando a senha/secret key)
|
||||
function getSafeConfig() {
|
||||
return {
|
||||
wasabiAccessKey: currentConfig.wasabiAccessKey,
|
||||
wasabiSecretKey: currentConfig.wasabiSecretKey ? '********' : '',
|
||||
wasabiBucket: currentConfig.wasabiBucket,
|
||||
wasabiRegion: currentConfig.wasabiRegion,
|
||||
wasabiEndpoint: currentConfig.wasabiEndpoint,
|
||||
enabled: !!(s3 && currentBucket)
|
||||
};
|
||||
}
|
||||
|
||||
// Salva e atualiza a configuração (no banco de dados e no arquivo de redundância local)
|
||||
async function updateConfig(newConfig) {
|
||||
try {
|
||||
const db = require('./database');
|
||||
|
||||
// Se a secretKey vier como asteriscos, mantém a antiga
|
||||
const finalSecretKey = newConfig.wasabiSecretKey === '********'
|
||||
? currentConfig.wasabiSecretKey
|
||||
: newConfig.wasabiSecretKey;
|
||||
|
||||
const configToSave = {
|
||||
wasabiAccessKey: newConfig.wasabiAccessKey || '',
|
||||
wasabiSecretKey: finalSecretKey || '',
|
||||
wasabiBucket: newConfig.wasabiBucket || '',
|
||||
wasabiRegion: newConfig.wasabiRegion || 'us-east-1',
|
||||
wasabiEndpoint: newConfig.wasabiEndpoint || 's3.wasabisys.com'
|
||||
};
|
||||
|
||||
// 1. Salva no banco de dados (com suporte a ON CONFLICT UPSERT do PostgreSQL e SQLite)
|
||||
const settingsMap = {
|
||||
'wasabi_access_key': configToSave.wasabiAccessKey,
|
||||
'wasabi_secret_key': configToSave.wasabiSecretKey,
|
||||
'wasabi_bucket': configToSave.wasabiBucket,
|
||||
'wasabi_region': configToSave.wasabiRegion,
|
||||
'wasabi_endpoint': configToSave.wasabiEndpoint
|
||||
};
|
||||
|
||||
for (const [key, val] of Object.entries(settingsMap)) {
|
||||
await db.run(
|
||||
`INSERT INTO settings (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (key)
|
||||
DO UPDATE SET value = EXCLUDED.value`,
|
||||
[key, val]
|
||||
);
|
||||
}
|
||||
console.log('💾 [Wasabi] Configurações persistidas no Banco de Dados.');
|
||||
|
||||
// 2. Salva localmente no arquivo de redundância JSON
|
||||
try {
|
||||
await fs.mkdir(path.dirname(CONFIG_FILE), { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(configToSave, null, 2), 'utf8');
|
||||
console.log('💾 [Wasabi] Cópia de segurança salva em wasabi_config.json');
|
||||
} catch (fsErr) {
|
||||
console.warn('⚠️ [Wasabi] Falha ao gravar cópia em arquivo de config:', fsErr.message);
|
||||
}
|
||||
|
||||
initS3(configToSave);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('❌ [Wasabi] Erro ao salvar configurações:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Higieniza nomes para caminhos do S3
|
||||
function sanitizePathSegment(segment) {
|
||||
if (!segment) return 'desconhecido';
|
||||
return segment.trim().replace(/[\/\\?#%*:"<>|]/g, '_');
|
||||
}
|
||||
|
||||
// Salva uma imagem (original ou processada) no disco local e envia para o Wasabi
|
||||
async function saveImage(filename, buffer, clientName, patientName, isProcessed = false) {
|
||||
const targetDir = isProcessed ? PROCESSED_DIR : UPLOAD_DIR;
|
||||
const destPath = path.join(targetDir, filename);
|
||||
|
||||
// 1. Salva localmente de forma temporária
|
||||
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await fs.writeFile(destPath, buffer);
|
||||
console.log(`💾 [Storage] Imagem salva localmente de forma temporária: ${destPath}`);
|
||||
|
||||
// 2. Envia para o Wasabi se ativo
|
||||
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';
|
||||
}
|
||||
|
||||
const upload = new Upload({
|
||||
client: s3,
|
||||
params: {
|
||||
Bucket: currentBucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
},
|
||||
});
|
||||
await upload.done();
|
||||
console.log(`☁️ [Wasabi] Upload concluído na pasta do cliente/paciente: ${key}`);
|
||||
|
||||
// Se o upload foi bem-sucedido e o Wasabi está ativo, removemos o arquivo local imediatamente
|
||||
try {
|
||||
await fs.unlink(destPath);
|
||||
console.log(`🗑️ [Storage] Arquivo local temporário removido após upload no Wasabi: ${destPath}`);
|
||||
} catch (unlinkErr) {
|
||||
console.warn(`⚠️ [Storage] Falha ao remover arquivo local temporário: ${unlinkErr.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [Wasabi] Falha no upload de ${filename}:`, error.message);
|
||||
// Mantemos o arquivo local se o Wasabi falhar como fallback resiliente
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Obtém o buffer da imagem (lê localmente, e 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);
|
||||
|
||||
// 1. Tenta ler local primeiro (para compatibilidade caso o Wasabi não estivesse ativo no salvamento inicial)
|
||||
if (fsSync.existsSync(localPath)) {
|
||||
return await fs.readFile(localPath);
|
||||
}
|
||||
|
||||
// 2. Se não existir localmente, tenta obter do Wasabi
|
||||
if (s3 && currentBucket) {
|
||||
try {
|
||||
let cName = clientName;
|
||||
let pName = patientName;
|
||||
|
||||
// Se não foram passados, busca metadados no banco
|
||||
if (!cName || !pName) {
|
||||
try {
|
||||
const db = require('./database');
|
||||
const row = await db.get('SELECT client_name, patient_name FROM images WHERE filename = ? OR thumb_filename = ? 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}`;
|
||||
|
||||
console.log(`☁️ [Wasabi] Baixando do S3: ${key}`);
|
||||
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const response = await s3.send(new GetObjectCommand({
|
||||
Bucket: currentBucket,
|
||||
Key: key
|
||||
}));
|
||||
|
||||
if (response.Body) {
|
||||
const bytes = await response.Body.transformToByteArray();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// TODO(security): Não salvamos no disco rígido local da VPS em cache para preservar privacidade e espaço.
|
||||
// Apenas retornamos o buffer lido na memória diretamente para o servidor.
|
||||
console.log(`☁️ [Wasabi] Servindo imagem diretamente do S3 sem gravar em cache local`);
|
||||
return buffer;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [Wasabi] Falha ao recuperar do S3 para ${filename}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Exclui uma imagem (do disco local e do Wasabi S3)
|
||||
async function deleteImage(filename, clientName, patientName) {
|
||||
// 1. Excluir localmente da pasta de uploads (imagens originais) e processed (imagens rotacionadas)
|
||||
const localPaths = [
|
||||
path.join(UPLOAD_DIR, filename),
|
||||
path.join(PROCESSED_DIR, filename)
|
||||
];
|
||||
|
||||
for (const localPath of localPaths) {
|
||||
try {
|
||||
// Usando fsSync.existsSync para checagem síncrona segura
|
||||
if (fsSync.existsSync(localPath)) {
|
||||
await fs.unlink(localPath);
|
||||
console.log(`🗑️ [Storage] Arquivo local excluído: ${localPath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ [Storage] Falha ao excluir arquivo local ${localPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Excluir do Wasabi S3 se ativo
|
||||
if (s3 && currentBucket) {
|
||||
try {
|
||||
const cleanClient = sanitizePathSegment(clientName);
|
||||
const cleanPatient = sanitizePathSegment(patientName);
|
||||
const key = `${cleanClient}/${cleanPatient}/${filename}`;
|
||||
|
||||
const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||
await s3.send(new DeleteObjectCommand({
|
||||
Bucket: currentBucket,
|
||||
Key: key
|
||||
}));
|
||||
console.log(`🗑️ [Wasabi] Objeto excluído do S3: ${key}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ [Wasabi] Falha ao excluir objeto ${filename} do S3:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveImage,
|
||||
getImageBuffer,
|
||||
deleteImage,
|
||||
getSafeConfig,
|
||||
updateConfig,
|
||||
loadConfigFromDb,
|
||||
isWasabiEnabled: () => !!(s3 && currentBucket)
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
const db = require('./database');
|
||||
db.initDatabase().then(async () => {
|
||||
const row = await db.get('SELECT id, filename, thumb_filename, client_name, patient_name FROM images WHERE id = 533');
|
||||
console.log('Result:', row);
|
||||
process.exit(0);
|
||||
});
|
||||
+3
-3
@@ -8,15 +8,15 @@ services:
|
||||
container_name: rx_scoreodonto_app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:3000"
|
||||
- "8083:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- DB_TYPE=postgres
|
||||
- DB_HOST=10.99.0.3
|
||||
- DB_PORT=5432
|
||||
- DB_USER=postgres
|
||||
- DB_PASSWORD=sua_senha_segura # Altere no .env ou arquivo local se necessário
|
||||
- DB_USER=clube67
|
||||
- DB_PASSWORD=clube67_db_pass_9903
|
||||
- DB_NAME=dental_images
|
||||
- UPLOAD_DIR=/app/uploads
|
||||
- PROCESSED_DIR=/app/processed
|
||||
|
||||
Reference in New Issue
Block a user