diff --git a/package.json b/package.json index 748363e6..7e345630 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@cacheable/node-cache": "^1.4.0", "@hapi/boom": "^9.1.3", "async-mutex": "^0.5.0", + "fflate": "^0.8.2", "libsignal": "git+https://github.com/whiskeysockets/libsignal-node", "lru-cache": "^11.1.0", "music-metadata": "^11.7.0", diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index e5cc38fc..b40e74d7 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -123,7 +123,9 @@ export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = { 'product-catalog-image': '/product/image', 'md-app-state': '', 'md-msg-hist': '/mms/md-app-state', - 'biz-cover-photo': '/pps/biz-cover-photo' + 'biz-cover-photo': '/pps/biz-cover-photo', + 'sticker-pack': '/mms/sticker-pack', + 'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack' } export const MEDIA_HKDF_KEY_MAPPING = { @@ -145,7 +147,9 @@ export const MEDIA_HKDF_KEY_MAPPING = { 'product-catalog-image': '', 'payment-bg-image': 'Payment Background', ptv: 'Video', - 'biz-cover-photo': 'Image' + 'biz-cover-photo': 'Image', + 'sticker-pack': 'Sticker Pack', + 'thumbnail-sticker-pack': 'Sticker Pack Thumbnail' } export type MediaType = keyof typeof MEDIA_HKDF_KEY_MAPPING diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index fd8e3c59..fbf283f2 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1374,6 +1374,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { : message.audioMessage ? 'audio' : message.documentMessage ? 'document' : message.stickerMessage ? 'sticker' + : message.stickerPackMessage ? 'sticker_pack' : message.reactionMessage ? 'reaction' : 'other' recordMessageSent(msgType) diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 539a4b5c..6834dcbe 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -39,6 +39,59 @@ import type { ILogger } from '../Utils/logger' export type WAMediaPayloadURL = { url: URL | string } export type WAMediaPayloadStream = { stream: Readable } export type WAMediaUpload = Buffer | WAMediaPayloadStream | WAMediaPayloadURL + +/** + * Individual sticker in a sticker pack + */ +export type Sticker = { + /** Buffer, Stream or URL of the sticker image (will be converted to WebP if needed) */ + data: WAMediaUpload + /** Array of emojis associated with this sticker (max 3 recommended per WhatsApp standards) */ + emojis?: string[] + /** Accessibility label for screen readers (max 125 chars for static, 255 for animated) */ + accessibilityLabel?: string +} + +/** + * Sticker Pack message - send a complete pack of stickers + * + * Follows WhatsApp official specifications: + * - 3-30 stickers per pack (enforced) + * - WebP format (auto-converted) + * - Max 100KB per static sticker, 500KB per animated (recommended, not enforced) + * - Either all static OR all animated (recommended) + * + * @example + * ```typescript + * await sock.sendMessage(jid, { + * stickerPack: { + * name: 'My Awesome Pack', + * publisher: 'Your Name', + * description: 'Cool stickers collection', + * cover: Buffer.from(...), // or URL or Stream + * stickers: [ + * { data: Buffer.from(...), emojis: ['😀', '😃'] }, + * { data: 'https://example.com/sticker.webp', emojis: ['😎'] } + * ] + * } + * }) + * ``` + */ +export type StickerPack = { + /** Array of stickers (minimum 3, maximum 30 per WhatsApp official spec) */ + stickers: Sticker[] + /** Cover/tray icon for the pack (will be auto-resized to 252x252 JPEG) */ + cover: WAMediaUpload + /** Pack name (max 128 characters) */ + name: string + /** Publisher/author name (max 128 characters) */ + publisher: string + /** Optional pack description */ + description?: string + /** Optional custom pack ID (auto-generated if omitted) */ + packId?: string +} + /** Set of message types that are supported by the library */ export type MessageType = keyof proto.Message @@ -818,6 +871,44 @@ export type AnyRegularMessageContent = ( */ album: AlbumMessageOptions } + | { + /** + * Sticker Pack - Send a complete pack of stickers (3-30 stickers) + * + * The pack will appear in the recipient's sticker tray, similar to official sticker packs. + * All stickers are automatically converted to WebP format if needed. + * + * @example + * ```typescript + * import { readFileSync } from 'fs' + * + * await sock.sendMessage(jid, { + * stickerPack: { + * name: 'Emoji Pack', + * publisher: 'InfiniteAPI', + * description: 'Fun emoji stickers', + * cover: readFileSync('./pack-cover.png'), + * stickers: [ + * { data: readFileSync('./sticker1.webp'), emojis: ['😀'] }, + * { data: readFileSync('./sticker2.png'), emojis: ['😎', '🔥'] }, + * { data: { url: 'https://example.com/sticker3.webp' }, emojis: ['🎉'] } + * ] + * } + * }) + * ``` + * + * **Requirements:** + * - `fflate` package (installed automatically) + * - `sharp` package for image processing: `yarn add sharp` + * + * **Specifications (WhatsApp Official):** + * - Minimum 3 stickers, maximum 30 per pack + * - Recommended: 100KB per static sticker, 500KB per animated + * - WebP format (auto-converted from PNG/JPG/etc) + * - Best practice: All stickers either static OR animated, not mixed + */ + stickerPack: StickerPack + } | SharePhoneNumber | RequestPhoneNumber ) & diff --git a/src/Utils/messages-media.ts b/src/Utils/messages-media.ts index d2cbe7d3..7c691163 100644 --- a/src/Utils/messages-media.ts +++ b/src/Utils/messages-media.ts @@ -31,7 +31,12 @@ import type { ILogger } from './logger' const getTmpFilesDirectory = () => tmpdir() -const getImageProcessingLibrary = async () => { +/** + * Get available image processing library (Sharp or Jimp) + * Exported for use in sticker pack processing + * @returns Object with sharp or jimp property, or throws if neither available + */ +export const getImageProcessingLibrary = async () => { //@ts-ignore const [jimp, sharp] = await Promise.all([import('jimp').catch(() => {}), import('sharp').catch(() => {})]) @@ -380,18 +385,21 @@ type EncryptedStreamOptions = { saveOriginalFileIfRequired?: boolean logger?: ILogger opts?: RequestInit + /** Optional mediaKey to reuse (required for sticker pack thumbnail to match ZIP encryption) */ + mediaKey?: Uint8Array } export const encryptedStream = async ( media: WAMediaUpload, mediaType: MediaType, - { logger, saveOriginalFileIfRequired, opts }: EncryptedStreamOptions = {} + { logger, saveOriginalFileIfRequired, opts, mediaKey: providedMediaKey }: EncryptedStreamOptions = {} ) => { const { stream, type } = await getStream(media, opts) logger?.debug('fetched media stream') - const mediaKey = Crypto.randomBytes(32) + // Use provided mediaKey or generate new one + const mediaKey = providedMediaKey || Crypto.randomBytes(32) const { cipherKey, iv, macKey } = await getMediaKeys(mediaKey, mediaType) const encFilePath = join(getTmpFilesDirectory(), mediaType + generateMessageIDV2() + '-enc') diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index c3ddf38a..46c6cad4 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -47,6 +47,7 @@ import { getRawMediaUploadData, type MediaDownloadOptions } from './messages-media' +import { prepareStickerPackMessage } from './sticker-pack.js' import { shouldIncludeReportingToken } from './reporting-utils' type ExtractByKey = T extends Record ? T : never @@ -1493,6 +1494,16 @@ export const generateWAMessageContent = async ( } } else if (hasNonNullishProperty(message, 'requestPhoneNumber')) { m.requestPhoneNumberMessage = {} + } else if (hasNonNullishProperty(message, 'stickerPack')) { + // Prepare sticker pack message (3-30 stickers per WhatsApp official spec) + options.logger?.info('Generating sticker pack message') + + const stickerPackMessage = await prepareStickerPackMessage(message.stickerPack, { + upload: options.upload, + logger: options.logger + }) + + m.stickerPackMessage = stickerPackMessage } else if (hasNonNullishProperty(message, 'limitSharing')) { m.protocolMessage = { type: proto.Message.ProtocolMessage.Type.LIMIT_SHARING, diff --git a/src/Utils/sticker-pack.ts b/src/Utils/sticker-pack.ts new file mode 100644 index 00000000..60b59100 --- /dev/null +++ b/src/Utils/sticker-pack.ts @@ -0,0 +1,695 @@ +import { createHash } from 'crypto' +import { zipSync } from 'fflate' +import { promises as fs } from 'fs' +import { Boom } from '@hapi/boom' +import { proto } from '../../WAProto/index.js' +import type { MediaType } from '../Defaults/index.js' +import type { Sticker, StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js' +import type { ILogger } from './logger.js' +import { generateMessageIDV2 } from './generics.js' +import { getImageProcessingLibrary, encryptedStream } 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 +} + +/** + * 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 }> => { + // 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 } + } + + // 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(buffer).webp().toBuffer() + + return { webpBuffer, isAnimated: 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 e URL + * + * SECURITY: Implements protections against: + * - Memory exhaustion (size limits) + * - Slow read attacks (timeouts) + * - Resource DoS (stream cleanup) + * + * @param media - Media input (Buffer, Stream or 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' && '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 if (typeof media === 'object' && 'url' in media) { + // SECURITY: Download from URL with size limit and timeout + const url = media.url.toString() + + 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 { + 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}`) + + // Converte para WebP + const { webpBuffer, isAnimated } = await convertToWebP(buffer, logger) + + // Validação de tamanho (1MB hard limit, mas avisa se exceder recomendado) + const sizeKB = webpBuffer.length / 1024 + const recommendedLimit = isAnimated ? 500 : 100 + + if (webpBuffer.length > 1024 * 1024) { + throw new Boom( + `Sticker ${i + 1} exceeds the 1MB hard limit (${sizeKB.toFixed(2)}KB). ` + + `Please compress or reduce quality.`, + { statusCode: 400 } + ) + } + + if (sizeKB > recommendedLimit) { + logger?.warn( + { index: i, sizeKB, 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 (deduplicação automática) + const sha256Hash = generateSha256Hash(webpBuffer) + const fileName = `${sha256Hash}.webp` + + logger?.trace( + { index: i, fileName, sizeKB: sizeKB.toFixed(2), isAnimated }, + 'Sticker processed successfully' + ) + + return { + fileName, + webpBuffer, + isAnimated, + 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, 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 }] + + const metadata: proto.Message.StickerPackMessage.ISticker = { + fileName, + isAnimated, + emojis, + accessibilityLabel, + isLottie: false, + mimetype: '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') + + // Converte cover para WebP e adiciona ao ZIP + const result = await convertToWebP(coverBuffer, logger) + coverWebP = result.webpBuffer + coverFileName = `${stickerPackId}.webp` + 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(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') + }) +} diff --git a/yarn.lock b/yarn.lock index 16f90fdc..2145fb9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2841,6 +2841,7 @@ __metadata: eslint: "npm:^9" eslint-config-prettier: "npm:^10.1.2" eslint-plugin-prettier: "npm:^5.4.0" + fflate: "npm:^0.8.2" jest: "npm:^30.0.5" jimp: "npm:^1.6.0" jiti: "npm:^2.4.2" @@ -4668,6 +4669,13 @@ __metadata: languageName: node linkType: hard +"fflate@npm:^0.8.2": + version: 0.8.2 + resolution: "fflate@npm:0.8.2" + checksum: 10c0/03448d630c0a583abea594835a9fdb2aaf7d67787055a761515bf4ed862913cfd693b4c4ffd5c3f3b355a70cf1e19033e9ae5aedcca103188aaff91b8bd6e293 + languageName: node + linkType: hard + "figures@npm:^5.0.0": version: 5.0.0 resolution: "figures@npm:5.0.0"