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