diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 1912e46fca..fb82de9142 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -15,6 +15,7 @@ 🐞 Fixed +- Fixed images failing to load (HTTP 403) from CDNs that sign URLs with standard base64. `StreamImageCDN.resolveUrl` rebuilt the query string in a way that re-encoded the `=` padding in signing parameters as `%3D`, invalidating the signature. - Fixed a crash on web when the message list rebuilt while messages were selectable, for example after opening the attachment picker. - Fixed the browser's native context menu reappearing over the message context menu on web after scrolling messages out of view or deleting one. - Fixed the SDK re-enabling the browser's native context menu on web in apps that had disabled it themselves. diff --git a/packages/stream_chat_flutter/lib/src/utils/stream_image_cdn.dart b/packages/stream_chat_flutter/lib/src/utils/stream_image_cdn.dart index 602f903fb9..197eab4091 100644 --- a/packages/stream_chat_flutter/lib/src/utils/stream_image_cdn.dart +++ b/packages/stream_chat_flutter/lib/src/utils/stream_image_cdn.dart @@ -137,8 +137,7 @@ class StreamImageCDN { if (uri == null || !uri.host.contains(_streamCDNHost)) return sourceUrl; if (resize == null) return sourceUrl; - final queryParameters = { - ...uri.queryParameters, + final transform = { 'w': resize.width == 0 ? '*' : resize.width.floor().toString(), 'h': resize.height == 0 ? '*' : resize.height.floor().toString(), 'resize': resize.mode.value, @@ -146,7 +145,21 @@ class StreamImageCDN { if (resize.mode == ResizeMode.crop) 'crop': resize.crop.value, }; - return uri.replace(queryParameters: queryParameters).toString(); + // Rebuild the query from the raw string rather than via + // `replace(queryParameters:)`, which re-encodes every existing value. + // Signed CDN URLs carry base64 parameters (`URLPrefix`, `Signature`) whose + // `=` padding would become `%3D`, invalidating the signature (HTTP 403). + final preserved = uri.query.split('&').where((pair) { + if (pair.isEmpty) return false; + final name = Uri.decodeQueryComponent(pair.split('=').first); + return !transform.containsKey(name); + }); + + final applied = transform.entries.map( + (it) => '${it.key}=${Uri.encodeQueryComponent(it.value)}', + ); + + return uri.replace(query: [...preserved, ...applied].join('&')).toString(); } /// Returns a stable cache key for [imageUrl], stripping volatile diff --git a/packages/stream_chat_flutter/test/src/utils/stream_image_cdn_test.dart b/packages/stream_chat_flutter/test/src/utils/stream_image_cdn_test.dart index 967ba5a9ed..b6cdbe6695 100644 --- a/packages/stream_chat_flutter/test/src/utils/stream_image_cdn_test.dart +++ b/packages/stream_chat_flutter/test/src/utils/stream_image_cdn_test.dart @@ -118,6 +118,80 @@ void main() { }); }); + // Both backends hand the SDK a signed URL, but they sign it differently: + // CloudFront uses URL-safe base64 (`-`, `~`, `_` padding) while GCP Cloud + // CDN uses standard base64, which is padded with `=`. Rebuilding the query + // through `Uri.replace(queryParameters:)` re-encodes `=` as `%3D`, so only + // the GCP-signed URLs break — the signature no longer matches and the CDN + // answers 403. These two tests pin that difference. + group('signed CDN URLs', () { + test('preserves a CloudFront-signed URL (production backend)', () { + const policy = + 'eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiaHR0cHM6Ly91cy1lYX' + 'N0LnN0cmVhbS1pby1jZG4uY29tIn1dfQ__'; + const signature = + 'YNQTx6dJ4utuWCBN8HiXX1y~WwnD1n5a4er5xz5d6lqR03WVvH9' + '1zYOEFXANuRoTgdGdX5ud0Gs8UTOcSgQU3Sg__'; + const url = + 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg' + '?Key-Pair-Id=APKAIHG36VEWPDULE23Q' + '&Policy=$policy' + '&Signature=$signature' + '&oh=1920&ow=1440'; + + final result = cdn.resolveUrl( + url, + resize: const ImageResize(width: 200, height: 300), + ); + + // CloudFront pads with `_`, so nothing here needs escaping. + expect(result, contains('Policy=$policy')); + expect(result, contains('Signature=$signature')); + expect(Uri.parse(result).queryParameters['Policy'], equals(policy)); + expect( + Uri.parse(result).queryParameters['Signature'], + equals(signature), + ); + expect(result, contains('w=200')); + expect(result, contains('h=300')); + }); + + test('preserves a GCP Cloud CDN-signed URL (staging backend)', () { + // Standard base64 — note the `==` and `=` padding. + const urlPrefix = + 'aHR0cHM6Ly91cy1lYXN0MS5nY3Auc3RyZWFtLWlvLWNkbi5jb20' + 'vMTcxNjgxOS9pbWFnZXMvcGhvdG8uanBlZw=='; + const signature = 'kQATRhlxwyG6Uvz3D6-R61GefTA='; + const url = + 'https://us-east1.gcp.stream-io-cdn.com/1716819/images/photo.jpeg' + '?oh=447&ow=447' + '&URLPrefix=$urlPrefix' + '&Expires=1787660573' + '&KeyName=chat-us-east1-cdn-key' + '&Signature=$signature'; + + final result = cdn.resolveUrl( + url, + resize: const ImageResize(width: 200, height: 300), + ); + + // The regression: `=` must not come back as `%3D`. + expect(result, isNot(contains('%3D'))); + expect(result, contains('URLPrefix=$urlPrefix')); + expect(result, contains('Signature=$signature')); + expect( + Uri.parse(result).queryParameters['URLPrefix'], + equals(urlPrefix), + ); + expect( + Uri.parse(result).queryParameters['Signature'], + equals(signature), + ); + expect(result, contains('w=200')); + expect(result, contains('h=300')); + }); + }); + group('non-Stream URLs', () { test('returns URL unchanged regardless of resize', () { const url = 'https://example.com/photo.jpg';