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
|
* 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:
|
* SECURITY: Implements protections against:
|
||||||
* - Memory exhaustion (size limits)
|
* - Memory exhaustion (size limits)
|
||||||
* - Slow read attacks (timeouts)
|
* - Slow read attacks (timeouts)
|
||||||
* - Resource DoS (stream cleanup)
|
* - 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 context - Context for error messages (e.g., 'sticker', 'cover')
|
||||||
* @param options - Optional size limit and timeout
|
* @param options - Optional size limit and timeout
|
||||||
* @returns Buffer with media content
|
* @returns Buffer with media content
|
||||||
@@ -191,46 +191,36 @@ const mediaToBuffer = async (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
return media
|
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) {
|
} else if (typeof media === 'object' && 'url' in media) {
|
||||||
// SECURITY: Download from URL with size limit and timeout
|
|
||||||
const url = media.url.toString()
|
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 controller = new AbortController()
|
||||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT)
|
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT)
|
||||||
|
|
||||||
@@ -282,6 +272,42 @@ const mediaToBuffer = async (
|
|||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId)
|
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 {
|
} else {
|
||||||
throw new Boom(`Invalid ${context} data format`, { statusCode: 400 })
|
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}`)
|
const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`)
|
||||||
|
|
||||||
// Converte para WebP
|
// 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)
|
// ENHANCEMENT: Auto-compression if exceeds 1MB (try quality 70, then 50)
|
||||||
const sizeKB = webpBuffer.length / 1024
|
const MAX_STICKER_SIZE = 1024 * 1024 // 1MB
|
||||||
const recommendedLimit = isAnimated ? 500 : 100
|
const recommendedLimit = isAnimated ? 500 : 100
|
||||||
|
|
||||||
if (webpBuffer.length > 1024 * 1024) {
|
if (webpBuffer.length > MAX_STICKER_SIZE) {
|
||||||
throw new Boom(
|
logger?.warn(
|
||||||
`Sticker ${i + 1} exceeds the 1MB hard limit (${sizeKB.toFixed(2)}KB). ` +
|
{ index: i, sizeKB: (webpBuffer.length / 1024).toFixed(2) },
|
||||||
`Please compress or reduce quality.`,
|
`Sticker ${i + 1} exceeds 1MB, attempting compression...`
|
||||||
{ statusCode: 400 }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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(
|
logger?.warn(
|
||||||
{ index: i, sizeKB, recommendedLimit, isAnimated },
|
{ index: i, sizeKB: finalSizeKB, recommendedLimit, isAnimated },
|
||||||
`Sticker ${i + 1} exceeds WhatsApp recommended size (${recommendedLimit}KB). ` +
|
`Sticker ${i + 1} exceeds WhatsApp recommended size (${recommendedLimit}KB). ` +
|
||||||
`This may cause slower sending or delivery issues.`
|
`This may cause slower sending or delivery issues.`
|
||||||
)
|
)
|
||||||
@@ -464,7 +539,7 @@ export const prepareStickerPackMessage = async (
|
|||||||
const fileName = `${sha256Hash}.webp`
|
const fileName = `${sha256Hash}.webp`
|
||||||
|
|
||||||
logger?.trace(
|
logger?.trace(
|
||||||
{ index: i, fileName, sizeKB: sizeKB.toFixed(2), isAnimated },
|
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated },
|
||||||
'Sticker processed successfully'
|
'Sticker processed successfully'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user