// @dart=2.9 import 'dart:async'; import 'dart:typed_data'; import 'package:bip39/bip39.dart' as bip39; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_sodium/flutter_sodium.dart'; import 'package:logging/logging.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/core/network.dart'; import 'package:photos/db/public_keys_db.dart'; import 'package:photos/events/two_factor_status_change_event.dart'; import 'package:photos/events/user_details_changed_event.dart'; import 'package:photos/models/delete_account.dart'; import 'package:photos/models/key_attributes.dart'; import 'package:photos/models/key_gen_result.dart'; import 'package:photos/models/public_key.dart'; import 'package:photos/models/sessions.dart'; import 'package:photos/models/set_keys_request.dart'; import 'package:photos/models/set_recovery_key_request.dart'; import 'package:photos/models/user_details.dart'; import 'package:photos/ui/account/login_page.dart'; import 'package:photos/ui/account/ott_verification_page.dart'; import 'package:photos/ui/account/password_entry_page.dart'; import 'package:photos/ui/account/password_reentry_page.dart'; import 'package:photos/ui/account/two_factor_authentication_page.dart'; import 'package:photos/ui/account/two_factor_recovery_page.dart'; import 'package:photos/ui/account/two_factor_setup_page.dart'; import 'package:photos/utils/crypto_util.dart'; import 'package:photos/utils/dialog_util.dart'; import 'package:photos/utils/navigation_util.dart'; import 'package:photos/utils/toast_util.dart'; class UserService { final _dio = Network.instance.getDio(); final _enteDio = Network.instance.enteDio; final _logger = Logger((UserService).toString()); final _config = Configuration.instance; ValueNotifier emailValueNotifier; UserService._privateConstructor(); static final UserService instance = UserService._privateConstructor(); Future init() async { emailValueNotifier = ValueNotifier(Configuration.instance.getEmail()); } Future sendOtt( BuildContext context, String email, { bool isChangeEmail = false, bool isCreateAccountScreen = false, }) async { final dialog = createProgressDialog(context, "Please wait..."); await dialog.show(); try { final response = await _dio.post( _config.getHttpEndpoint() + "/users/ott", data: {"email": email, "purpose": isChangeEmail ? "change" : ""}, ); await dialog.hide(); if (response != null && response.statusCode == 200) { unawaited( Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) { return OTTVerificationPage( email, isChangeEmail: isChangeEmail, isCreateAccountScreen: isCreateAccountScreen, ); }, ), ), ); return; } unawaited(showGenericErrorDialog(context)); } on DioError catch (e) { await dialog.hide(); _logger.info(e); if (e.response != null && e.response.statusCode == 403) { unawaited( showErrorDialog( context, "Oops", "This email is already in use", ), ); } else { unawaited(showGenericErrorDialog(context)); } } catch (e) { await dialog.hide(); _logger.severe(e); unawaited(showGenericErrorDialog(context)); } } Future getPublicKey(String email) async { try { final response = await _enteDio.get( "/users/public-key", queryParameters: {"email": email}, ); final publicKey = response.data["publicKey"]; await PublicKeysDB.instance.setKey(PublicKey(email, publicKey)); return publicKey; } on DioError catch (e) { _logger.info(e); return null; } } Future getUserDetailsV2({bool memoryCount = true}) async { try { final response = await _enteDio.get( "/users/details/v2?memoryCount=$memoryCount", queryParameters: { "memoryCount": memoryCount, }, ); return UserDetails.fromMap(response.data); } on DioError catch (e) { _logger.info(e); rethrow; } } Future getActiveSessions() async { try { final response = await _enteDio.get("/users/sessions"); return Sessions.fromMap(response.data); } on DioError catch (e) { _logger.info(e); rethrow; } } Future terminateSession(String token) async { try { await _enteDio.delete( "/users/session", queryParameters: { "token": token, }, ); } on DioError catch (e) { _logger.info(e); rethrow; } } Future leaveFamilyPlan() async { try { await _enteDio.delete("/family/leave"); } on DioError catch (e) { _logger.warning('failed to leave family plan', e); rethrow; } } Future logout(BuildContext context) async { final dialog = createProgressDialog(context, "Logging out..."); await dialog.show(); try { final response = await _enteDio.post("/users/logout"); if (response != null && response.statusCode == 200) { await Configuration.instance.logout(); await dialog.hide(); Navigator.of(context).popUntil((route) => route.isFirst); } else { throw Exception("Log out action failed"); } } catch (e) { _logger.severe(e); await dialog.hide(); showGenericErrorDialog(context); } } Future getDeleteChallenge( BuildContext context, ) async { final dialog = createProgressDialog(context, "Please wait..."); await dialog.show(); try { final response = await _enteDio.get("/users/delete-challenge"); if (response != null && response.statusCode == 200) { // clear data await dialog.hide(); return DeleteChallengeResponse( allowDelete: response.data["allowDelete"] as bool, encryptedChallenge: response.data["encryptedChallenge"], ); } else { throw Exception("delete action failed"); } } catch (e) { _logger.severe(e); await dialog.hide(); await showGenericErrorDialog(context); return null; } } Future deleteAccount( BuildContext context, String challengeResponse, ) async { final dialog = createProgressDialog(context, "Deleting account..."); await dialog.show(); try { final response = await _enteDio.delete( "/users/delete", data: { "challenge": challengeResponse, }, ); if (response != null && response.statusCode == 200) { // clear data await Configuration.instance.logout(); await dialog.hide(); showToast( context, "We have deleted your account and scheduled your uploaded data " "for deletion.", ); Navigator.of(context).popUntil((route) => route.isFirst); } else { throw Exception("delete action failed"); } } catch (e) { _logger.severe(e); await dialog.hide(); showGenericErrorDialog(context); } } Future verifyEmail(BuildContext context, String ott) async { final dialog = createProgressDialog(context, "Please wait..."); await dialog.show(); try { final response = await _dio.post( _config.getHttpEndpoint() + "/users/verify-email", data: { "email": _config.getEmail(), "ott": ott, }, ); await dialog.hide(); if (response != null && response.statusCode == 200) { Widget page; final String twoFASessionID = response.data["twoFactorSessionID"]; if (twoFASessionID != null && twoFASessionID.isNotEmpty) { page = TwoFactorAuthenticationPage(twoFASessionID); } else { await _saveConfiguration(response); if (Configuration.instance.getEncryptedToken() != null) { page = const PasswordReentryPage(); } else { page = const PasswordEntryPage(); } } Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (BuildContext context) { return page; }, ), (route) => route.isFirst, ); } else { // should never reach here throw Exception("unexpected response during email verification"); } } on DioError catch (e) { _logger.info(e); await dialog.hide(); if (e.response != null && e.response.statusCode == 410) { await showErrorDialog( context, "Oops", "Your verification code has expired", ); Navigator.of(context).pop(); } else { showErrorDialog( context, "Incorrect code", "Sorry, the code you've entered is incorrect", ); } } catch (e) { await dialog.hide(); _logger.severe(e); showErrorDialog(context, "Oops", "Verification failed, please try again"); } } Future setEmail(String email) async { await _config.setEmail(email); emailValueNotifier.value = email ?? ""; } Future changeEmail( BuildContext context, String email, String ott, ) async { final dialog = createProgressDialog(context, "Please wait..."); await dialog.show(); try { final response = await _enteDio.post( "/users/change-email", data: { "email": email, "ott": ott, }, ); await dialog.hide(); if (response != null && response.statusCode == 200) { showToast(context, "Email changed to " + email); await setEmail(email); Navigator.of(context).popUntil((route) => route.isFirst); Bus.instance.fire(UserDetailsChangedEvent()); return; } showErrorDialog(context, "Oops", "Verification failed, please try again"); } on DioError catch (e) { await dialog.hide(); if (e.response != null && e.response.statusCode == 403) { showErrorDialog(context, "Oops", "This email is already in use"); } else { showErrorDialog( context, "Incorrect code", "Authentication failed, please try again", ); } } catch (e) { await dialog.hide(); _logger.severe(e); showErrorDialog(context, "Oops", "Verification failed, please try again"); } } Future setAttributes(KeyGenResult result) async { try { final name = _config.getName(); await _enteDio.put( "/users/attributes", data: { "name": name, "keyAttributes": result.keyAttributes.toMap(), }, ); await _config.setKey(result.privateKeyAttributes.key); await _config.setSecretKey(result.privateKeyAttributes.secretKey); await _config.setKeyAttributes(result.keyAttributes); } catch (e) { _logger.severe(e); rethrow; } } Future updateKeyAttributes(KeyAttributes keyAttributes) async { try { final setKeyRequest = SetKeysRequest( kekSalt: keyAttributes.kekSalt, encryptedKey: keyAttributes.encryptedKey, keyDecryptionNonce: keyAttributes.keyDecryptionNonce, memLimit: keyAttributes.memLimit, opsLimit: keyAttributes.opsLimit, ); await _enteDio.put( "/users/keys", data: setKeyRequest.toMap(), ); await _config.setKeyAttributes(keyAttributes); } catch (e) { _logger.severe(e); rethrow; } } Future setRecoveryKey(KeyAttributes keyAttributes) async { try { final setRecoveryKeyRequest = SetRecoveryKeyRequest( keyAttributes.masterKeyEncryptedWithRecoveryKey, keyAttributes.masterKeyDecryptionNonce, keyAttributes.recoveryKeyEncryptedWithMasterKey, keyAttributes.recoveryKeyDecryptionNonce, ); await _enteDio.put( "/users/recovery-key", data: setRecoveryKeyRequest.toMap(), ); await _config.setKeyAttributes(keyAttributes); } catch (e) { _logger.severe(e); rethrow; } } Future verifyTwoFactor( BuildContext context, String sessionID, String code, ) async { final dialog = createProgressDialog(context, "Authenticating..."); await dialog.show(); try { final response = await _dio.post( _config.getHttpEndpoint() + "/users/two-factor/verify", data: { "sessionID": sessionID, "code": code, }, ); await dialog.hide(); if (response != null && response.statusCode == 200) { showToast(context, "Authentication successful!"); await _saveConfiguration(response); Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (BuildContext context) { return const PasswordReentryPage(); }, ), (route) => route.isFirst, ); } } on DioError catch (e) { await dialog.hide(); _logger.severe(e); if (e.response != null && e.response.statusCode == 404) { showToast(context, "Session expired"); Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (BuildContext context) { return const LoginPage(); }, ), (route) => route.isFirst, ); } else { showErrorDialog( context, "Incorrect code", "Authentication failed, please try again", ); } } catch (e) { await dialog.hide(); _logger.severe(e); showErrorDialog( context, "Oops", "Authentication failed, please try again", ); } } Future recoverTwoFactor(BuildContext context, String sessionID) async { final dialog = createProgressDialog(context, "Please wait..."); await dialog.show(); try { final response = await _dio.get( _config.getHttpEndpoint() + "/users/two-factor/recover", queryParameters: { "sessionID": sessionID, }, ); if (response != null && response.statusCode == 200) { Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (BuildContext context) { return TwoFactorRecoveryPage( sessionID, response.data["encryptedSecret"], response.data["secretDecryptionNonce"], ); }, ), (route) => route.isFirst, ); } } on DioError catch (e) { _logger.severe(e); if (e.response != null && e.response.statusCode == 404) { showToast(context, "Session expired"); Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (BuildContext context) { return const LoginPage(); }, ), (route) => route.isFirst, ); } else { showErrorDialog( context, "Oops", "Something went wrong, please try again", ); } } catch (e) { _logger.severe(e); showErrorDialog( context, "Oops", "Something went wrong, please try again", ); } finally { await dialog.hide(); } } Future removeTwoFactor( BuildContext context, String sessionID, String recoveryKey, String encryptedSecret, String secretDecryptionNonce, ) async { final dialog = createProgressDialog(context, "Please wait..."); await dialog.show(); String secret; try { if (recoveryKey.contains(' ')) { if (recoveryKey.split(' ').length != mnemonicKeyWordCount) { throw AssertionError( 'recovery code should have $mnemonicKeyWordCount words', ); } recoveryKey = bip39.mnemonicToEntropy(recoveryKey); } secret = Sodium.bin2base64( await CryptoUtil.decrypt( Sodium.base642bin(encryptedSecret), Sodium.hex2bin(recoveryKey.trim()), Sodium.base642bin(secretDecryptionNonce), ), ); } catch (e) { await dialog.hide(); await showErrorDialog( context, "Incorrect recovery key", "The recovery key you entered is incorrect", ); return; } try { final response = await _dio.post( _config.getHttpEndpoint() + "/users/two-factor/remove", data: { "sessionID": sessionID, "secret": secret, }, ); if (response != null && response.statusCode == 200) { showShortToast(context, "Two-factor authentication successfully reset"); await _saveConfiguration(response); Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (BuildContext context) { return const PasswordReentryPage(); }, ), (route) => route.isFirst, ); } } on DioError catch (e) { _logger.severe(e); if (e.response != null && e.response.statusCode == 404) { showToast(context, "Session expired"); Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (BuildContext context) { return const LoginPage(); }, ), (route) => route.isFirst, ); } else { showErrorDialog( context, "Oops", "Something went wrong, please try again", ); } } catch (e) { _logger.severe(e); showErrorDialog( context, "Oops", "Something went wrong, please try again", ); } finally { await dialog.hide(); } } Future setupTwoFactor(BuildContext context) async { final dialog = createProgressDialog(context, "Please wait..."); await dialog.show(); try { final response = await _enteDio.post("/users/two-factor/setup"); await dialog.hide(); unawaited( routeToPage( context, TwoFactorSetupPage( response.data["secretCode"], response.data["qrCode"], ), ), ); } catch (e) { await dialog.hide(); _logger.severe("Failed to setup tfa", e); rethrow; } } Future enableTwoFactor( BuildContext context, String secret, String code, ) async { Uint8List recoveryKey; try { recoveryKey = await getOrCreateRecoveryKey(context); } catch (e) { showGenericErrorDialog(context); return false; } final dialog = createProgressDialog(context, "Verifying..."); await dialog.show(); final encryptionResult = CryptoUtil.encryptSync(Sodium.base642bin(secret), recoveryKey); try { await _enteDio.post( "/users/two-factor/enable", data: { "code": code, "encryptedTwoFactorSecret": Sodium.bin2base64(encryptionResult.encryptedData), "twoFactorSecretDecryptionNonce": Sodium.bin2base64(encryptionResult.nonce), }, ); await dialog.hide(); Navigator.pop(context); Bus.instance.fire(TwoFactorStatusChangeEvent(true)); return true; } catch (e, s) { await dialog.hide(); _logger.severe(e, s); if (e is DioError) { if (e.response != null && e.response.statusCode == 401) { showErrorDialog( context, "Incorrect code", "Please verify the code you have entered", ); return false; } } showErrorDialog( context, "Something went wrong", "Please contact support if the problem persists", ); } return false; } Future disableTwoFactor(BuildContext context) async { final dialog = createProgressDialog(context, "Disabling two-factor authentication..."); await dialog.show(); try { await _enteDio.post( "/users/two-factor/disable", ); Bus.instance.fire(TwoFactorStatusChangeEvent(false)); await dialog.hide(); unawaited( showToast( context, "Two-factor authentication has been disabled", ), ); } catch (e) { await dialog.hide(); _logger.severe("Failed to disabled 2FA", e); await showErrorDialog( context, "Something went wrong", "Please contact support if the problem persists", ); } } Future fetchTwoFactorStatus() async { try { final response = await _enteDio.get("/users/two-factor/status"); return response.data["status"]; } catch (e) { _logger.severe("Failed to fetch 2FA status", e); rethrow; } } Future getOrCreateRecoveryKey(BuildContext context) async { final encryptedRecoveryKey = _config.getKeyAttributes().recoveryKeyEncryptedWithMasterKey; if (encryptedRecoveryKey == null || encryptedRecoveryKey.isEmpty) { final dialog = createProgressDialog(context, "Please wait..."); await dialog.show(); try { final keyAttributes = await _config.createNewRecoveryKey(); await setRecoveryKey(keyAttributes); await dialog.hide(); } catch (e, s) { await dialog.hide(); _logger.severe(e, s); rethrow; } } final recoveryKey = _config.getRecoveryKey(); return recoveryKey; } Future getPaymentToken() async { try { final response = await _enteDio.get("/users/payment-token"); if (response != null && response.statusCode == 200) { return response.data["paymentToken"]; } else { throw Exception("non 200 ok response"); } } catch (e) { _logger.severe("Failed to get payment token", e); return null; } } Future getFamiliesToken() async { try { final response = await _enteDio.get("/users/families-token"); if (response != null && response.statusCode == 200) { return response.data["familiesToken"]; } else { throw Exception("non 200 ok response"); } } catch (e, s) { _logger.severe("failed to fetch families token", e, s); rethrow; } } Future _saveConfiguration(Response response) async { await Configuration.instance.setUserID(response.data["id"]); if (response.data["encryptedToken"] != null) { await Configuration.instance .setEncryptedToken(response.data["encryptedToken"]); await Configuration.instance.setKeyAttributes( KeyAttributes.fromMap(response.data["keyAttributes"]), ); } else { await Configuration.instance.setToken(response.data["token"]); } } }