From 368928446a7cde3b0a021c6b5cb7d7e609ccc93b Mon Sep 17 00:00:00 2001 From: FlavioPulli Date: Thu, 6 Aug 2026 16:57:08 -0300 Subject: [PATCH 1/2] fix(sticker): send WebP stickers as-is instead of re-encoding them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SendSticker` ran every sticker through `convertToWebP`, which fetches the URL, decodes with `image.Decode` and re-encodes with `webp.Encode(Quality: 80)`. That is wrong whenever the source is already a WebP — which is every sticker that came from WhatsApp itself: 1. Animated stickers cannot be sent at all. The registered decoder (chai2010/webp) only reads static WebP, so an animated file fails with `webpDecodeRGBA: failed` and the whole send dies. 2. The ones that do go through lose quality for nothing: a perfectly valid WebP is decoded and re-compressed at 80%. If the downloaded bytes are already a valid WebP they are now uploaded untouched, and `StickerMessage.IsAnimated` is set from the container flags — without it the recipient's client renders the first frame as a still image. Non-WebP input (PNG, JPEG) still goes through the conversion path, unchanged. How often this bites, measured on a real deployment: of 100 sticker files received by one instance, classified by the VP8X animation flag and by counting ANMF chunks, 61 were animated with 2+ frames and 4 carried the animation flag with a single frame — so 65 of 100 could not be re-sent. The single-frame ones are worth noting because they look perfectly still to the user, which makes the failure read as a bug in the product rather than a limitation. Two defensive details that the passthrough makes necessary: - The download is capped with an `io.LimitReader`. `http.Get` + `io.ReadAll` was unbounded, and now that the payload is uploaded rather than decoded, nothing downstream constrains its size either. - `isWebP` validates the declared RIFF size against the buffer length. The old conversion path rejected a truncated download for free (a truncated file fails to decode); a partial body still carries valid RIFF/WEBP magic and would be uploaded as-is, reaching the recipient broken. Verified end to end against a live instance: a sticker that previously failed with the error above was sent, delivered and read, with the animation intact. --- pkg/sendMessage/service/send_service.go | 3 +- pkg/sendMessage/service/sticker_webp.go | 91 +++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 pkg/sendMessage/service/sticker_webp.go diff --git a/pkg/sendMessage/service/send_service.go b/pkg/sendMessage/service/send_service.go index c6ecdcdd5..24aac80c8 100644 --- a/pkg/sendMessage/service/send_service.go +++ b/pkg/sendMessage/service/send_service.go @@ -1568,7 +1568,7 @@ func (s *sendService) SendSticker(data *StickerStruct, instance *instance_model. var filedata []byte if strings.HasPrefix(data.Sticker, "http") { - webpData, err := convertToWebP(data.Sticker) + webpData, err := stickerWebP(data.Sticker) if err != nil { return nil, fmt.Errorf("failed to convert image to WebP: %v", err) } @@ -1591,6 +1591,7 @@ func (s *sendService) SendSticker(data *StickerStruct, instance *instance_model. FileEncSHA256: uploaded.FileEncSHA256, FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(filedata))), + IsAnimated: proto.Bool(webpIsAnimated(filedata)), }} message, err := s.SendMessage(instance, msg, "StickerMessage", &SendDataStruct{ diff --git a/pkg/sendMessage/service/sticker_webp.go b/pkg/sendMessage/service/sticker_webp.go new file mode 100644 index 000000000..88d458282 --- /dev/null +++ b/pkg/sendMessage/service/sticker_webp.go @@ -0,0 +1,91 @@ +package send_service + +// Sticker payload handling. +// +// `SendSticker` used to run every sticker through `convertToWebP`: fetch the URL, decode with +// `image.Decode` and re-encode with `webp.Encode(Quality: 80)`. That is wrong whenever the source +// is already a WebP — which is the case for every sticker that came from WhatsApp itself: +// +// 1. Animated stickers cannot be sent at all. The registered decoder (chai2010/webp) only reads +// static WebP, so an animated file fails with `webpDecodeRGBA: failed` and the whole send dies. +// 2. The ones that do go through lose quality for nothing — a perfectly valid WebP is decoded and +// re-compressed at 80%. +// +// So: if the downloaded bytes are already a valid WebP, they are uploaded untouched. Animation and +// quality survive, and the conversion path is left to the inputs that actually need it (PNG, JPEG). + +import ( + "bytes" + "encoding/binary" + "fmt" + "image" + "io" + "net/http" + + "github.com/chai2010/webp" +) + +// maxStickerBytes caps the sticker download. WhatsApp rejects stickers far smaller than this; the +// limit exists so a hostile URL cannot exhaust the process memory — relevant now that the payload +// is uploaded rather than decoded, so nothing downstream constrains its size either. +const maxStickerBytes = 10 << 20 // 10 MiB + +// stickerWebP fetches the sticker URL and returns WebP bytes ready to upload. +// +// A source that is already a valid WebP is returned untouched; anything else is decoded and +// encoded to WebP as before. +func stickerWebP(url string) ([]byte, error) { + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch image from URL: %v", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxStickerBytes+1)) + if err != nil { + return nil, fmt.Errorf("failed to read image from URL: %v", err) + } + if len(raw) > maxStickerBytes { + return nil, fmt.Errorf("sticker exceeds %d bytes", maxStickerBytes) + } + + if isWebP(raw) { + return raw, nil + } + + img, _, err := image.Decode(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("failed to decode image: %v", err) + } + var buf bytes.Buffer + if err := webp.Encode(&buf, img, &webp.Options{Lossless: false, Quality: 80}); err != nil { + return nil, fmt.Errorf("failed to encode image to WebP: %v", err) + } + return buf.Bytes(), nil +} + +// isWebP reports whether b is a well-formed WebP container. +// +// It checks the RIFF magic AND that the declared payload size fits in the buffer. That second +// check matters specifically because of the passthrough above: the old conversion path rejected a +// truncated download for free (a truncated file fails to decode), whereas a partial body still +// carries valid RIFF/WEBP magic and would be uploaded as-is, reaching the recipient broken. +func isWebP(b []byte) bool { + if len(b) < 12 || string(b[0:4]) != "RIFF" || string(b[8:12]) != "WEBP" { + return false + } + // The RIFF size field counts everything after it, i.e. len(file) - 8. Trailing padding is + // tolerated (some encoders add it); a payload larger than what we hold means truncation. + return int(binary.LittleEndian.Uint32(b[4:8]))+8 <= len(b) +} + +// webpIsAnimated reports whether a WebP container declares animation. +// +// Only the extended format (VP8X) can be animated, and bit 0x02 of its flags byte is the +// declaration — the same ANIMATION_FLAG libwebp uses. Plain VP8/VP8L are static by definition. +func webpIsAnimated(b []byte) bool { + if !isWebP(b) || len(b) < 21 || string(b[12:16]) != "VP8X" { + return false + } + return b[20]&0x02 != 0 +} From 78033119555249563e9de0fa0f92b43424f386d8 Mon Sep 17 00:00:00 2001 From: FlavioPulli Date: Thu, 6 Aug 2026 17:25:43 -0300 Subject: [PATCH 2/2] review: bound the sticker download and stop mislabelling the error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two points from the review, both fair: - `http.Get` used the default client: no timeout, no context, and no status check. The sticker URL comes from the API caller and may point anywhere, so a server that accepts the connection and then stalls held the goroutine open indefinitely. Now it goes through a client with a 30s timeout via `http.NewRequestWithContext`, and a non-2xx response fails immediately — without that check an HTML error page was read as image data and failed later, deeper, with a decode error that said nothing about the URL having answered 404. - `failed to convert image to WebP` was describing something that no longer happens on the passthrough path, where nothing is converted. It is now `failed to prepare sticker payload`. The context is `context.Background()` at the call site, matching the adjacent `client.Upload` call. Threading the real request context through `SendSticker` would change the service interface, so I left it out of this PR — happy to do it if you would rather have it here. --- pkg/sendMessage/service/send_service.go | 4 +-- pkg/sendMessage/service/sticker_webp.go | 33 ++++++++++++++++++++----- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/pkg/sendMessage/service/send_service.go b/pkg/sendMessage/service/send_service.go index 24aac80c8..bb5198b68 100644 --- a/pkg/sendMessage/service/send_service.go +++ b/pkg/sendMessage/service/send_service.go @@ -1568,9 +1568,9 @@ func (s *sendService) SendSticker(data *StickerStruct, instance *instance_model. var filedata []byte if strings.HasPrefix(data.Sticker, "http") { - webpData, err := stickerWebP(data.Sticker) + webpData, err := stickerWebP(context.Background(), data.Sticker) if err != nil { - return nil, fmt.Errorf("failed to convert image to WebP: %v", err) + return nil, fmt.Errorf("failed to prepare sticker payload: %v", err) } filedata = webpData diff --git a/pkg/sendMessage/service/sticker_webp.go b/pkg/sendMessage/service/sticker_webp.go index 88d458282..c86ed3aae 100644 --- a/pkg/sendMessage/service/sticker_webp.go +++ b/pkg/sendMessage/service/sticker_webp.go @@ -16,31 +16,52 @@ package send_service import ( "bytes" + "context" "encoding/binary" "fmt" "image" "io" "net/http" + "time" "github.com/chai2010/webp" ) -// maxStickerBytes caps the sticker download. WhatsApp rejects stickers far smaller than this; the -// limit exists so a hostile URL cannot exhaust the process memory — relevant now that the payload -// is uploaded rather than decoded, so nothing downstream constrains its size either. -const maxStickerBytes = 10 << 20 // 10 MiB +const ( + // maxStickerBytes caps the sticker download. WhatsApp rejects stickers far smaller than this; + // the limit exists so a hostile URL cannot exhaust the process memory — relevant now that the + // payload is uploaded rather than decoded, so nothing downstream constrains its size either. + maxStickerBytes = 10 << 20 // 10 MiB + + // stickerFetchTimeout bounds the download. The sticker URL is supplied by the API caller and + // may point anywhere, so a server that accepts the connection and then stalls would otherwise + // hold the goroutine open indefinitely. + stickerFetchTimeout = 30 * time.Second +) + +var stickerFetchClient = &http.Client{Timeout: stickerFetchTimeout} // stickerWebP fetches the sticker URL and returns WebP bytes ready to upload. // // A source that is already a valid WebP is returned untouched; anything else is decoded and // encoded to WebP as before. -func stickerWebP(url string) ([]byte, error) { - resp, err := http.Get(url) +func stickerWebP(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to build request for sticker URL: %v", err) + } + resp, err := stickerFetchClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch image from URL: %v", err) } defer resp.Body.Close() + // Without this, an HTML error page is read as if it were image data: it fails later, deeper, + // with a decode error that says nothing about the URL having answered 404. + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("sticker URL returned HTTP %d", resp.StatusCode) + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxStickerBytes+1)) if err != nil { return nil, fmt.Errorf("failed to read image from URL: %v", err)