Files
rx.scoreodonto.com/dental-server/image-processor.js
T
VPS 4 Deploy Agent 31022c6a6f feat(rx): multi-tenant por clinica_id + página /clients de clientes ativos
- clinica_id (tenant do scoreodonto, clinicas.id) como eixo de isolamento em
  clinics_devices e images (+índices); clinic_name vira rótulo legível.
- Cadastro de device, socket (machine_token) e uploads (direct + legado) gravam clinica_id.
- Listagens (/images, /patients, /by-patient, /unique-clinics) filtram por clinica_id.
- /clients: lista clientes Windows ativos (inclui autenticados sem client-identify),
  tempo real + polling, exibe a clínica.
- send-image marcado como legado (client real usa Direct Upload).

Inclui trabalho de dev acumulado no working tree (não havia commits desde origin/main).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 16:17:09 +02:00

214 lines
6.0 KiB
JavaScript

const sharp = require('sharp');
const fs = require('fs').promises;
const path = require('path');
class ImageProcessor {
constructor() {
this.supportedFormats = ['png', 'jpg', 'jpeg'];
this.maxImageSize = 10 * 1024 * 1024; // 10MB
}
// ================================================================
// MÉTODOS EXISTENTES (se já existirem no servidor)
// ================================================================
async process(imageBuffer, options = {}) {
let pipeline = sharp(imageBuffer);
// Ajustar brilho
if (options.brightness !== undefined) {
pipeline = pipeline.modulate({
brightness: options.brightness
});
}
// Ajustar contraste
if (options.contrast !== undefined) {
pipeline = pipeline.modulate({
brightness: options.contrast
});
}
// Ajustar saturação
if (options.saturation !== undefined) {
pipeline = pipeline.modulate({
saturation: options.saturation
});
}
return await pipeline.png().toBuffer();
}
// ================================================================
// NOVOS MÉTODOS - ROTAÇÃO E FLIP
// ================================================================
async rotate(imageBuffer, degrees) {
try {
if (degrees === 0) return imageBuffer;
const sharp = require('sharp');
return await sharp(imageBuffer)
.rotate(degrees)
.png()
.toBuffer();
} catch (error) {
console.error('Erro na rotação:', error);
throw error;
}
}
async flip(imageBuffer, horizontal, vertical) {
try {
const sharp = require('sharp');
let pipeline = sharp(imageBuffer);
if (horizontal) {
pipeline = pipeline.flop();
}
if (vertical) {
pipeline = pipeline.flip();
}
return await pipeline.png().toBuffer();
} catch (error) {
console.error('Erro no flip:', error);
throw error;
}
}
async rotateAndFlip(imageBuffer, options = {}) {
try {
let pipeline = sharp(imageBuffer);
if (options.rotation && options.rotation !== 0) {
pipeline = pipeline.rotate(options.rotation);
}
if (options.flipH) {
pipeline = pipeline.flop();
}
if (options.flipV) {
pipeline = pipeline.flip();
}
return await pipeline.png().toBuffer();
} catch (error) {
console.error('Erro ao rotacionar e flipar:', error);
throw error;
}
}
// ================================================================
// MÉTODOS DE UTILIDADE
// ================================================================
async getThumbnail(imageBuffer, width = 500, height = 500) {
try {
const sharp = require('sharp');
return await sharp(imageBuffer)
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true
})
.jpeg({ quality: 80, force: true })
.toBuffer();
} catch (error) {
console.error('Erro ao gerar thumbnail:', error);
throw error;
}
}
async getBase64Preview(imageBuffer, maxSize = 300) {
try {
const thumbnail = await this.getThumbnail(imageBuffer, maxSize, maxSize);
return `data:image/png;base64,${thumbnail.toString('base64')}`;
} catch (error) {
console.error('Erro ao gerar preview base64:', error);
throw error;
}
}
// Verifica magic bytes do buffer antes de invocar sharp — rejeita executáveis e arquivos arbitrários
static checkMagicBytes(buffer) {
if (!buffer || buffer.length < 4) return false;
// JPEG: FF D8 FF
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) return true;
// PNG: 89 50 4E 47
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) return true;
// GIF: GIF8
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) return true;
// WebP: RIFF....WEBP
if (buffer.length >= 12 &&
buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) return true;
// BMP: BM
if (buffer[0] === 0x42 && buffer[1] === 0x4D) return true;
return false;
}
async validateImage(imageBuffer) {
try {
// Verifica magic bytes antes de acionar o sharp — bloqueia não-imagens na entrada
if (!ImageProcessor.checkMagicBytes(imageBuffer)) {
throw new Error('Arquivo rejeitado: assinatura de bytes inválida (não é uma imagem)');
}
const metadata = await sharp(imageBuffer).metadata();
if (!metadata.format || !this.supportedFormats.includes(metadata.format.toLowerCase())) {
throw new Error(`Formato não suportado: ${metadata.format}`);
}
if (imageBuffer.length > this.maxImageSize) {
throw new Error(`Imagem muito grande: ${(imageBuffer.length / 1024 / 1024).toFixed(2)}MB`);
}
return true;
} catch (error) {
console.error('Erro na validação:', error);
throw error;
}
}
async resize(imageBuffer, width, height, options = {}) {
try {
const sharp = require('sharp');
return await sharp(imageBuffer)
.resize(width, height, {
fit: options.fit || 'inside',
withoutEnlargement: true
})
.png()
.toBuffer();
} catch (error) {
console.error('Erro no resize:', error);
throw error;
}
}
async getImageMetadata(imageBuffer) {
try {
const sharp = require('sharp');
const metadata = await sharp(imageBuffer).metadata();
return {
format: metadata.format,
width: metadata.width,
height: metadata.height,
size: imageBuffer.length,
channels: metadata.channels,
hasAlpha: metadata.hasAlpha
};
} catch (error) {
console.error('Erro ao ler metadata:', error);
throw error;
}
}
}
// Exportar instância singleton
module.exports = new ImageProcessor();