diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index 275c791b..c16a5bc6 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -1,3 +1,7 @@ +## [3.4.7] - 2026-10-27 + +* Preserves short, safe URL extensions for downloads with missing or generic content types, ignores dynamic endpoint suffixes, and tolerates malformed legacy URLs ([#489](https://github.com/Baseflow/flutter_cache_manager/issues/489)). + ## [3.4.6] - 2026-10-27 * Catches unhandled asynchronous errors when updating cache-hit metadata and during scheduled cleanup in `CacheStore` ([#535](https://github.com/Baseflow/flutter_cache_manager/issues/535)). diff --git a/flutter_cache_manager/lib/src/compat/file_service_compat.dart b/flutter_cache_manager/lib/src/compat/file_service_compat.dart index 7d6fdbc6..1d78826b 100644 --- a/flutter_cache_manager/lib/src/compat/file_service_compat.dart +++ b/flutter_cache_manager/lib/src/compat/file_service_compat.dart @@ -15,15 +15,16 @@ class FileServiceCompat extends FileService { Map? headers, }) async { final legacyResponse = await fileFetcher(url, headers: headers); - return CompatFileServiceGetResponse(legacyResponse); + return CompatFileServiceGetResponse(legacyResponse, Uri.tryParse(url)); } } class CompatFileServiceGetResponse implements FileServiceResponse { final FileFetcherResponse legacyResponse; + final Uri? _uri; final DateTime _receivedTime = clock.now(); - CompatFileServiceGetResponse(this.legacyResponse); + CompatFileServiceGetResponse(this.legacyResponse, [this._uri]); String? _header(String name) { return legacyResponse.header(name); @@ -64,15 +65,10 @@ class CompatFileServiceGetResponse implements FileServiceResponse { String? get eTag => _header(HttpHeaders.etagHeader); @override - String get fileExtension { - var fileExtension = ''; - final contentTypeHeader = _header(HttpHeaders.contentTypeHeader); - if (contentTypeHeader != null) { - final contentType = ContentType.parse(contentTypeHeader); - fileExtension = contentType.fileExtension; - } - return fileExtension; - } + String get fileExtension => fileExtensionFromContentTypeOrUri( + _header(HttpHeaders.contentTypeHeader), + _uri, + ); @override int get statusCode => legacyResponse.statusCode as int; diff --git a/flutter_cache_manager/lib/src/web/file_service.dart b/flutter_cache_manager/lib/src/web/file_service.dart index 376d672e..4ba1b886 100644 --- a/flutter_cache_manager/lib/src/web/file_service.dart +++ b/flutter_cache_manager/lib/src/web/file_service.dart @@ -66,11 +66,13 @@ abstract class FileServiceResponse { /// Basic implementation of a [FileServiceResponse] for http requests. class HttpGetResponse implements FileServiceResponse { - HttpGetResponse(this._response); + HttpGetResponse(this._response, [Uri? uri]) + : _uri = uri ?? _response.request?.url; final DateTime _receivedTime = clock.now(); final http.StreamedResponse _response; + final Uri? _uri; @override int get statusCode => _response.statusCode; @@ -114,13 +116,8 @@ class HttpGetResponse implements FileServiceResponse { String? get eTag => _header(HttpHeaders.etagHeader); @override - String get fileExtension { - var fileExtension = ''; - final contentTypeHeader = _header(HttpHeaders.contentTypeHeader); - if (contentTypeHeader != null) { - final contentType = ContentType.parse(contentTypeHeader); - fileExtension = contentType.fileExtension; - } - return fileExtension; - } + String get fileExtension => fileExtensionFromContentTypeOrUri( + _header(HttpHeaders.contentTypeHeader), + _uri, + ); } diff --git a/flutter_cache_manager/lib/src/web/mime_converter.dart b/flutter_cache_manager/lib/src/web/mime_converter.dart index d73e772f..2acffead 100644 --- a/flutter_cache_manager/lib/src/web/mime_converter.dart +++ b/flutter_cache_manager/lib/src/web/mime_converter.dart @@ -5,6 +5,50 @@ extension ContentTypeConverter on ContentType { String get fileExtension => mimeTypes[mimeType] ?? '.$subType'; } +final RegExp _safeFileExtension = RegExp(r'^\.[A-Za-z0-9]{1,10}$'); + +const _genericContentTypes = { + 'application/octet-stream', + 'application/x-octet-stream', + 'application/force-download', + 'application/download', + 'binary/octet-stream', +}; + +const _dynamicEndpointExtensions = {'.php', '.asp', '.aspx', '.cgi', '.jsp'}; + +/// Gets an extension from a specific content type or a safe URL path suffix. +/// +/// Specific content types take precedence. Missing or generic content types +/// use a short alphanumeric URL suffix when available. Known server-side +/// endpoint suffixes are ignored. The result falls back to `.bin` for generic +/// types or an empty string when the content type is absent. +String fileExtensionFromContentTypeOrUri(String? contentTypeHeader, Uri? uri) { + final contentType = contentTypeHeader == null + ? null + : ContentType.parse(contentTypeHeader); + + if (contentType != null && + !_genericContentTypes.contains(contentType.mimeType)) { + return contentType.fileExtension; + } + + final pathSegments = uri?.pathSegments; + final fileName = pathSegments == null || pathSegments.isEmpty + ? '' + : pathSegments.last; + final extensionIndex = fileName.lastIndexOf('.'); + if (extensionIndex > 0 && extensionIndex < fileName.length - 1) { + final extension = fileName.substring(extensionIndex); + if (_safeFileExtension.hasMatch(extension) && + !_dynamicEndpointExtensions.contains(extension.toLowerCase())) { + return extension; + } + } + + return contentType == null ? '' : '.bin'; +} + /// Source of MIME Types: /// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types /// Updated on 20th of March in 2020 while being quarantined diff --git a/flutter_cache_manager/pubspec.yaml b/flutter_cache_manager/pubspec.yaml index 8456c4b5..c3d22cd7 100644 --- a/flutter_cache_manager/pubspec.yaml +++ b/flutter_cache_manager/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_cache_manager description: Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite. -version: 3.4.6 +version: 3.4.7 homepage: https://github.com/Baseflow/flutter_cache_manager topics: - cache diff --git a/flutter_cache_manager/test/http_file_fetcher_test.dart b/flutter_cache_manager/test/http_file_fetcher_test.dart index 34f81d0b..f3387142 100644 --- a/flutter_cache_manager/test/http_file_fetcher_test.dart +++ b/flutter_cache_manager/test/http_file_fetcher_test.dart @@ -7,6 +7,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart'; import 'package:http/testing.dart'; +import 'helpers/mock_cache_info_repository.dart'; +import 'helpers/test_configuration.dart' show TestFileSystem; + void main() { group('Check header values', () { test('Valid headers should be parsed normally', () async { @@ -77,6 +80,244 @@ void main() { expect(response.fileExtension, '.$fileExtension'); }); + test('HttpGetResponse keeps its original one-argument constructor', () { + final response = HttpGetResponse( + StreamedResponse( + const Stream>.empty(), + 200, + headers: {'content-type': 'application/octet-stream'}, + request: Request('GET', Uri.parse('https://example.com/video.mp4')), + ), + ); + + expect(response.fileExtension, '.mp4'); + }); + + test('Download uses the URL extension for generic content type', () async { + const url = 'https://example.com/video.mp4?token=secret'; + final client = MockClient((request) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + request: request, + ); + }); + final cacheManager = CacheManager( + Config( + 'test', + fileSystem: TestFileSystem(), + repo: MockCacheInfoRepository(), + fileService: HttpFileService(httpClient: client), + ), + ); + + final result = await cacheManager.downloadFile(url); + + expect(result.file.path, endsWith('.mp4')); + }); + + test('URL extension is used when content type is missing', () async { + final client = MockClient((request) async { + return Response.bytes(Uint8List(16), 200, request: request); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/document.pdf?download=1'); + + expect(response.fileExtension, '.pdf'); + }); + + test('Specific content type takes precedence over URL extension', () async { + final client = MockClient((_) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'image/jpeg'}, + ); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/video.mp4'); + + expect(response.fileExtension, '.jpg'); + }); + + test( + 'Legacy file service uses URL extension for generic content type', + () async { + final client = MockClient((_) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + ); + }); + + Future defaultHttpGetter( + String url, { + Map? headers, + }) async { + final httpResponse = await client.get( + Uri.parse(url), + headers: headers, + ); + return HttpFileFetcherResponse(httpResponse); + } + + final response = await FileServiceCompat( + defaultHttpGetter, + ).get('https://example.com/video.mp4'); + + expect(response.fileExtension, '.mp4'); + }, + ); + + test('Generic content type without URL extension remains .bin', () async { + final client = MockClient((_) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + ); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/download'); + + expect(response.fileExtension, '.bin'); + }); + + for (final contentType in const [ + 'application/octet-stream', + 'binary/octet-stream', + 'application/x-octet-stream', + 'application/force-download', + 'application/download', + ]) { + test( + 'URL extension is used for generic MIME type $contentType', + () async { + final client = MockClient((request) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': contentType}, + request: request, + ); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/video.mp4'); + + expect(response.fileExtension, '.mp4'); + }, + ); + } + + test('URL extension is limited to short alphanumeric suffixes', () async { + final client = MockClient((request) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + request: request, + ); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/file.${'a' * 250}'); + + expect(response.fileExtension, '.bin'); + }); + + test('URL version suffix is not treated as a file extension', () async { + final client = MockClient((request) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + request: request, + ); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/files/v1.2-beta'); + + expect(response.fileExtension, '.bin'); + }); + + test( + 'Dynamic download endpoint is not used as the file extension', + () async { + final client = MockClient((request) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + request: request, + ); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/download.php?id=7'); + + expect(response.fileExtension, '.bin'); + }, + ); + + test( + 'Malformed legacy URL does not fail after the fetch succeeds', + () async { + final legacyResponse = HttpFileFetcherResponse( + Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + ), + ); + final response = await FileServiceCompat( + (url, {headers}) async => legacyResponse, + ).get('https://[::1/video.mp4'); + + expect(response.fileExtension, '.bin'); + }, + ); + + test( + 'Encoded path separators are not used in cached file extensions', + () async { + const url = 'https://example.com/video.mp4%2Fpart'; + final client = MockClient((request) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + request: request, + ); + }); + final cacheManager = CacheManager( + Config( + 'test', + fileSystem: TestFileSystem(), + repo: MockCacheInfoRepository(), + fileService: HttpFileService(httpClient: client), + ), + ); + + final result = await cacheManager.downloadFile(url); + + expect(result.file.path, endsWith('.bin')); + }, + ); + test('Test CompatFileService', () async { var eTag = 'test'; var fileExtension = 'jpg';