alterações de melhorias
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Serviço de manipulação de mídia.
|
||||
*
|
||||
* Centraliza compressão de vídeo, conversão de áudio, validação de headers
|
||||
* e descriptografia de mídia do WhatsApp. Antes espalhado entre
|
||||
* chatController e evolutionController.
|
||||
*/
|
||||
|
||||
const { execFile } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const ffmpegPath = require('ffmpeg-static');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const UPLOAD_DIR = path.join(__dirname, '../../uploads/audio');
|
||||
|
||||
// Garante que o diretório de uploads existe
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
// ============================================================
|
||||
// Compressão de Vídeo
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Comprime vídeo para tamanho aceitável pelo WhatsApp/Evolution API.
|
||||
* Reduz bitrate e resolução se necessário (alvo: ~10MB).
|
||||
*
|
||||
* @param {Buffer} inputBuffer - Buffer do vídeo original
|
||||
* @returns {Promise<Buffer>} Buffer do vídeo comprimido
|
||||
*/
|
||||
function compressVideo(inputBuffer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const inputPath = path.join(UPLOAD_DIR, 'video_in_' + Date.now() + '.mp4');
|
||||
const outputPath = path.join(UPLOAD_DIR, 'video_out_' + Date.now() + '.mp4');
|
||||
|
||||
fs.writeFileSync(inputPath, inputBuffer);
|
||||
const inputSizeMB = (inputBuffer.length / (1024 * 1024)).toFixed(1);
|
||||
|
||||
const targetBitrate = inputBuffer.length < 10 * 1024 * 1024 ? '1M' : '500k';
|
||||
console.log('[Video] Comprimindo de ' + inputSizeMB + 'MB, bitrate alvo: ' + targetBitrate);
|
||||
|
||||
execFile(ffmpegPath, [
|
||||
'-y',
|
||||
'-i', inputPath,
|
||||
'-c:v', 'libx264',
|
||||
'-b:v', targetBitrate,
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '64k',
|
||||
'-vf', 'scale=720:-2',
|
||||
'-movflags', '+faststart',
|
||||
'-preset', 'fast',
|
||||
'-maxrate', '1M',
|
||||
'-bufsize', '2M',
|
||||
outputPath
|
||||
], { timeout: 120000 }, (err) => {
|
||||
try { fs.unlinkSync(inputPath); } catch (_) {}
|
||||
|
||||
if (err) {
|
||||
try { fs.unlinkSync(outputPath); } catch (_) {}
|
||||
console.error('[Video] Erro na compressão:', err.message.substring(0, 100));
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const outputBuffer = fs.readFileSync(outputPath);
|
||||
try { fs.unlinkSync(outputPath); } catch (_) {}
|
||||
|
||||
const outputSizeMB = (outputBuffer.length / (1024 * 1024)).toFixed(1);
|
||||
console.log('[Video] Compressão concluída: ' + inputSizeMB + 'MB -> ' + outputSizeMB + 'MB');
|
||||
resolve(outputBuffer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Conversão de Áudio
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Converte buffer de áudio WebM para Ogg Opus usando ffmpeg.
|
||||
*
|
||||
* @param {Buffer} inputBuffer - Buffer do áudio WebM
|
||||
* @returns {Promise<Buffer>} Buffer Ogg Opus
|
||||
*/
|
||||
function convertWebmToOgg(inputBuffer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const inputPath = path.join(UPLOAD_DIR, 'input_' + Date.now() + '.webm');
|
||||
const outputPath = path.join(UPLOAD_DIR, 'output_' + Date.now() + '.ogg');
|
||||
|
||||
fs.writeFileSync(inputPath, inputBuffer);
|
||||
|
||||
execFile(ffmpegPath, [
|
||||
'-y',
|
||||
'-i', inputPath,
|
||||
'-c:a', 'libopus',
|
||||
'-b:a', '16k',
|
||||
'-ar', '16000',
|
||||
'-ac', '1',
|
||||
outputPath
|
||||
], { timeout: 30000 }, (err) => {
|
||||
try { fs.unlinkSync(inputPath); } catch (_) {}
|
||||
|
||||
if (err) {
|
||||
try { fs.unlinkSync(outputPath); } catch (_) {}
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const outputBuffer = fs.readFileSync(outputPath);
|
||||
try { fs.unlinkSync(outputPath); } catch (_) {}
|
||||
resolve(outputBuffer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Validação de Headers de Mídia
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Valida se um buffer tem cabeçalho de arquivo de mídia válido.
|
||||
* Defesa: rejeita JSON, HTML ou dados criptografados salvos por engano.
|
||||
*
|
||||
* @param {Buffer} buf
|
||||
* @param {string} [mimeType] - MIME type opcional para validação extra
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function validarHeaderMidia(buf, mimeType) {
|
||||
if (!buf || buf.length < 4) return false;
|
||||
|
||||
const headerHex = buf.slice(0, 4).toString('hex');
|
||||
|
||||
// Headers de áudio conhecidos
|
||||
const audioHeaders = ['4f676753', // OggS (ogg/opus)
|
||||
'52494646', // RIFF (wav)
|
||||
'66747970', // ftyp (mp4/m4a)
|
||||
'494433', // ID3 (mp3)
|
||||
'1a45dfa3', // WebM/Matroska EBML
|
||||
'fffb', 'fff3', 'fffa', 'fff2']; // MP3 frames
|
||||
for (const vh of audioHeaders) {
|
||||
if (headerHex.startsWith(vh)) return true;
|
||||
}
|
||||
|
||||
// Imagens
|
||||
if (mimeType && mimeType.startsWith('image/')) {
|
||||
if (headerHex.startsWith('ffd8ff') || // JPEG
|
||||
headerHex.startsWith('89504e47') || // PNG
|
||||
headerHex.startsWith('47494638')) // GIF
|
||||
return true;
|
||||
}
|
||||
|
||||
// Documentos (ZIP/Office)
|
||||
if (mimeType && mimeType.startsWith('application/')) {
|
||||
if (headerHex.startsWith('504b')) return true; // PK (ZIP)
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Descriptografia de Mídia WhatsApp
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Descriptografa mídia do WhatsApp usando a mediaKey do webhook.
|
||||
* Algoritmo: AES-256-CBC + HMAC-SHA256 (padrão Signal/WhatsApp).
|
||||
*
|
||||
* @param {Buffer} encryptedBuffer - Dados criptografados (inclui 10 bytes HMAC no final)
|
||||
* @param {string} mediaKeyBase64 - Chave em base64 (do webhook)
|
||||
* @param {string} mediaType - 'audio', 'image', 'video', 'document'
|
||||
* @returns {Buffer|null} Dados descriptografados ou null se falhar
|
||||
*/
|
||||
function decryptWhatsAppMedia(encryptedBuffer, mediaKeyBase64, mediaType) {
|
||||
try {
|
||||
const mediaKey = Buffer.from(mediaKeyBase64, 'base64');
|
||||
if (mediaKey.length !== 32) {
|
||||
console.log('[Decrypt] mediaKey tamanho inválido:', mediaKey.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tipo -> info string para HKDF
|
||||
const infoMap = {
|
||||
'image': 'WhatsApp Image Keys',
|
||||
'video': 'WhatsApp Video Keys',
|
||||
'audio': 'WhatsApp Audio Keys',
|
||||
'document': 'WhatsApp Document Keys'
|
||||
};
|
||||
const info = Buffer.from(infoMap[mediaType] || 'WhatsApp Audio Keys');
|
||||
|
||||
// Passo 1: HKDF Extract
|
||||
const salt = Buffer.alloc(32, 0);
|
||||
const prk = crypto.createHmac('sha256', salt).update(mediaKey).digest();
|
||||
|
||||
// Passo 2: HKDF Expand para 112 bytes
|
||||
const hashLen = 32;
|
||||
const numBlocks = Math.ceil(112 / hashLen);
|
||||
const blocks = [];
|
||||
let prev = Buffer.alloc(0);
|
||||
for (let i = 0; i < numBlocks; i++) {
|
||||
const hmac = crypto.createHmac('sha256', prk);
|
||||
hmac.update(prev);
|
||||
hmac.update(info);
|
||||
hmac.update(Buffer.from([i + 1]));
|
||||
prev = hmac.digest();
|
||||
blocks.push(prev);
|
||||
}
|
||||
const expanded = Buffer.concat(blocks).slice(0, 112);
|
||||
|
||||
if (!expanded || expanded.length < 80) {
|
||||
console.log('[Decrypt] HKDF produziu buffer muito curto:', expanded ? expanded.length : 0);
|
||||
return null;
|
||||
}
|
||||
|
||||
const cipherKey = expanded.slice(0, 32);
|
||||
const macKey = expanded.slice(32, 64);
|
||||
const iv = expanded.slice(64, 80);
|
||||
|
||||
// Passo 3: Arquivo = dados criptografados (n-10 bytes) + HMAC (últimos 10 bytes)
|
||||
if (encryptedBuffer.length < 10) {
|
||||
console.log('[Decrypt] Buffer muito curto:', encryptedBuffer.length);
|
||||
return null;
|
||||
}
|
||||
const fileData = encryptedBuffer.slice(0, -10);
|
||||
const expectedMac = encryptedBuffer.slice(-10);
|
||||
|
||||
// Passo 4: Verifica HMAC
|
||||
const hmacVerify = crypto.createHmac('sha256', macKey);
|
||||
hmacVerify.update(fileData);
|
||||
const computedMac = hmacVerify.digest().slice(0, 10);
|
||||
|
||||
if (Buffer.compare(computedMac, expectedMac) !== 0) {
|
||||
console.log('[Decrypt] HMAC inválido - dados corrompidos, chave errada, ou mídia expirada');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Passo 5: Descriptografa AES-256-CBC
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', cipherKey, iv);
|
||||
decipher.setAutoPadding(true);
|
||||
const decrypted = Buffer.concat([decipher.update(fileData), decipher.final()]);
|
||||
|
||||
console.log('[Decrypt] Sucesso!', encryptedBuffer.length, 'bytes ->', decrypted.length, 'bytes');
|
||||
return decrypted;
|
||||
} catch (e) {
|
||||
console.log('[Decrypt] Erro:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compressVideo,
|
||||
convertWebmToOgg,
|
||||
validarHeaderMidia,
|
||||
decryptWhatsAppMedia,
|
||||
};
|
||||
Reference in New Issue
Block a user