Files
chatc2/src/services/mediaService.js
T
2026-06-24 14:13:09 +00:00

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,
};