From d2a5cccd3ad99f31c8a9523678c0be05b3bbd0e1 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Mon, 6 Mar 2023 11:23:27 -0600 Subject: [PATCH] feat(shorebird_code_push_api): engine download endpoint and api key verification (#17) --- .../shorebird_code_push_api/bin/server.dart | 12 ++++- .../lib/src/http_client.dart | 19 +++++++ .../lib/src/middleware/api_key_verifier.dart | 13 +++++ .../src/middleware/http_client_provider.dart | 12 +++++ .../lib/src/middleware/middleware.dart | 2 + .../download_engine/download_engine.dart | 1 + .../download_engine_handler.dart | 26 ++++++++++ .../lib/src/routes/routes.dart | 1 + packages/shorebird_code_push_api/pubspec.yaml | 2 + .../src/middleware/api_key_verifier_test.dart | 49 +++++++++++++++++++ .../middleware/http_client_provider_test.dart | 24 +++++++++ .../download_engine_handler_test.dart | 45 +++++++++++++++++ 12 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 packages/shorebird_code_push_api/lib/src/http_client.dart create mode 100644 packages/shorebird_code_push_api/lib/src/middleware/api_key_verifier.dart create mode 100644 packages/shorebird_code_push_api/lib/src/middleware/http_client_provider.dart create mode 100644 packages/shorebird_code_push_api/lib/src/routes/download_engine/download_engine.dart create mode 100644 packages/shorebird_code_push_api/lib/src/routes/download_engine/download_engine_handler.dart create mode 100644 packages/shorebird_code_push_api/test/src/middleware/api_key_verifier_test.dart create mode 100644 packages/shorebird_code_push_api/test/src/middleware/http_client_provider_test.dart create mode 100644 packages/shorebird_code_push_api/test/src/routes/download_engine/download_engine_handler_test.dart diff --git a/packages/shorebird_code_push_api/bin/server.dart b/packages/shorebird_code_push_api/bin/server.dart index 46548c45..d8e2f988 100644 --- a/packages/shorebird_code_push_api/bin/server.dart +++ b/packages/shorebird_code_push_api/bin/server.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:shelf/shelf.dart'; @@ -12,10 +13,19 @@ Future main() async { ..all('/', (_) => Response(HttpStatus.noContent)) ..post('/api/v1/updates', checkForUpdatesHandler) ..get('/api/v1/releases/', downloadReleaseHandler) - ..post('/api/v1/releases', uploadReleaseHandler); + ..post('/api/v1/releases', uploadReleaseHandler) + ..get('/api/v1/engines/', downloadEngineHandler); + + final apiKeys = json.decode( + Platform.environment['CODE_PUSH_API_KEYS'] ?? '[]', + ) as List; + + final gcpKey = Platform.environment['GCP_SA'] ?? ''; final handler = const Pipeline() .addMiddleware(versionStoreProvider) + .addMiddleware(httpClientProvider(gcpKey)) + .addMiddleware(apiKeyVerifier(keys: apiKeys.cast())) .addHandler(router.call); final server = await shelf_io.serve( diff --git a/packages/shorebird_code_push_api/lib/src/http_client.dart b/packages/shorebird_code_push_api/lib/src/http_client.dart new file mode 100644 index 00000000..04571799 --- /dev/null +++ b/packages/shorebird_code_push_api/lib/src/http_client.dart @@ -0,0 +1,19 @@ +import 'package:googleapis_auth/auth_io.dart'; +import 'package:http/http.dart' as http; + +const _scopes = [ + // Cloud Storage + 'https://www.googleapis.com/auth/devstorage.read_write', +]; + +Future createClient(String key) async { + try { + // coverage:ignore-start + final serviceAccount = ServiceAccountCredentials.fromJson(key); + final client = await clientViaServiceAccount(serviceAccount, _scopes); + return client; + // coverage:ignore-end + } catch (_) { + return http.Client(); + } +} diff --git a/packages/shorebird_code_push_api/lib/src/middleware/api_key_verifier.dart b/packages/shorebird_code_push_api/lib/src/middleware/api_key_verifier.dart new file mode 100644 index 00000000..0ef41cac --- /dev/null +++ b/packages/shorebird_code_push_api/lib/src/middleware/api_key_verifier.dart @@ -0,0 +1,13 @@ +import 'dart:io'; + +import 'package:shelf/shelf.dart'; + +Middleware apiKeyVerifier({List keys = const []}) { + return (handler) { + return (request) async { + final apiKey = request.headers['x-api-key']; + if (!keys.contains(apiKey)) return Response(HttpStatus.unauthorized); + return handler(request); + }; + }; +} diff --git a/packages/shorebird_code_push_api/lib/src/middleware/http_client_provider.dart b/packages/shorebird_code_push_api/lib/src/middleware/http_client_provider.dart new file mode 100644 index 00000000..a7ddd0a8 --- /dev/null +++ b/packages/shorebird_code_push_api/lib/src/middleware/http_client_provider.dart @@ -0,0 +1,12 @@ +import 'package:http/http.dart' as http; +import 'package:shelf/shelf.dart'; +import 'package:shorebird_code_push_api/src/http_client.dart'; +import 'package:shorebird_code_push_api/src/provider.dart'; + +Middleware httpClientProvider(String key) { + return provider>( + (_) async => _httpClient ??= createClient(key), + ); +} + +Future? _httpClient; diff --git a/packages/shorebird_code_push_api/lib/src/middleware/middleware.dart b/packages/shorebird_code_push_api/lib/src/middleware/middleware.dart index 036aa574..83dce027 100644 --- a/packages/shorebird_code_push_api/lib/src/middleware/middleware.dart +++ b/packages/shorebird_code_push_api/lib/src/middleware/middleware.dart @@ -1 +1,3 @@ +export 'api_key_verifier.dart'; +export 'http_client_provider.dart'; export 'version_store_provider.dart'; diff --git a/packages/shorebird_code_push_api/lib/src/routes/download_engine/download_engine.dart b/packages/shorebird_code_push_api/lib/src/routes/download_engine/download_engine.dart new file mode 100644 index 00000000..5cb510aa --- /dev/null +++ b/packages/shorebird_code_push_api/lib/src/routes/download_engine/download_engine.dart @@ -0,0 +1 @@ +export 'download_engine_handler.dart'; diff --git a/packages/shorebird_code_push_api/lib/src/routes/download_engine/download_engine_handler.dart b/packages/shorebird_code_push_api/lib/src/routes/download_engine/download_engine_handler.dart new file mode 100644 index 00000000..99e5579b --- /dev/null +++ b/packages/shorebird_code_push_api/lib/src/routes/download_engine/download_engine_handler.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:shelf/shelf.dart'; +import 'package:shorebird_code_push_api/src/provider.dart'; + +final _engineUrl = Uri.parse( + 'https://storage.googleapis.com/download/storage/v1/b/shorebird-code-push-api.appspot.com/o/${Uri.encodeComponent('engines/engine.zip')}?alt=media', +); + +Future downloadEngineHandler(Request request, String revision) async { + final httpClient = await request.lookup>(); + final response = await httpClient.get( + _engineUrl, + headers: { + 'Content-Type': 'application/octet-stream', + 'Connection': 'close' + }, + ); + + if (response.statusCode != HttpStatus.ok) { + return Response(response.statusCode, body: response.body); + } + + return Response.ok(response.bodyBytes); +} diff --git a/packages/shorebird_code_push_api/lib/src/routes/routes.dart b/packages/shorebird_code_push_api/lib/src/routes/routes.dart index 7fb726d7..9832524a 100644 --- a/packages/shorebird_code_push_api/lib/src/routes/routes.dart +++ b/packages/shorebird_code_push_api/lib/src/routes/routes.dart @@ -1,3 +1,4 @@ export 'check_for_updates/check_for_updates.dart'; +export 'download_engine/download_engine.dart'; export 'download_release/download_release.dart'; export 'upload_release/upload_release.dart'; diff --git a/packages/shorebird_code_push_api/pubspec.yaml b/packages/shorebird_code_push_api/pubspec.yaml index b8540cad..1561032f 100644 --- a/packages/shorebird_code_push_api/pubspec.yaml +++ b/packages/shorebird_code_push_api/pubspec.yaml @@ -7,6 +7,8 @@ environment: sdk: ">=2.19.0 <3.0.0" dependencies: + googleapis_auth: ^1.3.1 + http: ^0.13.5 path: ^1.8.3 shelf: ^1.4.0 shelf_multipart: ^1.0.0 diff --git a/packages/shorebird_code_push_api/test/src/middleware/api_key_verifier_test.dart b/packages/shorebird_code_push_api/test/src/middleware/api_key_verifier_test.dart new file mode 100644 index 00000000..cd19d161 --- /dev/null +++ b/packages/shorebird_code_push_api/test/src/middleware/api_key_verifier_test.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:shelf/shelf.dart'; +import 'package:shorebird_code_push_api/src/middleware/middleware.dart'; +import 'package:test/test.dart'; + +void main() { + group('apiKeyVerifier', () { + const keys = ['valid-key']; + + test('returns 401 if no key is provided', () async { + final handler = const Pipeline() + .addMiddleware(apiKeyVerifier()) + .addHandler((_) => Response.ok('OK')); + + final request = Request('GET', Uri.parse('http://localhost/')); + final response = await handler(request); + expect(response.statusCode, equals(HttpStatus.unauthorized)); + }); + + test('returns 401 if key is invalid', () async { + final handler = const Pipeline() + .addMiddleware(apiKeyVerifier(keys: keys)) + .addHandler((_) => Response.ok('OK')); + + final request = Request( + 'GET', + Uri.parse('http://localhost/'), + headers: {'x-api-key': 'invalid-key'}, + ); + final response = await handler(request); + expect(response.statusCode, equals(HttpStatus.unauthorized)); + }); + + test('returns 200 if key is valid', () async { + final handler = const Pipeline() + .addMiddleware(apiKeyVerifier(keys: keys)) + .addHandler((_) => Response.ok('OK')); + + final request = Request( + 'GET', + Uri.parse('http://localhost/'), + headers: {'x-api-key': 'valid-key'}, + ); + final response = await handler(request); + expect(response.statusCode, equals(HttpStatus.ok)); + }); + }); +} diff --git a/packages/shorebird_code_push_api/test/src/middleware/http_client_provider_test.dart b/packages/shorebird_code_push_api/test/src/middleware/http_client_provider_test.dart new file mode 100644 index 00000000..b570ae57 --- /dev/null +++ b/packages/shorebird_code_push_api/test/src/middleware/http_client_provider_test.dart @@ -0,0 +1,24 @@ +import 'package:http/http.dart' as http; +import 'package:shelf/shelf.dart'; +import 'package:shorebird_code_push_api/src/middleware/middleware.dart'; +import 'package:shorebird_code_push_api/src/provider.dart'; +import 'package:test/test.dart'; + +void main() { + group('httpClientProvider', () { + test('provides an http client instance', () async { + Future? client; + + final handler = httpClientProvider('')( + (req) async { + client = req.lookup>(); + return Response.ok(''); + }, + ); + final request = Request('GET', Uri.parse('http://localhost/')); + + await handler(request); + expect(client, isNotNull); + }); + }); +} diff --git a/packages/shorebird_code_push_api/test/src/routes/download_engine/download_engine_handler_test.dart b/packages/shorebird_code_push_api/test/src/routes/download_engine/download_engine_handler_test.dart new file mode 100644 index 00000000..35e6dd37 --- /dev/null +++ b/packages/shorebird_code_push_api/test/src/routes/download_engine/download_engine_handler_test.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shorebird_code_push_api/src/provider.dart'; +import 'package:shorebird_code_push_api/src/routes/download_engine/download_engine.dart'; +import 'package:test/test.dart'; + +class _MockHttpClient extends Mock implements http.Client {} + +void main() { + group('downloadEngineHandler', () { + final uri = Uri.parse('http://localhost/'); + late http.Client httpClient; + + setUpAll(() { + registerFallbackValue(Uri()); + }); + + setUp(() { + httpClient = _MockHttpClient(); + }); + + test('returns error on failure', () async { + when( + () => httpClient.get(any(), headers: any(named: 'headers')), + ).thenAnswer((_) async => http.Response('oops', HttpStatus.unauthorized)); + final request = Request('GET', uri).provide(() async => httpClient); + + final response = await downloadEngineHandler(request, 'revision'); + expect(response.statusCode, equals(HttpStatus.unauthorized)); + }); + + test('returns bytes on success', () async { + when( + () => httpClient.get(any(), headers: any(named: 'headers')), + ).thenAnswer((_) async => http.Response('OK', HttpStatus.ok)); + final request = Request('GET', uri).provide(() async => httpClient); + + final response = await downloadEngineHandler(request, 'revision'); + expect(response.statusCode, equals(HttpStatus.ok)); + }); + }); +}