diff --git a/app/test/shared/versions_test.dart b/app/test/shared/versions_test.dart index 683d681a2f..339df83b3e 100644 --- a/app/test/shared/versions_test.dart +++ b/app/test/shared/versions_test.dart @@ -65,7 +65,7 @@ void main() { 'accepted runtime versions should be lexicographically ordered', () { for (final version in acceptedRuntimeVersions) { - expect(runtimeVersionPattern.hasMatch(version), isTrue); + expect(version, matches(runtimeVersionPattern)); } final sorted = [...acceptedRuntimeVersions] ..sort((a, b) => -a.compareTo(b)); @@ -77,23 +77,29 @@ void main() { expect(acceptedRuntimeVersions, hasLength(lessThan(6))); }); - test('runtime sdk version should match CI and dockerfile', () async { + test('runtime sdk version should match CI and dockerfiles', () async { final String docker = await File('../Dockerfile.app').readAsString(); - expect(docker.contains('\nFROM dart:$runtimeSdkVersion\n'), isTrue); + expect(docker, contains('\nFROM dart:$runtimeSdkVersion\n')); final ci = await File('../.github/workflows/all-test.yml').readAsString(); - expect(ci.contains("DART_SDK_VERSION: '$runtimeSdkVersion'"), isTrue); + expect(ci, contains("DART_SDK_VERSION: '$runtimeSdkVersion'")); + final imageProxyDocker = await File( + '../pkg/image_proxy/Dockerfile', + ).readAsString(); + expect(imageProxyDocker, contains('\nFROM dart:$runtimeSdkVersion ')); }); test('Dart SDK versions should match Dockerfile.worker', () async { final dockerfileContent = await File('../Dockerfile.worker').readAsString(); expect( - dockerfileContent.contains( - 'tool/setup-dart.sh /home/worker/dart/stable stable/raw/hash/', - ) || - dockerfileContent.contains( - 'tool/setup-dart.sh /home/worker/dart/stable $toolStableDartSdkVersion', - ), - isTrue, + dockerfileContent, + anyOf( + contains( + 'tool/setup-dart.sh /home/worker/dart/stable stable/raw/hash/', + ), + contains( + 'tool/setup-dart.sh /home/worker/dart/stable $toolStableDartSdkVersion', + ), + ), ); }); @@ -166,7 +172,7 @@ and do not format to also bump the runtimeVersion.''', // roll traffic backwards. // Avoid this by temporarily hardcoding gcBeforeRuntimeVersion to not be // the last version of acceptedRuntimeVersions. - expect(gcBeforeRuntimeVersion != runtimeVersion, isTrue); + expect(gcBeforeRuntimeVersion, isNot(runtimeVersion)); }); scopedTest('GC is returning correct values for known versions', () { diff --git a/pkg/image_proxy/Dockerfile b/pkg/image_proxy/Dockerfile new file mode 100644 index 0000000000..cf0bc8e040 --- /dev/null +++ b/pkg/image_proxy/Dockerfile @@ -0,0 +1,21 @@ +# Use latest stable channel SDK. +FROM dart:3.9.0 AS build + +ENV PUB_ENVIRONMENT="bot" +ENV PUB_CACHE="/project/.pub-cache" + +# Resolve app dependencies. +WORKDIR /app +COPY . . +RUN dart pub get --enforce-lockfile +RUN dart compile exe pkg/image_proxy/bin/server.dart -o server + +# Build minimal serving image from AOT-compiled `/server` +# and the pre-built AOT-runtime in the `/runtime/` directory of the base image. +FROM scratch +COPY --from=build /runtime/ / +COPY --from=build /app/server /app/bin/ + +# Start server. +EXPOSE 8080 +CMD ["/app/bin/server"] diff --git a/pkg/image_proxy/README.md b/pkg/image_proxy/README.md new file mode 100644 index 0000000000..799fc2c0e7 --- /dev/null +++ b/pkg/image_proxy/README.md @@ -0,0 +1,20 @@ +# Image proxy for pub.dev. + + +Will forward requests to a url, when given a request like: +``` +https://external-image.pub.dev/// +``` + +date is a "microsecond after epoch" timestamp of a specific date's midnight. + +hmac_kms is calculated in KMS with the key version at HMAC_KEY_ID. + +## Development + +To build the docker image (from the repository root): + +``` +docker build -t image-proxy-server . --file pkg/image_proxy/Dockerfile +``` + diff --git a/pkg/image_proxy/bin/server.dart b/pkg/image_proxy/bin/server.dart new file mode 100644 index 0000000000..d328375ea3 --- /dev/null +++ b/pkg/image_proxy/bin/server.dart @@ -0,0 +1,5 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'package:pub_dev_image_proxy/image_proxy_service.dart' show main; diff --git a/pkg/image_proxy/lib/image_proxy_service.dart b/pkg/image_proxy/lib/image_proxy_service.dart new file mode 100644 index 0000000000..5ae8c114fc --- /dev/null +++ b/pkg/image_proxy/lib/image_proxy_service.dart @@ -0,0 +1,309 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:googleapis/cloudkms/v1.dart' as kms; +import 'package:googleapis_auth/auth_io.dart' as auth; +import 'package:googleapis_auth/googleapis_auth.dart'; +import 'package:http/http.dart' as http; +import 'package:retry/retry.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'package:shelf/shelf_io.dart'; + +bool isTesting = Platform.environment['IMAGE_PROXY_TESTING'] == 'true'; + +Duration timeoutDelay = Duration(seconds: isTesting ? 1 : 8); + +/// The keys we currently allow the url to be signed with. +Map allowedKeys = {}; + +/// Ensure that [allowedKeys] contains keys for today and the two surrounding +/// days. +Future updateAllowedKeys() async { + final now = DateTime.now(); + final yesterday = DateTime(now.year, now.month, now.day - 1); + final today = DateTime(now.year, now.month, now.day); + final tomorrow = DateTime(now.year, now.month, now.day + 1); + + for (final d in [yesterday, today, tomorrow]) { + if (!allowedKeys.containsKey(d.millisecondsSinceEpoch)) { + allowedKeys[d.millisecondsSinceEpoch] = isTesting + ? await getDailySecretMock(d) + : await getDailySecret(d); + print('Generating new key for ${d.toIso8601String()}'); + } + } + while (allowedKeys.length > 3) { + final dates = allowedKeys.keys.toList()..sort(); + allowedKeys.remove(dates.first); + } + assert(allowedKeys.length == 3); +} + +late auth.AuthClient? _apiClient; + +/// The client used for communicating with the google apis. +Future authClient() async { + return (_apiClient ??= await retry(() async { + return await auth.clientViaApplicationDefaultCredentials(scopes: []); + }))!; +} + +Future getDailySecretMock(DateTime day) async { + return hmacSign( + utf8.encode('fake secret'), + utf8.encode( + DateTime(day.year, day.month, day.day).toUtc().toIso8601String(), + ), + ); +} + +/// Requests a derived hmac key corresponding to [day] using. +Future getDailySecret(DateTime day) async { + final api = kms.CloudKMSApi(await authClient()); + final response = await api + .projects + .locations + .keyRings + .cryptoKeys + .cryptoKeyVersions + .macSign( + kms.MacSignRequest() + ..dataAsBytes = utf8.encode( + DateTime(day.year, day.month, day.day).toUtc().toIso8601String(), + ), + Platform.environment['HMAC_KEY_ID']!, + ); + return response.macAsBytes as Uint8List; +} + +bool _constantTimeEquals(Uint8List a, Uint8List b) { + if (a.length != b.length) return false; + bool answer = true; + for (var i = 0; i < a.length; i++) { + answer &= a[i] == b[i]; + } + return answer; +} + +// The client used for requesting the images. +// Using raw dart:io client such that we can disable autoUncompress. +final HttpClient client = HttpClient() + ..autoUncompress = false + ..connectionTimeout = Duration(seconds: 10) + ..idleTimeout = Duration(seconds: 15); + +final maxImageSize = 1024 * 1024 * 10; // At most 10 MB. + +Future handler(shelf.Request request) async { + try { + if (request.method != 'GET') { + return shelf.Response.notFound('Unsupported method'); + } + final segments = request.url.pathSegments; + if (segments.length != 3) { + return shelf.Response.badRequest( + body: + 'malformed request, ${segments.length} should be of the form //', + ); + } + final Uint8List signature; + try { + signature = base64Decode(segments[0]); + } on FormatException catch (_) { + return shelf.Response.badRequest( + body: 'malformed request, could not decode mac signature', + ); + } + final date = int.tryParse(segments[1]); + if (date == null) { + return shelf.Response.badRequest(body: 'malformed request, missing date'); + } + final secret = allowedKeys[date]; + if (secret == null) { + return shelf.Response.badRequest( + body: 'malformed request, proxy url expired', + ); + } + + final imageUrl = segments[2]; + if (imageUrl.length > 1024) { + return shelf.Response.badRequest(body: 'proxied url too long'); + } + final imageUrlBytes = utf8.encode(imageUrl); + + if (_constantTimeEquals(hmacSign(secret, imageUrlBytes), signature)) { + final Uri parsedImageUrl; + try { + parsedImageUrl = Uri.parse(imageUrl); + } on FormatException catch (e) { + return shelf.Response.badRequest(body: 'Malformed proxied url $e'); + } + if (!(parsedImageUrl.isScheme('http') || + parsedImageUrl.isScheme('https'))) { + return shelf.Response.badRequest( + body: 'Can only proxy http and https urls', + ); + } + if (!parsedImageUrl.isAbsolute) { + return shelf.Response.badRequest(body: 'Can only proxy absolute urls'); + } + + int statusCode; + List bytes; + String? contentType; + String? contentEncoding; + try { + (statusCode, bytes, contentType, contentEncoding) = await retry( + maxDelay: timeoutDelay, + maxAttempts: isTesting ? 2 : 8, + () async { + final request = await client.getUrl(parsedImageUrl); + Timer? timeoutTimer; + void scheduleRequestTimeout() { + timeoutTimer?.cancel(); + timeoutTimer = Timer(timeoutDelay, () { + request.abort(RequestTimeoutException('No response')); + }); + } + + request.headers.add( + 'user-agent', + 'Image proxy for pub.dev. See https://github.com/dart-lang/pub-dev/pkg/image-proxy. If you have any issues, contact support@pub.dev.', + ); + request.followRedirects = false; + scheduleRequestTimeout(); + var response = await request.close(); + var redirectCount = 0; + while (response.isRedirect) { + await response.drain(); + redirectCount++; + if (redirectCount > 10) { + throw RedirectException('Too many redirects.'); + } + final location = response.headers.value( + HttpHeaders.locationHeader, + ); + if (location == null) { + throw RedirectException('No location header in redirect.'); + } + final uri = parsedImageUrl.resolve(location); + final request = await client.getUrl(uri); + + request.headers.add('user-agent', 'pub-proxy'); + // Set the body or headers as desired. + request.followRedirects = false; + scheduleRequestTimeout(); + response = await request.close(); + } + switch (response.statusCode) { + case final int statusCode && >= 500 && < 600: + throw ServerSideException(statusCode: statusCode); + case final int statusCode && >= 300 && < 400: + throw ServerSideException(statusCode: statusCode); + } + final contentLength = response.contentLength; + if (contentLength != -1 && contentLength > maxImageSize) { + throw TooLargeException(); + } + return ( + response.statusCode, + await readAllBytes( + response, + contentLength == -1 ? maxImageSize : contentLength, + ).timeout( + timeoutDelay, + onTimeout: () { + throw RequestTimeoutException('No response'); + }, + ), + response.headers.value('content-type'), + response.headers.value('content-encoding'), + ); + }, + retryIf: (e) => + e is SocketException || + e is http.ClientException || + e is ServerSideException, + ); + } on TooLargeException { + return shelf.Response.badRequest(body: 'Image too large'); + } on RedirectException catch (e) { + return shelf.Response.badRequest(body: e.message); + } on RequestTimeoutException catch (e) { + return shelf.Response.badRequest(body: e.message); + } on ServerSideException catch (e) { + return shelf.Response.badRequest( + body: 'Failed to retrieve image. Status code ${e.statusCode}', + ); + } + + return shelf.Response( + statusCode, + body: bytes, + headers: { + 'Cache-control': 'max-age=180, public', + 'content-type': ?contentType, + 'content-encoding': ?contentEncoding, + }, + ); + } else { + return shelf.Response.unauthorized('Bad hmac'); + } + } catch (e, st) { + stderr.writeln('Uncaught error: $e $st'); + rethrow; + } +} + +void main(List args) async { + await updateAllowedKeys(); + Timer.periodic(Duration(hours: 1), (_) => updateAllowedKeys()); + final server = await serve( + handler, + InternetAddress.anyIPv6, + int.tryParse(Platform.environment['IMAGE_PROXY_PORT'] ?? '') ?? 80, + ); + print('Serving image proxy on ${server.address}:${server.port}'); +} + +class TooLargeException implements Exception { + TooLargeException(); +} + +class ServerSideException implements Exception { + int statusCode; + ServerSideException({required this.statusCode}); +} + +class RedirectException implements Exception { + String message; + RedirectException(this.message); +} + +class RequestTimeoutException implements Exception { + String message; + RequestTimeoutException(this.message); +} + +Uint8List hmacSign(Uint8List key, Uint8List imageUrlBytes) { + return Hmac(sha256, key).convert(imageUrlBytes).bytes as Uint8List; +} + +Future readAllBytes(Stream> stream, int maxBytes) async { + final builder = BytesBuilder(); + + await for (final chunk in stream) { + if (builder.length + chunk.length > maxBytes) { + throw TooLargeException(); + } + builder.add(chunk); + } + return builder.takeBytes(); +} diff --git a/pkg/image_proxy/pubspec.yaml b/pkg/image_proxy/pubspec.yaml new file mode 100644 index 0000000000..637dcb0c24 --- /dev/null +++ b/pkg/image_proxy/pubspec.yaml @@ -0,0 +1,18 @@ +name: pub_dev_image_proxy +environment: + sdk: ^3.9.0-0 + +resolution: workspace + +dependencies: + shelf: ^1.4.2 + crypto: ^3.0.6 + http: ^1.5.0 + retry: ^3.1.2 + gcloud: ^0.8.19 + googleapis_auth: ^2.0.0 + googleapis: ^14.0.0 + yaml: ^3.1.3 +dev_dependencies: + test: + lints: diff --git a/pkg/image_proxy/test/image_proxy_test.dart b/pkg/image_proxy/test/image_proxy_test.dart new file mode 100644 index 0000000000..11334fe9f2 --- /dev/null +++ b/pkg/image_proxy/test/image_proxy_test.dart @@ -0,0 +1,448 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:pub_dev_image_proxy/image_proxy_service.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:test/test.dart'; + +Future startImageProxy() async { + final p = await Process.start( + Platform.resolvedExecutable, + ['run', '-r', 'bin/server.dart'], + environment: {'IMAGE_PROXY_TESTING': 'true', 'IMAGE_PROXY_PORT': '0'}, + ); + addTearDown(() => p.kill()); + int? port; + await for (final line in LineSplitter().bind(Utf8Decoder().bind(p.stdout))) { + if (line.startsWith('Serving image proxy on')) { + port = int.parse(line.split(':').last); + break; + } + } + return port!; +} + +Stream> infiniteStream() async* { + while (true) { + yield List.generate(1000, (i) => 0); + } +} + +Future startImageServer() async { + var i = 0; + final server = await shelf_io.serve( + (shelf.Request request) async { + switch (request.url.path) { + case 'path/to/image.jpg': + return shelf.Response.ok( + File(jpgImagePath).readAsBytesSync(), + headers: {'content-type': 'image/jpeg'}, + ); + case 'path/to/image.png': + return shelf.Response.ok( + File(pngImagePath).readAsBytesSync(), + headers: {'content-type': 'image/png'}, + ); + case 'path/to/image.svg': + return shelf.Response.ok( + GZipCodec().encode(File(svgImagePath).readAsBytesSync()), + headers: { + 'content-type': 'image/svg+xml', + 'Content-Encoding': 'gzip', + }, + ); + case 'canBeCachedLong': + return shelf.Response.ok( + File(jpgImagePath).readAsBytesSync(), + headers: { + 'content-type': 'image/jpeg', + 'cache-control': 'max-age=20000, public', + }, + ); + case 'redirect': + return shelf.Response.movedPermanently('path/to/image.jpg'); + case 'redirectForever': + return shelf.Response.movedPermanently('redirectForever'); + case 'serverError': + return shelf.Response.internalServerError(); + case 'worksSecondTime': + if (i++ == 0) { + return shelf.Response.internalServerError(); + } + return shelf.Response.ok( + File(jpgImagePath).readAsBytesSync(), + headers: {'content-type': 'image/jpeg'}, + ); + case 'timeout': + await Future.delayed(Duration(hours: 1)); + return shelf.Response.notFound('Not found'); + case 'timeoutstreaming': + late final StreamController lateStreamController; + lateStreamController = StreamController( + onListen: () async { + // Return a single byte, and then stall. + lateStreamController.add([1]); + await Future.delayed(Duration(hours: 1)); + await lateStreamController.close(); + }, + ); + return shelf.Response.ok( + lateStreamController.stream, + headers: {'content-type': 'image/jpeg'}, + ); + case 'okstreaming': + return shelf.Response.ok( + // Has no content-length + File(jpgImagePath).openRead(), + headers: {'content-type': 'image/jpeg'}, + ); + case 'toobig': + return shelf.Response.ok( + infiniteStream(), + headers: { + 'content-type': 'image/jpeg', + 'content-length': '100000000', + }, + ); + case 'toobigstreaming': + return shelf.Response.ok( + infiniteStream(), + headers: {'content-type': 'image/jpeg'}, + ); + default: + return shelf.Response.notFound('Not found'); + } + }, + 'localhost', + 0, + ); + return server.port; +} + +final jpgImagePath = 'test/test_data/image.jpg'; +final pngImagePath = 'test/test_data/image.png'; +final svgImagePath = 'test/test_data/image.svg'; + +final now = DateTime.now(); +final yesterday = DateTime(now.year, now.month, now.day - 1); +final today = DateTime(now.year, now.month, now.day); +final tomorrow = DateTime(now.year, now.month, now.day + 1); + +Future getImage({ + required int imageProxyPort, + required int imageServerPort, + String pathToImage = 'path/to/image.jpg', + DateTime? day, + bool disturbSignature = false, +}) async { + final client = HttpClient(); + day ??= today; + final dailySecret = await getDailySecretMock(day); + final imageUrl = 'http://localhost:$imageServerPort/$pathToImage'; + final signature = hmacSign(dailySecret, utf8.encode(imageUrl)); + if (disturbSignature) { + signature[0]++; + } + final url = Uri( + scheme: 'http', + host: 'localhost', + port: imageProxyPort, + pathSegments: [ + base64Encode(signature), + day.millisecondsSinceEpoch.toString(), + imageUrl, + ], + ); + final request = await client.getUrl(url); + return await request.close(); +} + +Future main() async { + test('Can proxy images', () async { + final imageProxyPort = await startImageProxy(); + final imageServerPort = await startImageServer(); + + for (final day in [yesterday, today, tomorrow]) { + final response = await getImage( + day: day, + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + ); + expect(response.statusCode, 200); + expect(response.headers['content-type']!.single, 'image/jpeg'); + expect(response.headers['cache-control']!.single, 'max-age=180, public'); + + final hash = await sha256.bind(response).single; + final expected = sha256.convert(File(jpgImagePath).readAsBytesSync()); + expect(hash, expected); + } + + { + final response = await getImage( + day: today, + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + pathToImage: 'path/to/image.png', + ); + expect(response.statusCode, 200); + expect(response.headers['content-type']!.single, 'image/png'); + final hash = await sha256.bind(response).single; + final expected = sha256.convert(File(pngImagePath).readAsBytesSync()); + expect(hash, expected); + } + + { + final response = await getImage( + day: today, + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + pathToImage: 'path/to/image.svg', + ); + expect(response.statusCode, 200); + expect(response.headers['content-type']!.single, 'image/svg+xml'); + final hash = await sha256.bind(response).single; + final expected = sha256.convert(File(svgImagePath).readAsBytesSync()); + expect(hash, expected); + } + + { + final response = await getImage( + day: today, + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + // Gives no content-length + pathToImage: 'okstreaming', + ); + expect(response.statusCode, 200); + expect(response.headers['content-type']!.single, 'image/jpeg'); + final jpgFile = File(jpgImagePath).readAsBytesSync(); + expect(response.contentLength, jpgFile.length); + final hash = await sha256.bind(response).single; + final expected = sha256.convert(jpgFile); + expect(hash, expected); + } + }); + + test('Fails on days outside recent range', () async { + final imageProxyPort = await startImageProxy(); + final imageServerPort = await startImageServer(); + { + final response = await getImage( + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + day: tomorrow.add(Duration(days: 1)), + ); + expect(response.statusCode, 400); + + expect( + await Utf8Codec().decodeStream(response), + 'malformed request, proxy url expired', + ); + } + }); + + test('Fails with bad hmac', () async { + final imageProxyPort = await startImageProxy(); + final imageServerPort = await startImageServer(); + { + final response = await getImage( + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + day: today, + disturbSignature: true, + ); + expect(response.statusCode, 401); + + expect(await Utf8Codec().decodeStream(response), 'Bad hmac'); + } + }); + + test('Fails with too long query url', () async { + final imageProxyPort = await startImageProxy(); + final imageServerPort = await startImageServer(); + { + final response = await getImage( + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + day: today, + disturbSignature: true, + pathToImage: 'next/' * 1000 + 'image.jpg', + ); + expect(response.statusCode, 400); + + expect(await Utf8Codec().decodeStream(response), 'proxied url too long'); + } + }); + + test('Follows redirect', () async { + final imageProxyPort = await startImageProxy(); + final imageServerPort = await startImageServer(); + { + final response = await getImage( + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + day: today, + pathToImage: 'redirect', + ); + + expect(response.statusCode, 200); + final hash = await sha256.bind(response).single; + final expected = sha256.convert(File(jpgImagePath).readAsBytesSync()); + expect(hash, expected); + } + }); + + test('Fails with infinite redirect', () async { + final imageProxyPort = await startImageProxy(); + final imageServerPort = await startImageServer(); + { + final response = await getImage( + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + day: today, + pathToImage: 'redirectForever', + ); + + expect(await Utf8Codec().decodeStream(response), 'Too many redirects.'); + expect(response.statusCode, 400); + } + }); + + test('Fails with 500', () async { + final imageProxyPort = await startImageProxy(); + final imageServerPort = await startImageServer(); + { + final response = await getImage( + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + day: today, + pathToImage: 'serverError', + ); + + expect( + await Utf8Codec().decodeStream(response), + 'Failed to retrieve image. Status code 500', + ); + expect(response.statusCode, 400); + } + }); + + test('Fails on 4xx', () async { + final imageProxyPort = await startImageProxy(); + final imageServerPort = await startImageServer(); + { + final response = await getImage( + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + day: today, + pathToImage: 'doesntexist', + ); + + expect(await Utf8Codec().decodeStream(response), 'Not found'); + expect(response.statusCode, 404); + } + }); + + test('Retries 500', () async { + final imageProxyPort = await startImageProxy(); + final imageServerPort = await startImageServer(); + { + final response = await getImage( + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + day: today, + pathToImage: 'worksSecondTime', + ); + + expect(response.statusCode, 200); + final hash = await sha256.bind(response).single; + final expected = sha256.convert(File(jpgImagePath).readAsBytesSync()); + expect(hash, expected); + } + }); + + test('Doesn\'t forward cache header', () async { + final imageProxyPort = await startImageProxy(); + final imageServerPort = await startImageServer(); + { + final response = await getImage( + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + day: today, + pathToImage: 'canBeCachedLong', + ); + + expect(response.statusCode, 200); + // The proxy doesn't cache as long time as the original. + expect(response.headers['cache-control']!.single, 'max-age=180, public'); + final hash = await sha256.bind(response).single; + final expected = sha256.convert(File(jpgImagePath).readAsBytesSync()); + expect(hash, expected); + } + }); + + test('times out', () async { + final imageProxyPort = await startImageProxy(); + final imageServerPort = await startImageServer(); + { + final response = await getImage( + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + day: today, + pathToImage: 'timeout', + ); + + expect(response.statusCode, 400); + // The proxy doesn't cache as long time as the original. + expect(await Utf8Codec().decodeStream(response), 'No response'); + } + { + final response = await getImage( + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + day: today, + pathToImage: 'timeoutstreaming', + ); + + expect(response.statusCode, 400); + // The proxy doesn't cache as long time as the original. + expect(await Utf8Codec().decodeStream(response), 'No response'); + } + }); + + test('protects against too big files', () async { + final imageProxyPort = await startImageProxy(); + final imageServerPort = await startImageServer(); + { + final response = await getImage( + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + day: today, + pathToImage: 'toobig', + ); + + expect(response.statusCode, 400); + // The proxy doesn't cache as long time as the original. + expect(await Utf8Codec().decodeStream(response), 'Image too large'); + } + { + final response = await getImage( + imageProxyPort: imageProxyPort, + imageServerPort: imageServerPort, + day: today, + pathToImage: 'toobigstreaming', + ); + + expect(response.statusCode, 400); + // The proxy doesn't cache as long time as the original. + expect(await Utf8Codec().decodeStream(response), 'Image too large'); + } + }); +} diff --git a/pkg/image_proxy/test/test_data/image.jpg b/pkg/image_proxy/test/test_data/image.jpg new file mode 100644 index 0000000000..a77cb57c14 Binary files /dev/null and b/pkg/image_proxy/test/test_data/image.jpg differ diff --git a/pkg/image_proxy/test/test_data/image.png b/pkg/image_proxy/test/test_data/image.png new file mode 100644 index 0000000000..2b149017d5 Binary files /dev/null and b/pkg/image_proxy/test/test_data/image.png differ diff --git a/pkg/image_proxy/test/test_data/image.svg b/pkg/image_proxy/test/test_data/image.svg new file mode 100644 index 0000000000..ccc5b2d47d --- /dev/null +++ b/pkg/image_proxy/test/test_data/image.svg @@ -0,0 +1,248 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + + diff --git a/pubspec.yaml b/pubspec.yaml index 056a4fdfa4..d29bd4f52b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,7 @@ workspace: - pkg/indexed_blob - pkg/pub_package_reader - pkg/puppeteer_screenshots + - pkg/image_proxy dev_dependencies: lints: ^6.0.0