import { Boom } from '@hapi/boom' import { createHash } from 'crypto' import { zipSync } from 'fflate' import { promises as fs } from 'fs' import { gunzipSync } from 'zlib' import { proto } from '../../WAProto/index.js' import type { MediaType } from '../Defaults/index.js' import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js' import { generateMessageIDV2 } from './generics.js' import type { ILogger } from './logger.js' import { encryptedStream, getImageProcessingLibrary } from './messages-media.js' /** * Verifica se um buffer é um arquivo WebP válido * Valida os magic bytes: RIFF....WEBP * * @param buffer - Buffer to check * @returns true if buffer is valid WebP format * * @example * ```typescript * const buffer = await readFile('image.webp') * if (isWebPBuffer(buffer)) { * console.log('Valid WebP file') * } * ``` */ export const isWebPBuffer = (buffer: Buffer): boolean => { if (buffer.length < 12) return false // Verifica magic bytes RIFF (0-3) e WEBP (8-11) const riffHeader = buffer.toString('ascii', 0, 4) const webpHeader = buffer.toString('ascii', 8, 12) return riffHeader === 'RIFF' && webpHeader === 'WEBP' } /** * Detecta se um WebP é animado através da análise de chunks * * Analisa a estrutura do arquivo WebP procurando por: * - VP8X header com animation flag (bit 1) * - Chunks ANIM (animation) ou ANMF (animation frame) * * SECURITY: Implements robust validation to prevent: * - Integer overflow attacks (malicious chunk sizes) * - Out-of-bounds reads (buffer overflow) * - Infinite loop DoS (iteration limit) * * @param buffer - WebP buffer to analyze * @returns true if WebP is animated, false if static or malformed * * @example * ```typescript * const webpBuffer = await readFile('sticker.webp') * if (isAnimatedWebP(webpBuffer)) { * console.log('Animated sticker detected') * } * ``` */ export const isAnimatedWebP = (buffer: Buffer): boolean => { if (!isWebPBuffer(buffer)) return false const MAX_CHUNK_SIZE = 100 * 1024 * 1024 // 100MB max per chunk const MAX_ITERATIONS = 1000 // Prevent infinite loop let offset = 12 // Skip RIFF header (12 bytes) let iterations = 0 while (offset < buffer.length - 8 && iterations++ < MAX_ITERATIONS) { const chunkFourCC = buffer.toString('ascii', offset, offset + 4) const chunkSize = buffer.readUInt32LE(offset + 4) // SECURITY: Validate chunk size to prevent integer overflow and buffer overflow if (chunkSize < 0 || chunkSize > MAX_CHUNK_SIZE) { // Invalid chunk size - treat as non-animated return false } // SECURITY: Verify chunk fits within buffer bounds if (offset + 8 + chunkSize > buffer.length) { // Chunk extends beyond buffer - malformed file return false } // VP8X extended header - check animation flag if (chunkFourCC === 'VP8X' && offset + 8 < buffer.length) { const flags = buffer[offset + 8] // Bit 1 (0x02) = animation flag if (flags && flags & 0x02) return true } // Animation chunks if (chunkFourCC === 'ANIM' || chunkFourCC === 'ANMF') { return true } // Move to next chunk (8 byte header + chunk size + padding) offset += 8 + chunkSize + (chunkSize % 2) } return false } /** * Detecta se um buffer é Lottie JSON (raw ou gzip-compressed/WAS) * * WABA usa mimetype `application/was` para stickers Lottie. * WAS (WhatsApp Animated Sticker) = gzip-compressed Lottie JSON. * * Detecção: * - Gzip (0x1f 0x8b): descomprime e verifica se é Lottie JSON * - JSON bruto: verifica campos Lottie obrigatórios (v, ip, op, layers) * * @param buffer - Buffer to check * @returns true if buffer is Lottie/WAS format */ export const isLottieBuffer = (buffer: Buffer): boolean => { if (buffer.length < 2) return false let jsonBuffer: Buffer // Check if gzip-compressed (WAS format) if (buffer[0] === 0x1f && buffer[1] === 0x8b) { try { jsonBuffer = gunzipSync(buffer) as Buffer } catch { return false } } else if (buffer[0] === 0x7b) { // Starts with '{' - could be raw Lottie JSON jsonBuffer = buffer } else { return false } // Validate Lottie JSON structure: must have v, ip, op, layers try { const str = jsonBuffer.toString('utf8', 0, Math.min(jsonBuffer.length, 4096)) // Quick check for required Lottie fields return str.includes('"v"') && str.includes('"layers"') && (str.includes('"ip"') || str.includes('"op"')) } catch { return false } } /** * Converte uma imagem para WebP usando Sharp * Preserva o buffer original se já for WebP para manter EXIF e animações * * @param buffer - Image buffer to convert * @param logger - Optional logger for debugging * @returns Object with WebP buffer and animation status * * @throws {Boom} If Sharp is not installed and buffer is not WebP */ const convertToWebP = async ( buffer: Buffer, logger?: ILogger ): Promise<{ webpBuffer: Buffer; isAnimated: boolean; isLottie: boolean }> => { // Lottie/WAS: passthrough sem converter (formato vetorial, mimetype=application/was) if (isLottieBuffer(buffer)) { logger?.trace('Input is Lottie/WAS format, preserving original buffer') return { webpBuffer: buffer, isAnimated: true, isLottie: true } } // Se já é WebP, preserva o buffer original (mantém EXIF e animações) if (isWebPBuffer(buffer)) { const isAnimated = isAnimatedWebP(buffer) logger?.trace({ isAnimated }, 'Input is already WebP, preserving original buffer') return { webpBuffer: buffer, isAnimated, isLottie: false } } // Tenta usar Sharp para converter const lib = await getImageProcessingLibrary() if (!lib?.sharp) { throw new Boom( 'Sharp library is required to convert non-WebP images to WebP format. Install with: yarn add sharp', { statusCode: 400 } ) } logger?.trace('Converting image to WebP using Sharp') const webpBuffer = await lib.sharp.default(buffer).webp().toBuffer() return { webpBuffer, isAnimated: false, isLottie: false } } /** * Gera hash SHA256 em formato base64 URL-safe (RFC 4648) * Usado para nomear arquivos de stickers no ZIP (auto-deduplicação) * * SECURITY: Correctly implements base64url encoding to prevent hash collisions: * - '+' → '-' * - '/' → '_' (DIFFERENT from '+' mapping) * - '=' padding removed * * @param buffer - Buffer to hash * @returns Base64 URL-safe SHA256 hash (RFC 4648 compliant) */ const generateSha256Hash = (buffer: Buffer): string => { return createHash('sha256') .update(buffer) .digest('base64') .replace(/\+/g, '-') // + becomes - .replace(/\//g, '_') // / becomes _ (CRITICAL: different from + mapping!) .replace(/=/g, '') // Remove padding } /** * Converte WAMediaUpload para Buffer com limites de segurança * Suporta Buffer, Stream, URL e Data URLs * * SECURITY: Implements protections against: * - Memory exhaustion (size limits) * - Slow read attacks (timeouts) * - Resource DoS (stream cleanup) * * @param media - Media input (Buffer, Stream, URL or Data URL) * @param context - Context for error messages (e.g., 'sticker', 'cover') * @param options - Optional size limit and timeout * @returns Buffer with media content * * @throws {Boom} If media format is invalid, too large, or timeout */ const mediaToBuffer = async ( media: WAMediaUpload, context: string, options?: { maxSize?: number; timeout?: number } ): Promise => { const MAX_SIZE = options?.maxSize || 10 * 1024 * 1024 // 10MB default const TIMEOUT = options?.timeout || 30000 // 30s default if (Buffer.isBuffer(media)) { // SECURITY: Validate buffer size if (media.length > MAX_SIZE) { throw new Boom(`${context} size (${(media.length / 1024).toFixed(2)}KB) exceeds ${MAX_SIZE / 1024}KB limit`, { statusCode: 413 }) } return media } else if (typeof media === 'object' && 'url' in media) { const url = media.url.toString() // ENHANCEMENT: Support Data URLs (data:image/...) if (url.startsWith('data:')) { try { const base64Data = url.split(',')[1] if (!base64Data) { throw new Boom(`Invalid data URL for ${context}: missing base64 data`, { statusCode: 400 }) } const buffer = Buffer.from(base64Data, 'base64') // SECURITY: Validate buffer size if (buffer.length > MAX_SIZE) { throw new Boom( `${context} data URL size (${(buffer.length / 1024).toFixed(2)}KB) exceeds ${MAX_SIZE / 1024}KB limit`, { statusCode: 413 } ) } return buffer } catch (error) { if (error instanceof Boom) throw error throw new Boom(`Failed to parse data URL for ${context}: ${(error as Error).message}`, { statusCode: 400 }) } } // HTTP/HTTPS URLs - download with size limit and timeout const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), TIMEOUT) try { const response = await fetch(url, { signal: controller.signal }) if (!response.ok) { throw new Boom(`Failed to download ${context} from URL: ${url}`, { statusCode: 400, data: { url, status: response.status } }) } // SECURITY: Check Content-Length header before downloading const contentLength = response.headers.get('content-length') if (contentLength && parseInt(contentLength) > MAX_SIZE) { throw new Boom( `${context} URL file size (${(parseInt(contentLength) / 1024).toFixed(2)}KB) exceeds ${MAX_SIZE / 1024}KB limit`, { statusCode: 413, data: { url, contentLength } } ) } // SECURITY: Stream download with size validation const chunks: Buffer[] = [] let totalSize = 0 if (!response.body) { throw new Boom(`${context} URL response has no body`, { statusCode: 400, data: { url } }) } for await (const chunk of response.body as any) { const buffer = Buffer.from(chunk) totalSize += buffer.length // SECURITY: Enforce size limit during download if (totalSize > MAX_SIZE) { throw new Boom( `${context} download (${(totalSize / 1024).toFixed(2)}KB) exceeded ${MAX_SIZE / 1024}KB limit`, { statusCode: 413, data: { url } } ) } chunks.push(buffer) } return Buffer.concat(chunks) } finally { clearTimeout(timeoutId) } } else if (typeof media === 'object' && 'stream' in media) { // SECURITY: Read stream with size limit and timeout const chunks: Buffer[] = [] let totalSize = 0 const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Boom(`${context} stream timeout after ${TIMEOUT}ms`, { statusCode: 408 })), TIMEOUT) ) try { await Promise.race([ (async () => { for await (const chunk of media.stream) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) totalSize += buffer.length // SECURITY: Check size limit if (totalSize > MAX_SIZE) { throw new Boom( `${context} stream size (${(totalSize / 1024).toFixed(2)}KB) exceeds ${MAX_SIZE / 1024}KB limit`, { statusCode: 413 } ) } chunks.push(buffer) } })(), timeoutPromise ]) return Buffer.concat(chunks) } catch (error) { // SECURITY: Cleanup on error media.stream.destroy() throw error } } else { throw new Boom(`Invalid ${context} data format`, { statusCode: 400 }) } } export type PrepareStickerPackMessageOptions = { /** Upload function to encrypt and upload media to WhatsApp servers */ upload: WAMediaUploadFunction /** Optional logger for debugging */ logger?: ILogger /** Timeout for media uploads */ mediaUploadTimeoutMs?: number } /** * Prepara uma mensagem de sticker pack para envio * * **Processo:** * 1. Valida número de stickers (3-30 conforme padrão WhatsApp oficial) * 2. Processa cada sticker (converte para WebP se necessário) * 3. Cria ZIP com stickers + cover (deduplicação automática por hash) * 4. Criptografa ZIP usando AES-256-CBC + HMAC-SHA256 * 5. Gera thumbnail da capa (252x252 JPEG) * 6. Faz upload do ZIP e thumbnail (reutiliza mesma mediaKey) * 7. Retorna proto.Message.StickerPackMessage completo * * **Especificações WhatsApp:** * - 3-30 stickers por pack (oficial) * - WebP obrigatório * - Recomendado: 100KB por sticker estático, 500KB animado * - Tray icon: 252x252 pixels * * @param stickerPack - Sticker pack data with stickers, cover, name, publisher * @param options - Upload function and optional logger * @returns Prepared StickerPackMessage ready to send * * @throws {Boom} If validation fails (sticker count, size limits, format issues) * * @example * ```typescript * const stickerPackMessage = await prepareStickerPackMessage( * { * name: 'My Pack', * publisher: 'Author', * cover: coverBuffer, * stickers: [ * { data: sticker1Buffer, emojis: ['😀'] }, * { data: sticker2Buffer, emojis: ['😎'] } * ] * }, * { upload: uploadFunction, logger } * ) * ``` */ export const prepareStickerPackMessage = async ( stickerPack: StickerPack, options: PrepareStickerPackMessageOptions ): Promise => { const { upload, logger, mediaUploadTimeoutMs } = options const { stickers, cover, name, publisher, description, packId } = stickerPack // Helper function to encrypt and upload media with guaranteed cleanup // SECURITY FIX #5: Try/finally ensures temp file cleanup even on upload failure const uploadMedia = async (buffer: Buffer, mediaType: MediaType, opts?: { mediaKey?: Uint8Array }) => { let encFilePath: string | undefined try { // Encrypt the media const encrypted = await encryptedStream(buffer, mediaType, { logger, mediaKey: opts?.mediaKey }) encFilePath = encrypted.encFilePath // Upload encrypted file const result = await upload(encrypted.encFilePath, { fileEncSha256B64: encrypted.fileEncSha256.toString('base64'), mediaType, timeoutMs: mediaUploadTimeoutMs }) return { mediaKey: encrypted.mediaKey, fileSha256: encrypted.fileSha256, fileEncSha256: encrypted.fileEncSha256, directPath: result.directPath, mediaKeyTimestamp: result.ts } } finally { // SECURITY: Always cleanup temp file, even on error if (encFilePath) { try { await fs.unlink(encFilePath) logger?.trace({ encFilePath }, 'Cleaned up temporary encrypted file') } catch (unlinkError) { // Log but don't fail - file may not exist logger?.warn({ encFilePath, error: unlinkError }, 'Failed to cleanup temp file') } } } } // 1. Validações - Padrão WhatsApp oficial: 3-30 stickers // SECURITY FIX #4: Validate actual valid stickers (not undefined/null) const validStickers = stickers.filter((s): s is NonNullable => s !== null && s !== undefined) if (validStickers.length < 3 || validStickers.length > 30) { throw new Boom( `Sticker pack must contain between 3 and 30 valid stickers per WhatsApp official spec. ` + `Provided: ${validStickers.length} valid stickers ` + `(${stickers.length} total, ${stickers.length - validStickers.length} invalid/undefined)`, { statusCode: 400 } ) } // Validação de nomes (max 128 caracteres) if (name.length > 128) { throw new Boom(`Pack name must be 128 characters or less. Current length: ${name.length}`, { statusCode: 400 }) } if (publisher.length > 128) { throw new Boom(`Publisher name must be 128 characters or less. Current length: ${publisher.length}`, { statusCode: 400 }) } logger?.info({ stickerCount: stickers.length, name, publisher }, 'Preparing sticker pack message') // 2. Gera ID do pack se não fornecido const stickerPackId = packId || generateMessageIDV2() // 3. Processa stickers e cria estrutura ZIP // SECURITY FIX #6: Parallel processing for better performance (30 stickers = significant speedup) // SECURITY FIX #7: Track deduplication to merge metadata correctly const stickerData: Record = {} const stickerMetadata: proto.Message.StickerPackMessage.ISticker[] = [] // Track metadata by hash to merge duplicates const metadataByHash = new Map() // Process all stickers in parallel for performance const processedStickers = await Promise.all( stickers.map(async (sticker, i) => { if (!sticker) return null // Skip undefined stickers // SECURITY FIX #8: Better error context for debugging try { logger?.trace({ index: i }, 'Processing sticker') // Obtém buffer do sticker const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`) // Detecta formato e converte se necessário // eslint-disable-next-line prefer-const let { webpBuffer, isAnimated, isLottie } = await convertToWebP(buffer, logger) // Honor explicit isLottie flag from user input if (sticker.isLottie !== undefined) { isLottie = sticker.isLottie } // WABA: Enforce 512x512 dimensions for WebP stickers (Lottie is vector, skip) if (!isLottie && isWebPBuffer(webpBuffer)) { const lib = await getImageProcessingLibrary() if (lib?.sharp) { const metadata = await lib.sharp.default(webpBuffer).metadata() if (metadata.width !== 512 || metadata.height !== 512) { logger?.trace( { index: i, width: metadata.width, height: metadata.height }, 'Resizing sticker to 512x512 (WABA standard)' ) webpBuffer = await lib.sharp .default(webpBuffer) .resize(512, 512, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) .webp() .toBuffer() } } } // ENHANCEMENT: Auto-compression if exceeds 1MB (try quality 70, then 50) const MAX_STICKER_SIZE = 1024 * 1024 // 1MB const recommendedLimit = isAnimated ? 500 : 100 if (webpBuffer.length > MAX_STICKER_SIZE) { logger?.warn( { index: i, sizeKB: (webpBuffer.length / 1024).toFixed(2) }, `Sticker ${i + 1} exceeds 1MB, attempting compression...` ) const lib = await getImageProcessingLibrary() if (lib?.sharp) { // Try quality 70 try { const compressed70 = await lib.sharp.default(buffer).webp({ quality: 70 }).toBuffer() // eslint-disable-next-line max-depth if (compressed70.length <= MAX_STICKER_SIZE) { webpBuffer = compressed70 logger?.info( { index: i, originalKB: (webpBuffer.length / 1024).toFixed(2), compressedKB: (compressed70.length / 1024).toFixed(2) }, `Sticker ${i + 1} compressed successfully (quality 70)` ) } else { // Try quality 50 const compressed50 = await lib.sharp.default(buffer).webp({ quality: 50 }).toBuffer() // eslint-disable-next-line max-depth if (compressed50.length <= MAX_STICKER_SIZE) { webpBuffer = compressed50 logger?.info( { index: i, originalKB: (webpBuffer.length / 1024).toFixed(2), compressedKB: (compressed50.length / 1024).toFixed(2) }, `Sticker ${i + 1} compressed successfully (quality 50)` ) } else { // Still too large throw new Boom( `Sticker ${i + 1} still exceeds 1MB after compression (${(compressed50.length / 1024).toFixed(2)}KB). ` + `Please use a smaller image.`, { statusCode: 400 } ) } } } catch (compressionError) { // If compression fails, throw error about size throw new Boom( `Sticker ${i + 1} exceeds 1MB and compression failed: ${(compressionError as Error).message}`, { statusCode: 400 } ) } } else { // No Sharp available, can't compress throw new Boom( `Sticker ${i + 1} exceeds the 1MB hard limit (${(webpBuffer.length / 1024).toFixed(2)}KB). ` + `Sharp library required for auto-compression. Install with: yarn add sharp`, { statusCode: 400 } ) } } // Check recommended size (warning only) const finalSizeKB = webpBuffer.length / 1024 if (finalSizeKB > recommendedLimit) { logger?.warn( { index: i, sizeKB: finalSizeKB, recommendedLimit, isAnimated }, `Sticker ${i + 1} exceeds WhatsApp recommended size (${recommendedLimit}KB). ` + `This may cause slower sending or delivery issues.` ) } // Gera nome do arquivo: hash.webp ou hash.was (WABA: plain_file_hash.ext) const sha256Hash = generateSha256Hash(webpBuffer) const extension = isLottie ? 'was' : 'webp' const fileName = `${sha256Hash}.${extension}` logger?.trace( { index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated, isLottie }, 'Sticker processed successfully' ) return { fileName, webpBuffer, isAnimated, isLottie, emojis: sticker.emojis || [], accessibilityLabel: sticker.accessibilityLabel } } catch (error) { // SECURITY FIX #8: Wrap errors with sticker context throw new Boom(`Failed to process sticker ${i + 1}: ${(error as Error).message}`, { statusCode: error instanceof Boom ? error.output.statusCode : 500, data: { stickerIndex: i, originalError: error } }) } }) ) // Build stickerData and merge metadata for duplicates let duplicateCount = 0 for (const result of processedStickers) { if (!result) continue const { fileName, webpBuffer, isAnimated, isLottie, emojis, accessibilityLabel } = result // SECURITY FIX #7: Check if this hash already exists (duplicate sticker) const existingMetadata = metadataByHash.get(fileName) if (existingMetadata) { // Duplicate detected - merge metadata (combine emojis and labels) duplicateCount++ // Merge emojis (deduplicate) const mergedEmojis = Array.from(new Set([...(existingMetadata.emojis ?? []), ...emojis])) existingMetadata.emojis = mergedEmojis // Merge accessibility labels (concatenate with separator if both exist) if (accessibilityLabel) { if (existingMetadata.accessibilityLabel) { existingMetadata.accessibilityLabel += ` / ${accessibilityLabel}` } else { existingMetadata.accessibilityLabel = accessibilityLabel } } logger?.debug({ fileName, mergedEmojis, duplicateCount }, 'Duplicate sticker detected - merged metadata') } else { // New sticker - add to ZIP and create metadata stickerData[fileName] = [new Uint8Array(webpBuffer), { level: 0 as 0 }] // WABA: mimetype é 'application/was' para Lottie, 'image/webp' para WebP const metadata: proto.Message.StickerPackMessage.ISticker = { fileName, isAnimated, emojis, accessibilityLabel, isLottie, mimetype: isLottie ? 'application/was' : 'image/webp' } metadataByHash.set(fileName, metadata) stickerMetadata.push(metadata) } } if (duplicateCount > 0) { logger?.info( { duplicateCount, uniqueStickers: stickerMetadata.length }, `Removed ${duplicateCount} duplicate stickers via deduplication` ) } // 4. Processa cover image (tray icon) // SECURITY FIX #8: Error context for cover processing let coverBuffer: Buffer let coverWebP: Buffer let coverFileName: string try { logger?.trace('Processing cover image') coverBuffer = await mediaToBuffer(cover, 'cover image') // WABA: Tray icon uses PNG format (installed_tray_image_id = .png) const lib = await getImageProcessingLibrary() if (lib?.sharp) { // Convert cover to PNG and resize to 96x96 (WABA tray icon standard) coverWebP = await lib.sharp .default(coverBuffer) .resize(96, 96, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) .png() .toBuffer() } else { // Fallback: convert to WebP if Sharp not available const result = await convertToWebP(coverBuffer, logger) coverWebP = result.webpBuffer } coverFileName = `${stickerPackId}.png` stickerData[coverFileName] = [new Uint8Array(coverWebP), { level: 0 as 0 }] } catch (error) { throw new Boom(`Failed to process cover image: ${(error as Error).message}`, { statusCode: error instanceof Boom ? error.output.statusCode : 500, data: { originalError: error } }) } // 5. Cria ZIP (level 0 = sem compressão para velocidade) // SECURITY FIX #8: Error context for ZIP creation let zipBuffer: Buffer let uniqueFiles: number try { uniqueFiles = Object.keys(stickerData).length logger?.trace({ totalFiles: uniqueFiles, includingCover: true }, 'Creating ZIP file') zipBuffer = Buffer.from(zipSync(stickerData)) logger?.info({ zipSizeKB: (zipBuffer.length / 1024).toFixed(2) }, 'ZIP file created successfully') // Validação de tamanho total (30MB limit para segurança) const MAX_PACK_SIZE = 30 * 1024 * 1024 if (zipBuffer.length > MAX_PACK_SIZE) { throw new Boom( `Total pack size exceeds ${MAX_PACK_SIZE / 1024 / 1024}MB limit. ` + `Current size: ${(zipBuffer.length / 1024 / 1024).toFixed(2)}MB. ` + `Try compressing stickers or reducing pack size.`, { statusCode: 400 } ) } } catch (error) { throw new Boom(`Failed to create ZIP archive: ${(error as Error).message}`, { statusCode: error instanceof Boom ? error.output.statusCode : 500, data: { originalError: error } }) } // 6. Upload do ZIP criptografado // SECURITY FIX #8: Error context for sticker pack upload let stickerPackUpload: Awaited> try { logger?.trace('Uploading encrypted sticker pack ZIP') stickerPackUpload = await uploadMedia(zipBuffer, 'sticker-pack') } catch (error) { throw new Boom(`Failed to upload sticker pack: ${(error as Error).message}`, { statusCode: error instanceof Boom ? error.output.statusCode : 500, data: { originalError: error } }) } // 7. Gera thumbnail 252x252 JPEG // SECURITY FIX #8: Error context for thumbnail generation let thumbnailBuffer: Buffer try { logger?.trace('Generating thumbnail (252x252 JPEG)') const lib = await getImageProcessingLibrary() if (!lib?.sharp) { throw new Boom('Sharp library is required for thumbnail generation. Install with: yarn add sharp', { statusCode: 400 }) } thumbnailBuffer = await lib.sharp .default(coverBuffer) .resize(252, 252, { fit: 'cover', position: 'center' }) .jpeg({ quality: 85 }) .toBuffer() logger?.trace({ thumbnailSizeKB: (thumbnailBuffer.length / 1024).toFixed(2) }, 'Thumbnail generated') } catch (error) { throw new Boom(`Failed to generate thumbnail: ${(error as Error).message}`, { statusCode: error instanceof Boom ? error.output.statusCode : 500, data: { originalError: error } }) } // 8. Upload do thumbnail (REUTILIZA mesma mediaKey - requerido pelo protocolo!) // SECURITY FIX #8: Error context for thumbnail upload let thumbUpload: Awaited> try { logger?.trace('Uploading thumbnail with same mediaKey') thumbUpload = await uploadMedia(thumbnailBuffer, 'thumbnail-sticker-pack', { mediaKey: stickerPackUpload.mediaKey // CRÍTICO: mesma chave! }) } catch (error) { throw new Boom(`Failed to upload thumbnail: ${(error as Error).message}`, { statusCode: error instanceof Boom ? error.output.statusCode : 500, data: { originalError: error } }) } // 9. Monta mensagem protobuf logger?.info( { packId: stickerPackId, totalStickers: stickers.length, uniqueFiles: uniqueFiles - 1, // minus cover zipSizeKB: (zipBuffer.length / 1024).toFixed(2) }, 'Sticker pack message prepared successfully' ) return proto.Message.StickerPackMessage.create({ // Metadata do pack stickerPackId, name, publisher, packDescription: description, stickerPackOrigin: proto.Message.StickerPackMessage.StickerPackOrigin.USER_CREATED, stickerPackSize: zipBuffer.length, stickers: stickerMetadata, // ZIP file (criptografado) fileSha256: stickerPackUpload.fileSha256, fileEncSha256: stickerPackUpload.fileEncSha256, mediaKey: stickerPackUpload.mediaKey, directPath: stickerPackUpload.directPath, fileLength: zipBuffer.length, mediaKeyTimestamp: stickerPackUpload.mediaKeyTimestamp, // Tray icon info trayIconFileName: coverFileName, // Thumbnail (criptografado com mesma key) thumbnailDirectPath: thumbUpload.directPath, thumbnailSha256: createHash('sha256').update(thumbnailBuffer).digest(), thumbnailEncSha256: thumbUpload.fileEncSha256, thumbnailHeight: 252, thumbnailWidth: 252, imageDataHash: createHash('sha256').update(thumbnailBuffer).digest('base64') }) }