253 lines
8.3 KiB
JavaScript
253 lines
8.3 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|