feat: adicionar thumbnails, ajuste fino flutuante e correções no dental-client

This commit is contained in:
VPS 4 Builder
2026-05-24 23:47:54 +02:00
parent c38184653a
commit 5d47a8ebb5
29 changed files with 9579 additions and 101 deletions
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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"
}
+2628
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
node_modules
npm-debug.log
.env
dental_images.db
uploads
processed
+89
View File
@@ -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);
});
+55 -2
View File
@@ -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(` sqliteDb.exec(`
CREATE TABLE IF NOT EXISTS images ( CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -63,6 +71,7 @@ async function initSQLite() {
flip_vertical INTEGER DEFAULT 0, flip_vertical INTEGER DEFAULT 0,
doctor TEXT, doctor TEXT,
remark TEXT, remark TEXT,
thumb_filename TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
`); `);
@@ -95,6 +104,9 @@ async function initSQLite() {
try { try {
sqliteDb.exec("ALTER TABLE images ADD COLUMN remark TEXT"); sqliteDb.exec("ALTER TABLE images ADD COLUMN remark TEXT");
} catch (e) {} } catch (e) {}
try {
sqliteDb.exec("ALTER TABLE images ADD COLUMN thumb_filename TEXT");
} catch (e) {}
console.log('✅ Tabelas SQLite users e images criadas/verificadas'); console.log('✅ Tabelas SQLite users e images criadas/verificadas');
@@ -187,6 +199,15 @@ async function createTables() {
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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'); console.log('✅ Tabela users criada/verificada');
// Tabela de imagens // Tabela de imagens
@@ -208,6 +229,7 @@ async function createTables() {
flip_vertical SMALLINT DEFAULT 0, flip_vertical SMALLINT DEFAULT 0,
doctor VARCHAR(255) NULL, doctor VARCHAR(255) NULL,
remark TEXT NULL, remark TEXT NULL,
thumb_filename VARCHAR(500) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
`); `);
@@ -262,6 +284,35 @@ async function createTables() {
await client.query(`ALTER TABLE images ADD COLUMN remark TEXT`); await client.query(`ALTER TABLE images ADD COLUMN remark TEXT`);
} catch (e) { /* já existe */ } } 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) { } catch (error) {
console.error('Erro ao criar tabelas:', error); console.error('Erro ao criar tabelas:', error);
} finally { } finally {
@@ -341,8 +392,10 @@ function run(query, params = []) {
try { try {
let pgQuery = convertSqlForPg(query); let pgQuery = convertSqlForPg(query);
// Auto-append RETURNING id for INSERT if not present // Auto-append RETURNING id for INSERT if not present (except for settings table)
if (pgQuery.trim().toUpperCase().startsWith('INSERT') && !pgQuery.toUpperCase().includes('RETURNING ID')) { if (pgQuery.trim().toUpperCase().startsWith('INSERT') &&
!pgQuery.toUpperCase().includes('RETURNING') &&
!pgQuery.toUpperCase().includes('SETTINGS')) {
pgQuery += ' RETURNING id'; pgQuery += ' RETURNING id';
} }
+3 -1
View File
@@ -25,7 +25,9 @@
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"cookie-parser": "^1.4.6", "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": { "engines": {
"node": ">=16.0.0", "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
View File
@@ -98,6 +98,7 @@ let clientsList = [];
let selectedClient = ''; let selectedClient = '';
let showDisabled = false; let showDisabled = false;
let currentTransformations = []; let currentTransformations = [];
let fineTuneAngle = 0;
// ================================================================ // ================================================================
// DOM REFS // DOM REFS
@@ -149,6 +150,7 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadClientsList(); await loadClientsList();
loadPatients(); loadPatients();
setupEventListeners(); setupEventListeners();
initDragAndDrop();
setInterval(loadClientsList, 5000); setInterval(loadClientsList, 5000);
} catch (error) { } catch (error) {
_log.error('INIT', 'Erro fatal na inicialização:', error.message, error); _log.error('INIT', 'Erro fatal na inicialização:', error.message, error);
@@ -229,8 +231,12 @@ function updateClientsDropdown() {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = c.name; opt.value = c.name;
let lbl = c.name; let lbl = c.name;
if (c.name === 'Upload Web') {
lbl = 'Upload Web 💻 (Imagens do Painel)';
} else {
if (c.status === 'identified') lbl += ' ✅'; if (c.status === 'identified') lbl += ' ✅';
else if (c.status === 'historic') lbl += ' 📚'; else if (c.status === 'historic') lbl += ' 📚';
}
opt.textContent = lbl; opt.textContent = lbl;
clientFilter.appendChild(opt); clientFilter.appendChild(opt);
}); });
@@ -341,6 +347,7 @@ function renderPatientCards() {
return ` return `
<div class="image-card patient-card" onclick="openPatientByIndex(${index})"> <div class="image-card patient-card" onclick="openPatientByIndex(${index})">
<div class="image-preview" style="${thumb ? `background-image: url('${thumb}'); background-size: cover; background-position: center;` : 'background: linear-gradient(135deg,#667eea22,#764ba222);'}"> <div class="image-preview" 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 class="patient-count-badge">${count} imagem${count !== 1 ? 'ns' : ''}</div>
</div> </div>
<div class="image-info"> <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 // VIEW: PATIENT IMAGES
// ================================================================ // ================================================================
@@ -435,9 +474,11 @@ async function loadPatientImages(patientName) {
} }
function renderPatientImages() { 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-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-info">
<div class="image-meta"><span>📅 ${formatDate(image.created_at)}</span></div> <div class="image-meta"><span>📅 ${formatDate(image.created_at)}</span></div>
<div class="image-guid">${escapeHtml(image.image_guid || image.filename)}</div> <div class="image-guid">${escapeHtml(image.image_guid || image.filename)}</div>
@@ -451,8 +492,8 @@ function renderPatientImages() {
</div> </div>
</div> </div>
${!image.enabled ? '<div class="image-badge">Desabilitada</div>' : ''} ${!image.enabled ? '<div class="image-badge">Desabilitada</div>' : ''}
</div> </div>`;
`).join(''); }).join('');
} }
// ================================================================ // ================================================================
@@ -506,19 +547,30 @@ async function showTransformModal(imageId) {
</div>`; </div>`;
try { try {
const res = await fetchWithAuth(`/api/images/${imageId}/transformations`); const image = patientImages.find(img => img.id === imageId);
if (!res.ok) { if (!image) {
if (res.status === 401 || res.status === 403) { localStorage.removeItem('auth_token'); window.location.href = '/login'; return; } throw new Error('Imagem não encontrada localmente');
throw new Error('Erro ao gerar transformações');
} }
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; currentTransformations = transformations;
const container = document.getElementById('transformOptions'); const container = document.getElementById('transformOptions');
container.innerHTML = transformations.map((t, i) => ` container.innerHTML = transformations.map((t, i) => `
<div class="transform-option" data-index="${i}" onclick="selectTransform(${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 class="transform-name">${escapeHtml(t.name)}</div>
</div> </div>
`).join(''); `).join('');
@@ -526,7 +578,6 @@ async function showTransformModal(imageId) {
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Detectar orientação da imagem original → aplicar layout correto // Detectar orientação da imagem original → aplicar layout correto
// --------------------------------------------------------------- // ---------------------------------------------------------------
if (transformations.length > 0 && transformations[0].base64) {
const probe = new Image(); const probe = new Image();
probe.onload = () => { probe.onload = () => {
const orientation = probe.naturalWidth >= probe.naturalHeight ? 'landscape' : 'portrait'; const orientation = probe.naturalWidth >= probe.naturalHeight ? 'landscape' : 'portrait';
@@ -537,12 +588,11 @@ async function showTransformModal(imageId) {
probe.onerror = () => { probe.onerror = () => {
_log.warn('TRANSFORM', 'Não foi possível detectar orientação — usando fallback'); _log.warn('TRANSFORM', 'Não foi possível detectar orientação — usando fallback');
}; };
probe.src = transformations[0].base64; probe.src = imageUrl;
}
} catch (e) { } catch (e) {
_log.error('TRANSFORM', '❌ Exceção:', e.message, 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'); transformModal.classList.remove('active');
} }
} }
@@ -571,6 +621,17 @@ function selectTransform(index) {
el.classList.add('selected'); 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 // Show action area
document.getElementById('transformActionArea').style.display = 'block'; document.getElementById('transformActionArea').style.display = 'block';
@@ -585,11 +646,38 @@ function clearTransformSelection() {
const container = document.getElementById('transformOptions'); const container = document.getElementById('transformOptions');
container.classList.remove('selection-mode'); 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 => { document.querySelectorAll('.transform-option').forEach(option => {
option.classList.remove('selected'); option.classList.remove('selected');
option.style.display = 'block'; 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('transformActionArea').style.display = 'none';
document.getElementById('newImageRemark').value = ''; document.getElementById('newImageRemark').value = '';
} }
@@ -612,7 +700,7 @@ async function saveSelectedTransform() {
try { try {
const payload = { const payload = {
rotation: transform.rotation, rotation: transform.rotation + fineTuneAngle,
flipH: transform.flipH, flipH: transform.flipH,
flipV: transform.flipV, flipV: transform.flipV,
remark: remark remark: remark
@@ -621,7 +709,7 @@ async function saveSelectedTransform() {
const res = await fetchWithAuth(`/api/images/${selectedImageId}/transform`, { const res = await fetchWithAuth(`/api/images/${selectedImageId}/transform`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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'); 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) { async function toggleImageEnabled(imageId) {
try { try {
const res = await fetchWithAuth(`/api/images/${imageId}/toggle-enabled`, { method: 'PUT' }); const res = await fetchWithAuth(`/api/images/${imageId}/toggle-enabled`, { method: 'PUT' });
@@ -1044,4 +1175,600 @@ async function updateCredentials(event) {
window.openSettingsModal = openSettingsModal; window.openSettingsModal = openSettingsModal;
window.closeSettingsModal = closeSettingsModal; window.closeSettingsModal = closeSettingsModal;
window.updateCredentials = updateCredentials; 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 = '&times;';
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 // A aplicação é inicializada no evento DOMContentLoaded acima
+180 -1
View File
@@ -28,6 +28,12 @@
<a href="#" class="nav-item" onclick="openSettingsModal(); return false;"> <a href="#" class="nav-item" onclick="openSettingsModal(); return false;">
<span class="icon">⚙️</span> Configurações <span class="icon">⚙️</span> Configurações
</a> </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;"> <a href="/reset" class="nav-item" style="color: #dc3545;">
<span class="icon">⚠️</span> Zerar Sistema <span class="icon">⚠️</span> Zerar Sistema
</a> </a>
@@ -122,7 +128,7 @@
<!-- View 1: Transforms --> <!-- View 1: Transforms -->
<div id="transformViewWrapper" style="display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%;"> <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="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> <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> <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;"> <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> <button id="btnSaveTransform" class="btn btn-primary" style="flex: 2; padding: 12px; font-size: 1.1rem;" onclick="saveSelectedTransform()">Salvar Nova Imagem</button>
</div> </div>
</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"></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> </div>
<!-- View 2: GTO List & Link --> <!-- View 2: GTO List & Link -->
@@ -270,6 +286,169 @@
</div> </div>
</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()">&times;</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()">&times;</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 --> <!-- Application Script -->
<script src="/socket.io/socket.io.js"></script> <script src="/socket.io/socket.io.js"></script>
<script src="/app.js"></script> <script src="/app.js"></script>
+318 -8
View File
@@ -754,10 +754,9 @@ body {
grid-column: auto !important; grid-column: auto !important;
grid-row: auto !important; grid-row: auto !important;
} }
.selection-mode .transform-option img { .selection-mode .transform-image-wrapper {
height: 35vh !important; height: 45vh !important;
max-height: 400px; max-height: 500px;
object-fit: contain;
} }
/* -90°: col 4, linha 2 (empilhado sob +90°) */ /* -90°: col 4, linha 2 (empilhado sob +90°) */
.transform-grid.grid-portrait .transform-option:nth-child(5) { grid-column: 4; grid-row: 2; } .transform-grid.grid-portrait .transform-option:nth-child(5) { grid-column: 4; grid-row: 2; }
@@ -786,6 +785,7 @@ body {
align-items: center; align-items: center;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
position: relative; /* Para posicionar o ajuste fino absoluto */
} }
.transform-option:hover { .transform-option:hover {
@@ -798,13 +798,28 @@ body {
background: rgba(81, 207, 102, 0.08); 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%; max-width: 100%;
flex: 1; max-height: 100%;
min-height: 0;
object-fit: contain; object-fit: contain;
border-radius: 4px; border-radius: 4px;
margin-bottom: 10px; transition: transform 0.15s ease-out; /* Transição para ajuste fino suave */
} }
.transform-name { .transform-name {
@@ -814,6 +829,64 @@ body {
flex-shrink: 0; 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 { font-size: 0; padding: 10px; }
#toggleDisabledBtn::before { content: '👁'; font-size: 1.1rem; } #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
View File
@@ -4,6 +4,8 @@ const db = require('../database');
const imageProcessor = require('../image-processor'); const imageProcessor = require('../image-processor');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const storage = require('../storage');
const sharp = require('sharp');
// ================================================================ // ================================================================
// GET /api/images - Listar imagens // GET /api/images - Listar imagens
@@ -64,12 +66,12 @@ router.get('/patients', async (req, res) => {
const rows = await db.all( const rows = await db.all(
`SELECT `SELECT
patient_name, patient_name,
doctor, MAX(doctor) AS doctor,
remark, MAX(remark) AS remark,
client_name, MAX(client_name) AS client_name,
COUNT(*) AS image_count, COUNT(*) AS image_count,
MAX(created_at) AS last_date, 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} WHERE i2.patient_name = images.patient_name AND i2.enabled = ${enabledVal}
ORDER BY i2.created_at DESC LIMIT 1) AS thumb_filename ORDER BY i2.created_at DESC LIMIT 1) AS thumb_filename
FROM images 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 // 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 originalId = image.original_image_id || image.id;
const originalImage = await db.get('SELECT * FROM images WHERE id = ?', [originalId]); const originalImage = await db.get('SELECT * FROM images WHERE id = ?', [originalId]);
const originalPath = path.join(__dirname, '../uploads', originalImage.filename); let originalBuffer = await storage.getImageBuffer(originalImage.filename, false);
let originalBuffer; if (!originalBuffer) {
originalBuffer = await storage.getImageBuffer(originalImage.filename, true);
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;
} }
if (!originalBuffer) {
return res.status(404).json({ error: 'Arquivo de imagem original não encontrado' });
} }
const transformations = [ const transformations = [
@@ -228,17 +325,13 @@ router.post('/:id/transform', async (req, res) => {
const realOriginal = await db.get('SELECT * FROM images WHERE id = ?', [realOriginalId]); const realOriginal = await db.get('SELECT * FROM images WHERE id = ?', [realOriginalId]);
// Processar a imagem // Processar a imagem
const originalPath = path.join(__dirname, '../uploads', realOriginal.filename); let originalBuffer = await storage.getImageBuffer(realOriginal.filename, false);
let originalBuffer; if (!originalBuffer) {
try { originalBuffer = await storage.getImageBuffer(realOriginal.filename, true);
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;
} }
if (!originalBuffer) {
return res.status(404).json({ error: 'Arquivo de imagem original não encontrado' });
} }
let processed = await imageProcessor.rotate(originalBuffer, rotation); let processed = await imageProcessor.rotate(originalBuffer, rotation);
@@ -247,8 +340,7 @@ router.post('/:id/transform', async (req, res) => {
// Salvar nova versão em processed // Salvar nova versão em processed
const timestamp = Date.now(); const timestamp = Date.now();
const processedFilename = `${realOriginal.image_guid}_${timestamp}.png`; const processedFilename = `${realOriginal.image_guid}_${timestamp}.png`;
const processedPath = path.join(__dirname, '../processed', processedFilename); await storage.saveImage(processedFilename, processed, realOriginal.client_name, realOriginal.patient_name, true);
await fs.writeFile(processedPath, processed);
// Desabilitar a imagem atual para que não fique duplicada na interface // 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]); 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 // DELETE /api/images/:id - Apagar versão da imagem
// ================================================================ // ================================================================
@@ -357,13 +480,8 @@ router.delete('/:id', async (req, res) => {
}); });
} }
// Apagar arquivo // Apagar arquivo do storage (Wasabi S3 + Local)
const filePath = path.join(__dirname, '../processed', image.filename); await storage.deleteImage(image.filename, image.client_name, image.patient_name);
try {
await fs.unlink(filePath);
} catch (e) {
console.warn('Arquivo não encontrado para deletar:', filePath);
}
// Apagar do banco // Apagar do banco
await db.run('DELETE FROM images WHERE id = ?', [req.params.id]); await db.run('DELETE FROM images WHERE id = ?', [req.params.id]);
+25
View File
@@ -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; module.exports = router;
+103 -13
View File
@@ -5,6 +5,7 @@ const fs = require('fs').promises;
const path = require('path'); const path = require('path');
const cors = require('cors'); const cors = require('cors');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const sharp = require('sharp');
require('dotenv').config(); require('dotenv').config();
// Importar módulos // Importar módulos
@@ -12,6 +13,7 @@ const imageProcessor = require('./image-processor');
const { authenticateToken, hashPassword, JWT_SECRET } = require('./auth'); const { authenticateToken, hashPassword, JWT_SECRET } = require('./auth');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const db = require('./database'); const db = require('./database');
const storage = require('./storage');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const imageRoutes = require('./routes/images'); const imageRoutes = require('./routes/images');
const patientsRouter = require('./routes/patients'); const patientsRouter = require('./routes/patients');
@@ -401,8 +403,44 @@ app.use('/login', express.static('auth'));
// Serve static files // Serve static files
app.use(express.static('public')); 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) // 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', authenticateToken);
app.use('/api/gtos', gtosRouter); app.use('/api/gtos', gtosRouter);
// APIs do Sistema - requerem autenticação
app.use('/api/system', authenticateToken); app.use('/api/system', authenticateToken);
app.use('/api/system', systemRoutes); 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 // SOCKET.IO - CONEXÕES EM TEMPO REAL
// ================================================================ // ================================================================
@@ -613,16 +670,37 @@ io.on('connection', (socket) => {
// Validar imagem // Validar imagem
await imageProcessor.validateImage(imageBuffer); 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 // Salvar imagem original
const timestamp = Date.now(); 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); const filePath = path.join(UPLOAD_DIR, filename);
await fs.writeFile(filePath, imageBuffer);
// Buscar nome do cliente se identificado // Buscar nome do cliente se identificado
const clientInfo = connectedClients.find(c => c.socketId === socket.id); const clientInfo = connectedClients.find(c => c.socketId === socket.id);
const clientName = clientInfo ? clientInfo.name : 'Cliente não identificado'; 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 // Determinar data de criação
const formatLocalDateTime = (date) => { const formatLocalDateTime = (date) => {
@@ -643,8 +721,9 @@ io.on('connection', (socket) => {
enabled, enabled,
doctor, doctor,
remark, remark,
thumb_filename,
created_at created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
data.metadata.guid, data.metadata.guid,
data.patientData.name || 'Paciente não identificado', data.patientData.name || 'Paciente não identificado',
@@ -654,6 +733,7 @@ io.on('connection', (socket) => {
1, 1,
data.patientData.doctor || null, data.patientData.doctor || null,
data.patientData.remark || null, data.patientData.remark || null,
thumbFilename,
createdAt createdAt
] ]
); );
@@ -685,6 +765,7 @@ io.on('connection', (socket) => {
doctor: data.patientData.doctor || null, doctor: data.patientData.doctor || null,
remark: data.patientData.remark || null, remark: data.patientData.remark || null,
filename: filename, filename: filename,
thumb_filename: thumbFilename,
created_at: createdAt created_at: createdAt
}); });
} }
@@ -812,28 +893,37 @@ async function initDirectories() {
} }
async function start() { async function start() {
const fsSync = require('fs');
try { try {
// Inicializar diretórios // Inicializar diretórios
await initDirectories(); 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 envPath = path.join(__dirname, '.env');
const fsSync = require('fs'); const fsSync = require('fs');
if (fsSync.existsSync(envPath)) { if (fsSync.existsSync(envPath)) {
try { try {
// Recarregar .env se foi atualizado
require('dotenv').config(); 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)) { if (process.env.DB_TYPE === 'sqlite' || (process.env.DB_HOST && process.env.DB_NAME)) {
await db.initDatabase(); await db.initDatabase();
console.log('✅ Banco de dados inicializado'); console.log('✅ Banco de dados inicializado a partir do .env');
await storage.loadConfigFromDb();
} }
} catch (error) { } catch (error) {
console.log('⚠️ Banco de dados não configurado. Execute a instalação em /install'); console.log('⚠️ Banco de dados não configurado. Execute a instalação em /install');
} }
} }
}
// Iniciar servidor // Iniciar servidor
server.listen(PORT, () => { server.listen(PORT, () => {
+338
View File
@@ -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)
};
+6
View File
@@ -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
View File
@@ -8,15 +8,15 @@ services:
container_name: rx_scoreodonto_app container_name: rx_scoreodonto_app
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:3000" - "8083:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - PORT=3000
- DB_TYPE=postgres - DB_TYPE=postgres
- DB_HOST=10.99.0.3 - DB_HOST=10.99.0.3
- DB_PORT=5432 - DB_PORT=5432
- DB_USER=postgres - DB_USER=clube67
- DB_PASSWORD=sua_senha_segura # Altere no .env ou arquivo local se necessário - DB_PASSWORD=clube67_db_pass_9903
- DB_NAME=dental_images - DB_NAME=dental_images
- UPLOAD_DIR=/app/uploads - UPLOAD_DIR=/app/uploads
- PROCESSED_DIR=/app/processed - PROCESSED_DIR=/app/processed