feat(sticker-pack): add data URL support and auto-compression
feat(sticker-pack): add data URL support and auto-compression
This commit is contained in:
+125
-50
@@ -161,14 +161,14 @@ const generateSha256Hash = (buffer: Buffer): string => {
|
||||
|
||||
/**
|
||||
* Converte WAMediaUpload para Buffer com limites de segurança
|
||||
* Suporta Buffer, Stream e URL
|
||||
* 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 or URL)
|
||||
* @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
|
||||
@@ -191,46 +191,36 @@ const mediaToBuffer = async (
|
||||
})
|
||||
}
|
||||
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<never>((_, 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()
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -282,6 +272,42 @@ const mediaToBuffer = async (
|
||||
} 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<never>((_, 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 })
|
||||
}
|
||||
@@ -437,23 +463,72 @@ export const prepareStickerPackMessage = async (
|
||||
const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`)
|
||||
|
||||
// Converte para WebP
|
||||
const { webpBuffer, isAnimated } = await convertToWebP(buffer, logger)
|
||||
let { webpBuffer, isAnimated } = await convertToWebP(buffer, logger)
|
||||
|
||||
// Validação de tamanho (1MB hard limit, mas avisa se exceder recomendado)
|
||||
const sizeKB = webpBuffer.length / 1024
|
||||
// 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 > 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 (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()
|
||||
|
||||
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()
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (sizeKB > recommendedLimit) {
|
||||
// Check recommended size (warning only)
|
||||
const finalSizeKB = webpBuffer.length / 1024
|
||||
if (finalSizeKB > recommendedLimit) {
|
||||
logger?.warn(
|
||||
{ index: i, sizeKB, recommendedLimit, isAnimated },
|
||||
{ index: i, sizeKB: finalSizeKB, recommendedLimit, isAnimated },
|
||||
`Sticker ${i + 1} exceeds WhatsApp recommended size (${recommendedLimit}KB). ` +
|
||||
`This may cause slower sending or delivery issues.`
|
||||
)
|
||||
@@ -464,7 +539,7 @@ export const prepareStickerPackMessage = async (
|
||||
const fileName = `${sha256Hash}.webp`
|
||||
|
||||
logger?.trace(
|
||||
{ index: i, fileName, sizeKB: sizeKB.toFixed(2), isAnimated },
|
||||
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated },
|
||||
'Sticker processed successfully'
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user