diff --git a/lib/api/api_provider.dart b/lib/api/api_provider.dart index 1d3745f..bc50f8a 100644 --- a/lib/api/api_provider.dart +++ b/lib/api/api_provider.dart @@ -7,7 +7,9 @@ import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:vaani/db/cache_manager.dart'; +import 'package:vaani/models/error_response.dart'; import 'package:vaani/settings/api_settings_provider.dart'; +import 'package:vaani/settings/models/authenticated_user.dart'; import 'package:vaani/shared/extensions/obfuscation.dart'; part 'api_provider.g.dart'; @@ -49,6 +51,7 @@ AudiobookshelfApi authenticatedApi(AuthenticatedApiRef ref) { final apiSettings = ref.watch(apiSettingsProvider); final user = apiSettings.activeUser; if (user == null) { + _logger.severe('No active user can not provide authenticated api'); throw StateError('No active user'); } return AudiobookshelfApi( @@ -97,17 +100,26 @@ class PersonalizedView extends _$PersonalizedView { final api = ref.watch(authenticatedApiProvider); final apiSettings = ref.watch(apiSettingsProvider); final user = apiSettings.activeUser; + if (user == null) { + _logger.warning('no active user'); + yield []; + return; + } if (apiSettings.activeLibraryId == null) { // set it to default user library by logging in and getting the library id - final login = - await api.login(username: user!.username!, password: user.password!); + final login = await ref.read(loginProvider().future); + if (login == null) { + _logger.shout('failed to login, not building personalized view'); + yield []; + return; + } ref.read(apiSettingsProvider.notifier).updateState( - apiSettings.copyWith(activeLibraryId: login!.userDefaultLibraryId), + apiSettings.copyWith(activeLibraryId: login.userDefaultLibraryId), ); } // try to find in cache // final cacheKey = 'personalizedView:${apiSettings.activeLibraryId}'; - var key = 'personalizedView:${apiSettings.activeLibraryId! + user!.id!}'; + final key = 'personalizedView:${apiSettings.activeLibraryId! + user.id}'; final cachedRes = await apiResponseCacheManager.getFileFromMemory( key, ) ?? @@ -127,7 +139,7 @@ class PersonalizedView extends _$PersonalizedView { } } - // ! exagerated delay + // ! exaggerated delay // await Future.delayed(const Duration(seconds: 2)); final res = await api.libraries .getPersonalized(libraryId: apiSettings.activeLibraryId!); @@ -151,6 +163,7 @@ class PersonalizedView extends _$PersonalizedView { // method to force refresh the view and ignore the cache Future forceRefresh() async { // clear the cache + // TODO: find a better way to clear the cache for only personalized view key return apiResponseCacheManager.emptyCache(); } } @@ -173,6 +186,47 @@ FutureOr me( MeRef ref, ) async { final api = ref.watch(authenticatedApiProvider); - final res = await api.me.getUser(); - return res!; + final errorResponseHandler = ErrorResponseHandler(); + final res = await api.me.getUser( + responseErrorHandler: errorResponseHandler.storeError, + ); + if (res == null) { + _logger.severe( + 'me failed, got response: ${errorResponseHandler.response.obfuscate()}', + ); + throw StateError('me failed'); + } + return res; +} + +@riverpod +FutureOr login( + LoginRef ref, { + AuthenticatedUser? user, +}) async { + if (user == null) { + // try to get the user from settings + final apiSettings = ref.watch(apiSettingsProvider); + user = apiSettings.activeUser; + if (user == null) { + _logger.severe('no active user to login'); + return null; + } + _logger.fine('no user provided, using active user: ${user.obfuscate()}'); + } + final api = ref.watch(audiobookshelfApiProvider(user.server.serverUrl)); + api.token = user.authToken; + var errorResponseHandler = ErrorResponseHandler(); + _logger.fine('logging in with authenticated api'); + final res = await api.misc.authorize( + responseErrorHandler: errorResponseHandler.storeError, + ); + if (res == null) { + _logger.severe( + 'login failed, got response: ${errorResponseHandler.response.obfuscate()}', + ); + return null; + } + _logger.fine('login response: ${res.obfuscate()}'); + return res; } diff --git a/lib/api/api_provider.g.dart b/lib/api/api_provider.g.dart index 7758c7b..796adf7 100644 --- a/lib/api/api_provider.g.dart +++ b/lib/api/api_provider.g.dart @@ -168,7 +168,7 @@ class _AudiobookshelfApiProviderElement Uri? get baseUrl => (origin as AudiobookshelfApiProvider).baseUrl; } -String _$authenticatedApiHash() => r'f555efb6eede590b5a8d60cad2e6bfc2847e2d14'; +String _$authenticatedApiHash() => r'e662465f01ab1a6384db4738a3ae49b5fab48a4f'; /// get the api instance for the authenticated user /// @@ -507,7 +507,7 @@ final fetchContinueListeningProvider = typedef FetchContinueListeningRef = AutoDisposeFutureProviderRef; -String _$meHash() => r'bdc664c4fd867ad13018fa769ce7a6913248c44f'; +String _$meHash() => r'da5f40b8063b0c0a6651fdcc4ac2d192d0dc7df6'; /// See also [me]. @ProviderFor(me) @@ -521,7 +521,134 @@ final meProvider = AutoDisposeFutureProvider.internal( ); typedef MeRef = AutoDisposeFutureProviderRef; -String _$personalizedViewHash() => r'4c392ece4650bdc36d7195a0ddb8810e8fe4caa9'; +String _$loginHash() => r'eb1c4fcef1818dce994846c1adb8eca8f6ec9259'; + +/// See also [login]. +@ProviderFor(login) +const loginProvider = LoginFamily(); + +/// See also [login]. +class LoginFamily extends Family> { + /// See also [login]. + const LoginFamily(); + + /// See also [login]. + LoginProvider call({ + AuthenticatedUser? user, + }) { + return LoginProvider( + user: user, + ); + } + + @override + LoginProvider getProviderOverride( + covariant LoginProvider provider, + ) { + return call( + user: provider.user, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'loginProvider'; +} + +/// See also [login]. +class LoginProvider extends AutoDisposeFutureProvider { + /// See also [login]. + LoginProvider({ + AuthenticatedUser? user, + }) : this._internal( + (ref) => login( + ref as LoginRef, + user: user, + ), + from: loginProvider, + name: r'loginProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$loginHash, + dependencies: LoginFamily._dependencies, + allTransitiveDependencies: LoginFamily._allTransitiveDependencies, + user: user, + ); + + LoginProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.user, + }) : super.internal(); + + final AuthenticatedUser? user; + + @override + Override overrideWith( + FutureOr Function(LoginRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: LoginProvider._internal( + (ref) => create(ref as LoginRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + user: user, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _LoginProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is LoginProvider && other.user == user; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, user.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin LoginRef on AutoDisposeFutureProviderRef { + /// The parameter `user` of this provider. + AuthenticatedUser? get user; +} + +class _LoginProviderElement + extends AutoDisposeFutureProviderElement with LoginRef { + _LoginProviderElement(super.provider); + + @override + AuthenticatedUser? get user => (origin as LoginProvider).user; +} + +String _$personalizedViewHash() => r'65c0bc60e312d290498ab488496495114d407ccb'; /// fetch the personalized view /// diff --git a/lib/features/onboarding/view/onboarding_single_page.dart b/lib/features/onboarding/view/onboarding_single_page.dart index 5ceff08..79f0d63 100644 --- a/lib/features/onboarding/view/onboarding_single_page.dart +++ b/lib/features/onboarding/view/onboarding_single_page.dart @@ -17,7 +17,7 @@ class OnboardingSinglePage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final apiSettings = ref.watch(apiSettingsProvider); final serverUriController = useTextEditingController( - text: apiSettings.activeServer?.serverUrl.toString() ?? '', + text: apiSettings.activeServer?.serverUrl.toString() ?? 'https://', ); var audiobookshelfUri = makeBaseUrl(serverUriController.text); diff --git a/lib/features/onboarding/view/user_login_with_password.dart b/lib/features/onboarding/view/user_login_with_password.dart index 91eeea0..43a52e5 100644 --- a/lib/features/onboarding/view/user_login_with_password.dart +++ b/lib/features/onboarding/view/user_login_with_password.dart @@ -76,7 +76,6 @@ class UserLoginWithPassword extends HookConsumerWidget { final authenticatedUser = model.AuthenticatedUser( server: addServer(), id: success.user.id, - password: password, username: username, authToken: api.token!, ); diff --git a/lib/models/error_response.dart b/lib/models/error_response.dart index 954f3f3..13352be 100644 --- a/lib/models/error_response.dart +++ b/lib/models/error_response.dart @@ -7,14 +7,18 @@ final _logger = Logger('ErrorResponse'); class ErrorResponseHandler { String? name; http.Response _response; + bool logRawResponse; ErrorResponseHandler({ this.name, http.Response? response, + this.logRawResponse = false, }) : _response = response ?? http.Response('', 418); void storeError(http.Response response, [Object? error]) { - _logger.fine('for $name got response: ${response.obfuscate()}'); + if (logRawResponse) { + _logger.fine('for $name got response: ${response.obfuscate()}'); + } _response = response; } diff --git a/lib/settings/models/authenticated_user.dart b/lib/settings/models/authenticated_user.dart index 04f2bd0..321c885 100644 --- a/lib/settings/models/authenticated_user.dart +++ b/lib/settings/models/authenticated_user.dart @@ -10,9 +10,8 @@ class AuthenticatedUser with _$AuthenticatedUser { const factory AuthenticatedUser({ required AudiobookShelfServer server, required String authToken, - String? id, + required String id, String? username, - String? password, }) = _AuthenticatedUser; factory AuthenticatedUser.fromJson(Map json) => diff --git a/lib/settings/models/authenticated_user.freezed.dart b/lib/settings/models/authenticated_user.freezed.dart index e582928..2a7ce53 100644 --- a/lib/settings/models/authenticated_user.freezed.dart +++ b/lib/settings/models/authenticated_user.freezed.dart @@ -22,9 +22,8 @@ AuthenticatedUser _$AuthenticatedUserFromJson(Map json) { mixin _$AuthenticatedUser { AudiobookShelfServer get server => throw _privateConstructorUsedError; String get authToken => throw _privateConstructorUsedError; - String? get id => throw _privateConstructorUsedError; + String get id => throw _privateConstructorUsedError; String? get username => throw _privateConstructorUsedError; - String? get password => throw _privateConstructorUsedError; /// Serializes this AuthenticatedUser to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -45,9 +44,8 @@ abstract class $AuthenticatedUserCopyWith<$Res> { $Res call( {AudiobookShelfServer server, String authToken, - String? id, - String? username, - String? password}); + String id, + String? username}); $AudiobookShelfServerCopyWith<$Res> get server; } @@ -69,9 +67,8 @@ class _$AuthenticatedUserCopyWithImpl<$Res, $Val extends AuthenticatedUser> $Res call({ Object? server = null, Object? authToken = null, - Object? id = freezed, + Object? id = null, Object? username = freezed, - Object? password = freezed, }) { return _then(_value.copyWith( server: null == server @@ -82,18 +79,14 @@ class _$AuthenticatedUserCopyWithImpl<$Res, $Val extends AuthenticatedUser> ? _value.authToken : authToken // ignore: cast_nullable_to_non_nullable as String, - id: freezed == id + id: null == id ? _value.id : id // ignore: cast_nullable_to_non_nullable - as String?, + as String, username: freezed == username ? _value.username : username // ignore: cast_nullable_to_non_nullable as String?, - password: freezed == password - ? _value.password - : password // ignore: cast_nullable_to_non_nullable - as String?, ) as $Val); } @@ -119,9 +112,8 @@ abstract class _$$AuthenticatedUserImplCopyWith<$Res> $Res call( {AudiobookShelfServer server, String authToken, - String? id, - String? username, - String? password}); + String id, + String? username}); @override $AudiobookShelfServerCopyWith<$Res> get server; @@ -142,9 +134,8 @@ class __$$AuthenticatedUserImplCopyWithImpl<$Res> $Res call({ Object? server = null, Object? authToken = null, - Object? id = freezed, + Object? id = null, Object? username = freezed, - Object? password = freezed, }) { return _then(_$AuthenticatedUserImpl( server: null == server @@ -155,18 +146,14 @@ class __$$AuthenticatedUserImplCopyWithImpl<$Res> ? _value.authToken : authToken // ignore: cast_nullable_to_non_nullable as String, - id: freezed == id + id: null == id ? _value.id : id // ignore: cast_nullable_to_non_nullable - as String?, + as String, username: freezed == username ? _value.username : username // ignore: cast_nullable_to_non_nullable as String?, - password: freezed == password - ? _value.password - : password // ignore: cast_nullable_to_non_nullable - as String?, )); } } @@ -177,9 +164,8 @@ class _$AuthenticatedUserImpl implements _AuthenticatedUser { const _$AuthenticatedUserImpl( {required this.server, required this.authToken, - this.id, - this.username, - this.password}); + required this.id, + this.username}); factory _$AuthenticatedUserImpl.fromJson(Map json) => _$$AuthenticatedUserImplFromJson(json); @@ -189,15 +175,13 @@ class _$AuthenticatedUserImpl implements _AuthenticatedUser { @override final String authToken; @override - final String? id; + final String id; @override final String? username; - @override - final String? password; @override String toString() { - return 'AuthenticatedUser(server: $server, authToken: $authToken, id: $id, username: $username, password: $password)'; + return 'AuthenticatedUser(server: $server, authToken: $authToken, id: $id, username: $username)'; } @override @@ -210,15 +194,12 @@ class _$AuthenticatedUserImpl implements _AuthenticatedUser { other.authToken == authToken) && (identical(other.id, id) || other.id == id) && (identical(other.username, username) || - other.username == username) && - (identical(other.password, password) || - other.password == password)); + other.username == username)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => - Object.hash(runtimeType, server, authToken, id, username, password); + int get hashCode => Object.hash(runtimeType, server, authToken, id, username); /// Create a copy of AuthenticatedUser /// with the given fields replaced by the non-null parameter values. @@ -241,9 +222,8 @@ abstract class _AuthenticatedUser implements AuthenticatedUser { const factory _AuthenticatedUser( {required final AudiobookShelfServer server, required final String authToken, - final String? id, - final String? username, - final String? password}) = _$AuthenticatedUserImpl; + required final String id, + final String? username}) = _$AuthenticatedUserImpl; factory _AuthenticatedUser.fromJson(Map json) = _$AuthenticatedUserImpl.fromJson; @@ -253,11 +233,9 @@ abstract class _AuthenticatedUser implements AuthenticatedUser { @override String get authToken; @override - String? get id; + String get id; @override String? get username; - @override - String? get password; /// Create a copy of AuthenticatedUser /// with the given fields replaced by the non-null parameter values. diff --git a/lib/settings/models/authenticated_user.g.dart b/lib/settings/models/authenticated_user.g.dart index 0752807..4ff5a06 100644 --- a/lib/settings/models/authenticated_user.g.dart +++ b/lib/settings/models/authenticated_user.g.dart @@ -12,9 +12,8 @@ _$AuthenticatedUserImpl _$$AuthenticatedUserImplFromJson( server: AudiobookShelfServer.fromJson(json['server'] as Map), authToken: json['authToken'] as String, - id: json['id'] as String?, + id: json['id'] as String, username: json['username'] as String?, - password: json['password'] as String?, ); Map _$$AuthenticatedUserImplToJson( @@ -24,5 +23,4 @@ Map _$$AuthenticatedUserImplToJson( 'authToken': instance.authToken, 'id': instance.id, 'username': instance.username, - 'password': instance.password, }; diff --git a/lib/shared/extensions/obfuscation.dart b/lib/shared/extensions/obfuscation.dart index c70715a..6ff85fe 100644 --- a/lib/shared/extensions/obfuscation.dart +++ b/lib/shared/extensions/obfuscation.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; +import 'package:shelfsdk/audiobookshelf_api.dart' as shelfsdk; import 'package:vaani/settings/models/api_settings.dart'; import 'package:vaani/settings/models/audiobookshelf_server.dart'; import 'package:vaani/settings/models/authenticated_user.dart'; @@ -67,7 +68,6 @@ extension ObfuscateAuthenticatedUser on AuthenticatedUser { return this; } return copyWith( - password: password == null ? null : 'passwordObfuscated', username: username == null ? null : 'usernameObfuscated', authToken: 'authTokenObfuscated', server: server.obfuscate(), @@ -116,10 +116,54 @@ extension ObfuscateResponse on http.Response { return this; } return http.Response( - body, + obfuscateBody(), statusCode, headers: headers, request: request?.obfuscate(), ); } + + String obfuscateBody() { + if (!kReleaseMode) { + return body; + } + // replace any email addresses with emailObfuscated + // replace any phone numbers with phoneObfuscated + // replace any urls with urlObfuscated + // replace any tokens with tokenObfuscated + // token regex is `"token": "..."` + return body + .replaceAll( + RegExp(r'(\b\w+@\w+\.\w+\b)|' + r'(\b\d{3}-\d{3}-\d{4}\b)|' + r'(\bhttps?://\S+\b)'), + 'obfuscated', + ) + .replaceAll( + RegExp(r'"?token"?:?\s*"[^"]+"'), + '"token": "tokenObfuscated"', + ); + } +} + +extension ObfuscateLoginResponse on shelfsdk.LoginResponse { + shelfsdk.LoginResponse obfuscate() { + if (!kReleaseMode) { + return this; + } + return copyWith( + user: user.obfuscate(), + ); + } +} + +extension ObfuscateUser on shelfsdk.User { + shelfsdk.User obfuscate() { + if (!kReleaseMode) { + return this; + } + return shelfsdk.User.fromJson( + toJson()..['token'] = 'tokenObfuscated', + ); + } } diff --git a/lib/shared/widgets/add_new_server.dart b/lib/shared/widgets/add_new_server.dart index 9114c2b..a0c10e6 100644 --- a/lib/shared/widgets/add_new_server.dart +++ b/lib/shared/widgets/add_new_server.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:vaani/api/api_provider.dart'; +import 'package:vaani/main.dart'; + +final httpUrlRegExp = RegExp('https?://'); class AddNewServer extends HookConsumerWidget { const AddNewServer({ @@ -25,7 +28,8 @@ class AddNewServer extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final myController = controller ?? useTextEditingController(); + final myController = + controller ?? useTextEditingController(text: 'https://'); var newServerURI = useValueListenable(myController); final isServerAlive = ref.watch(isServerAliveProvider(newServerURI.text)); bool isServerAliveValue = isServerAlive.when( @@ -34,15 +38,33 @@ class AddNewServer extends HookConsumerWidget { error: (error, _) => false, ); + Uri parsedUri = Uri.parse(''); + + try { + parsedUri = Uri.parse(newServerURI.text); + } on FormatException { + // prepend https:// if not present + if (!newServerURI.text.startsWith(httpUrlRegExp)) { + myController.text = 'https://${newServerURI.text}'; + parsedUri = Uri.parse(myController.text); + } + } catch (e) { + // do nothing + appLogger.severe('Error parsing URI: $e'); + } + final canSubmit = !readOnly && + (isServerAliveValue || (allowEmpty && newServerURI.text.isEmpty)); return TextFormField( readOnly: readOnly, controller: controller, keyboardType: TextInputType.url, autofillHints: const [AutofillHints.url], textInputAction: TextInputAction.done, - onFieldSubmitted: (_) { - onPressed?.call(); - }, + onFieldSubmitted: canSubmit + ? (_) { + onPressed?.call(); + } + : null, decoration: InputDecoration( labelText: 'Server URI', labelStyle: TextStyle( @@ -50,8 +72,8 @@ class AddNewServer extends HookConsumerWidget { ), border: const OutlineInputBorder(), prefixText: - myController.text.startsWith(RegExp('https?://')) ? '' : 'https://', - prefixIcon: ServerAliveIcon(server: Uri.parse(newServerURI.text)), + myController.text.startsWith(httpUrlRegExp) ? '' : 'https://', + prefixIcon: ServerAliveIcon(server: parsedUri), // add server button suffixIcon: onPressed == null @@ -65,10 +87,10 @@ class AddNewServer extends HookConsumerWidget { focusColor: Theme.of(context).colorScheme.onSurface, // should be enabled when - onPressed: !readOnly && - (isServerAliveValue || - (allowEmpty && newServerURI.text.isEmpty)) - ? onPressed + onPressed: canSubmit + ? () { + onPressed?.call(); + } : null, // disable button if server is not alive ), ),