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