From 51b73300b2ad473e4cad62e77206000c652849f0 Mon Sep 17 00:00:00 2001 From: Robert-Jan Huijsman <22160949+rjhuijsman@users.noreply.github.com> Date: Wed, 14 Apr 2021 16:31:40 +0200 Subject: [PATCH] feat(v2 backend): update FCM token directly in Firestore (#1981) Before this PR, we would update the client's FCM token by writing to the putClientSettings HTTPS endpoint. With this PR, we remove that intermediate API layer and write directly to Firestore. We don't currently use the FCM token, so the main purposes of this PR are to... * Demonstrate how we'll perform direct Firestore access. * Demonstrate how to configure the app to use the local Firebase (Firestore) emulator. * Prepare the application for later use of the FCM token. Closes #1973 --- client/README.md | 22 +++++++ client/lib/api/who_service.dart | 32 +++++----- client/lib/main.dart | 17 ++++++ client/pubspec.lock | 45 ++++++++++++-- client/pubspec.yaml | 1 + client/test/firebase_test.dart | 14 +++++ server/firestore.rules | 17 +++++- server/functions/src/firestore_rules.spec.ts | 16 ++++- server/functions/src/index.ts | 63 -------------------- 9 files changed, 139 insertions(+), 88 deletions(-) create mode 100644 client/test/firebase_test.dart diff --git a/client/README.md b/client/README.md index 742f332daf..8d60ebc17f 100644 --- a/client/README.md +++ b/client/README.md @@ -53,3 +53,25 @@ For example to access the `staging` server (the default), the files are: Android: client/android/app/src//google-services.json iOS: client/ios/config//GoogleService-Info.plist ``` + +##### Using the Firebase Emulators + +If you'd like to test your app without using a "real" Firebase project, you can use the Firebase Local Emulator Suite as follows. + +First, start your local emulators by navigating to your `server/functions` directory and running: + + firebase emulators:start --project=dev + +Then, in your `client` directory, update your `main.dart` as follows: + + const USE_FIREBASE_LOCAL_EMULATORS = true; + +When working with the iOS client, temporarily disable transport security [as documented here](https://firebase.flutter.dev/docs/installation/ios/#enabling-use-of-firebase-emulator-suite). + +Then, run your application in its `hack` flavor: + + flutter run --flavor=hack + +Your logs will confirm that you are using the local emulators: + + I/flutter (13491): Will use local 🔥🔥 Firebase 🔥🔥 emulator diff --git a/client/lib/api/who_service.dart b/client/lib/api/who_service.dart index e75893e2db..523b0e8369 100644 --- a/client/lib/api/who_service.dart +++ b/client/lib/api/who_service.dart @@ -7,12 +7,18 @@ import 'dart:io' as io; import 'package:who_app/proto/api/who/who.pb.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_performance/firebase_performance.dart'; +const CLIENT_COLLECTION = 'Client'; + class WhoService { final String serviceUrl; + final FirebaseFirestore firestore; - WhoService({@required String endpoint}) : serviceUrl = endpoint; + WhoService({@required String endpoint}) + : serviceUrl = endpoint, + firestore = FirebaseFirestore.instance; final _MetricHttpClient http = _MetricHttpClient( Client(), @@ -20,19 +26,17 @@ class WhoService { /// Put Client Settings Future putClientSettings({String token, String isoCountryCode}) async { - final headers = await _getHeaders(); - final req = PutClientSettingsRequest.create(); - if (token != null) { - req.token = token; - } else { - req.clearToken(); - } - req.isoCountryCode = isoCountryCode; - final postBody = jsonEncode(req.toProto3Json()); - final url = '$serviceUrl/putClientSettings'; - final response = await http.post(url, headers: headers, body: postBody); - if (response.statusCode != 200) { - throw Exception('Error status code: ${response.statusCode}'); + var clientId = await UserPreferences().getClientUuid(); + try { + await firestore.collection(CLIENT_COLLECTION).doc(clientId).set({ + 'uuid': clientId, + 'token': token, + 'disableNotifications': token == null || token.isEmpty, + 'platform': _platform, + 'isoCountryCode': isoCountryCode + }); + } catch (e) { + debugPrint('Failed to update FCM token in Firestore: $e'); } return true; } diff --git a/client/lib/main.dart b/client/lib/main.dart index ca8400bce5..da28b1036c 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_performance/firebase_performance.dart'; import 'package:flutter/material.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; @@ -37,6 +38,10 @@ PackageInfo _packageInfo; PackageInfo get packageInfo => _packageInfo; +// ATTENTION: never check this in as 'true'! Always set back to 'false' before +// sending out your PR. This is verified by `test/firebase_test.dart`. +const USE_FIREBASE_LOCAL_EMULATORS = false; + void main() async { await mainImpl(routes: Routes.map); } @@ -51,6 +56,18 @@ void mainImpl({@required Map routes}) async { } var app = await Firebase.initializeApp(); + + if (USE_FIREBASE_LOCAL_EMULATORS) { + debugPrint('Will use local 🔥🔥 Firebase 🔥🔥 emulator'); + // Switch Firebase host based on platform, since iOS and Android + // use different ways of contacting localhost. + var host = defaultTargetPlatform == TargetPlatform.android + ? '10.0.2.2:8080' + : 'localhost:8080'; + FirebaseFirestore.instance.settings = + Settings(host: host, sslEnabled: false); + } + var projectId = app.options.projectId; print('Firebase ProjectID: $projectId'); var endpoint = Endpoint(projectId); diff --git a/client/pubspec.lock b/client/pubspec.lock index 075be8c5a2..c9ff15f771 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -134,6 +134,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0-nullsafety.1" + cloud_firestore: + dependency: "direct main" + description: + name: cloud_firestore + url: "https://pub.dartlang.org" + source: hosted + version: "0.14.4" + cloud_firestore_platform_interface: + dependency: transitive + description: + name: cloud_firestore_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" + cloud_firestore_web: + dependency: transitive + description: + name: cloud_firestore_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.1+2" code_builder: dependency: transitive description: @@ -252,7 +273,7 @@ packages: name: firebase_analytics url: "https://pub.dartlang.org" source: hosted - version: "6.0.2" + version: "6.3.0" firebase_analytics_platform_interface: dependency: transitive description: @@ -294,28 +315,35 @@ packages: name: firebase_crashlytics url: "https://pub.dartlang.org" source: hosted - version: "0.2.3" + version: "0.2.4" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.1.3" + version: "1.1.4" firebase_messaging: dependency: "direct main" description: name: firebase_messaging url: "https://pub.dartlang.org" source: hosted - version: "8.0.0-dev.8" + version: "8.0.0-dev.11" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.0-dev.5" + version: "1.0.0-dev.7" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0-dev.2" firebase_performance: dependency: "direct main" description: @@ -693,6 +721,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.3" + service_worker: + dependency: transitive + description: + name: service_worker + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.4" share: dependency: "direct main" description: diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 42636d4e4f..f1932989a5 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: sdk: flutter auto_size_text: 2.1.0 + cloud_firestore: 0.14.4 connectivity: ^2.0.0 cupertino_icons: ^0.1.3 expressions: 0.1.5 diff --git a/client/test/firebase_test.dart b/client/test/firebase_test.dart new file mode 100644 index 0000000000..bf3e1ca385 --- /dev/null +++ b/client/test/firebase_test.dart @@ -0,0 +1,14 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:who_app/main.dart'; + +void main() { + test( + 'Firebase Emulator usage has been disabled', + () { + expect( + USE_FIREBASE_LOCAL_EMULATORS, + false, + ); + }, + ); +} diff --git a/server/firestore.rules b/server/firestore.rules index d91e38c151..283678ddb0 100644 --- a/server/firestore.rules +++ b/server/firestore.rules @@ -1,10 +1,21 @@ rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { - // Currently: do not allow any direct reads or writes. - // TODO: this will change as we update the client to do direct reads and writes. + // Default: do not allow any direct reads or writes unless otherwise specified. match /{document=**} { allow read, write: if false; } + + // Allow writes to the "Clients" collection, permitting users to set their FCM + // tokens. + // + // If we were concerned about abuse, we could add constraints around what the user + // needs to be writing to be allowed to write. Fortunately since this is write-only, + // and since the WHO client ID is a long random string that can't be guessed by others + // (preventing others from overwriting your settings) there seems to be little risk + // of abuse, and we can suffice with a simple rule. + match /Client/{who_client_id} { + allow write: if true; + } } -} \ No newline at end of file +} diff --git a/server/functions/src/firestore_rules.spec.ts b/server/functions/src/firestore_rules.spec.ts index 088fa43b2b..3b25a2bce5 100644 --- a/server/functions/src/firestore_rules.spec.ts +++ b/server/functions/src/firestore_rules.spec.ts @@ -36,7 +36,7 @@ after(() => { }); describe("Firebase Rules", () => { - it("Should not allow direct access anywhere", async () => { + it("Should not allow access in random places", async () => { // Random location in the database. await firebase.assertFails( app @@ -45,7 +45,8 @@ describe("Firebase Rules", () => { .doc("nonexistent-doc") .get() ); - // The Client collection. + }); + it("Should only allow write access to the Client collection", async () => { await firebase.assertFails( app .firestore() @@ -53,6 +54,15 @@ describe("Firebase Rules", () => { .doc("00000000-0000-0000-0000-000000000000") .get() ); - // Add tests for real-life collections and documents here as we add them to Firestore. + await firebase.assertSucceeds( + app + .firestore() + .collection("Client") + .doc("00000000-0000-0000-0000-000000000000") + .set({ + foo: "bar", + }) + ); }); + // Add further tests for real-life collections and documents here as we add them to Firestore. }); diff --git a/server/functions/src/index.ts b/server/functions/src/index.ts index 89b18483e6..a03df9dbf6 100644 --- a/server/functions/src/index.ts +++ b/server/functions/src/index.ts @@ -39,69 +39,6 @@ export const getCaseStats = functions response.status(200).json(data); }); -// Implementation of the v1 API"s `putClientSettings` method. -// TODO: replace with direct Firestore acccess from the client. -export const putClientSettings = functions - .region(SERVING_REGION) - .https.onRequest((request, response) => { - const whoClientId = request.header("Who-Client-ID"); - if (whoClientId === undefined || whoClientId == null) { - response.status(400).send("Missing Who-Client-ID header"); - return; - } - const whoPlatform = request.header("Who-Platform"); - if (whoPlatform === undefined || whoPlatform == null) { - response.status(400).send("Missing Who-Platform header"); - return; - } - let platform = Platform.WEB; - if (whoPlatform == Platform[Platform.ANDROID]) { - platform = Platform.ANDROID; - } else if (whoPlatform == Platform[Platform.IOS]) { - platform = Platform.IOS; - } - - if (request.method != "POST") { - response.status(400).send("Call must be POST request"); - return; - } - let isoCountryCode = request.body["isoCountryCode"]; - if (isoCountryCode === undefined || isoCountryCode == null) { - isoCountryCode = ""; - } else if ( - // Don"t even run a regex on a very long string. - isoCountryCode.length != 2 || - !isoCountryCode.match(COUNTRY_CODE) - ) { - response.status(400).send("Invalid isoCountryCode"); - return; - } - let fcmToken = request.body["token"]; - if (fcmToken === undefined || fcmToken == "null") { - fcmToken = ""; - } - if (fcmToken.length > FCM_TOKEN_MAX_LENGTH) { - response.status(400).send("Invalid FCM Token"); - return; - } - const disableNotifications = fcmToken.length == 0; - - const client = { - uuid: whoClientId, - token: fcmToken, - disableNotifcations: disableNotifications, - platform: platform, - isoCountryCode: isoCountryCode, - subscribedTopics: [], // TODO: fill in. - } as Client; - - // This update will trigger the `clientSettingsUpdated` method below, - // which will actually register (or deregister) the client for notifications. - db.collection("Clients").doc(whoClientId).set(client); - - response.status(200).send({}); - }); - // Method that runs when client settings have been updated, and will make those // updated settings take effect. export const clientSettingsUpdated = functions