From 69c643ab4458a80613669d159c8a7b8013445be6 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 23 May 2026 12:46:53 -0500 Subject: [PATCH] feat: canonical paths and error handling for invalid links * not found/aturi parsing guard --- lib/core/router/app_route_paths.dart | 18 +- lib/core/router/invalid_route_screen.dart | 42 +++++ lib/core/router/route_query.dart | 26 ++- .../routes/authenticated_shell_routes.dart | 14 +- lib/core/router/routes/public_routes.dart | 125 +++++++++++--- lib/core/router/routes/root_routes.dart | 160 ++++++++++++++++-- .../devtools/cubit/dev_tools_state.dart | 3 +- .../feed/cubit/feed_preferences_cubit.dart | 13 +- .../domain/notification_domain_service.dart | 3 +- .../domain/notification_reason_utils.dart | 4 +- .../profile/data/follow_audit_repository.dart | 12 +- .../profile/data/profile_repository.dart | 18 +- test/core/router/app_router_test.dart | 57 ++++++- .../data/follow_audit_repository_test.dart | 15 ++ .../profile_repository_actor_likes_test.dart | 19 ++- 15 files changed, 476 insertions(+), 53 deletions(-) create mode 100644 lib/core/router/invalid_route_screen.dart diff --git a/lib/core/router/app_route_paths.dart b/lib/core/router/app_route_paths.dart index badfcca..db5f51a 100644 --- a/lib/core/router/app_route_paths.dart +++ b/lib/core/router/app_route_paths.dart @@ -22,9 +22,12 @@ enum AppRoutePath { /// Feed detail route shared by authenticated and public contexts. feed, - /// Post thread route shared by authenticated and public contexts. + /// Post thread compatibility route using a query AT-URI. post, + /// Canonical post route using actor and record key path params. + postRecord, + /// Topic timeline route shared by authenticated and public contexts. topic, @@ -52,15 +55,21 @@ enum AppRoutePath { /// Lists overview route. lists, - /// List detail route. + /// List detail compatibility route using a query AT-URI. list, + /// Canonical list detail route using actor and record key path params. + listRecord, + /// Create starter pack route. createStarterPack, - /// Starter pack detail route. + /// Starter pack compatibility route using a query AT-URI. starterPack, + /// Canonical starter pack detail route using actor and record key path params. + starterPackRecord, + /// Actor starter packs route. starterPacks, @@ -113,6 +122,7 @@ enum AppRoutePath { AppRoutePath.hashtag => '/hashtag', AppRoutePath.feed => '/feed', AppRoutePath.post => '/post', + AppRoutePath.postRecord => '/profile/:actor/post/:rkey', AppRoutePath.topic => '/topic', AppRoutePath.publicProfile => r'/profile/:actor(m|[^m][^/]*|m[^e][^/]*|me[^/]+)', AppRoutePath.profileMe => '/profile/me', @@ -123,8 +133,10 @@ enum AppRoutePath { AppRoutePath.liked => '/liked', AppRoutePath.lists => '/lists', AppRoutePath.list => '/list', + AppRoutePath.listRecord => '/list/:actor/:rkey', AppRoutePath.createStarterPack => '/create-starter-pack', AppRoutePath.starterPack => '/starter-pack', + AppRoutePath.starterPackRecord => '/starter-pack/:actor/:rkey', AppRoutePath.starterPacks => '/starter-packs', AppRoutePath.alerts => '/alerts', AppRoutePath.notifications => '/notifications', diff --git a/lib/core/router/invalid_route_screen.dart b/lib/core/router/invalid_route_screen.dart new file mode 100644 index 0000000..e39dc95 --- /dev/null +++ b/lib/core/router/invalid_route_screen.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/router/app_route_paths.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; + +/// Route-level error screen for malformed or unsupported external links. +/// +/// Used when URL/path/query data cannot be parsed into the route's required +/// identifiers for direct app links where there may be no useful previous +/// screen to return to. +class InvalidRouteScreen extends StatelessWidget { + const InvalidRouteScreen({ + super.key, + this.title = 'Invalid link', + this.message = 'This link is invalid or no longer available.', + }); + + final String title; + final String message; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(title)), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.link_off, size: 48, color: context.colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Text(title, style: context.textTheme.headlineSmall, textAlign: TextAlign.center), + const SizedBox(height: 8), + Text(message, style: context.textTheme.bodyMedium, textAlign: TextAlign.center), + const SizedBox(height: 24), + FilledButton(onPressed: () => context.go(AppRoutePath.home.path), child: const Text('Go home')), + ], + ), + ), + ), + ); +} diff --git a/lib/core/router/route_query.dart b/lib/core/router/route_query.dart index 479a334..5ae07fb 100644 --- a/lib/core/router/route_query.dart +++ b/lib/core/router/route_query.dart @@ -1,4 +1,5 @@ import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/poptart_client_adapter.dart'; /// Convenience reader for decoded route query and path values. @@ -19,15 +20,34 @@ class RouteQuery { /// Returns a decoded query value trimmed, or an empty string when absent. String decodedOrEmpty(String key) => decoded(key)?.trim() ?? ''; + /// Returns whether a query value was present and not blank after decoding. + bool hasNonEmpty(String key) => decoded(key)?.trim().isNotEmpty == true; + /// Returns a decoded path parameter, or an empty string when absent. String decodedPathOrEmpty(String key) => Uri.decodeComponent(state.pathParameters[key] ?? ''); - /// Parses a decoded AT-URI query value, or returns `null` when absent/blank. - AtUri? atUri(String key) { + /// Parses a decoded AT-URI query value, or returns `null` when invalid. + AtUri? tryAtUri(String key) { final value = decoded(key); if (value == null || value.trim().isEmpty) { return null; } - return AtUri.parse(value); + final normalized = value.trim(); + if (!normalized.startsWith('at://')) { + log.d('Invalid AT-URI in route query "$key": missing at:// scheme'); + return null; + } + try { + return AtUri.parse(normalized); + } catch (error, stackTrace) { + log.d('Invalid AT-URI in route query "$key"', error: error, stackTrace: stackTrace); + return null; + } } + + /// Parses a decoded AT-URI query value. + /// + /// Use [tryAtUri] for external/deep-link input where malformed values should + /// show an invalid-link route state instead of throwing. + AtUri? atUri(String key) => tryAtUri(key); } diff --git a/lib/core/router/routes/authenticated_shell_routes.dart b/lib/core/router/routes/authenticated_shell_routes.dart index f1af899..61bf127 100644 --- a/lib/core/router/routes/authenticated_shell_routes.dart +++ b/lib/core/router/routes/authenticated_shell_routes.dart @@ -5,6 +5,7 @@ import 'package:lazurite/core/router/app_route_page.dart'; import 'package:lazurite/core/router/app_route_paths.dart'; import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/core/router/content_route_factory.dart'; +import 'package:lazurite/core/router/invalid_route_screen.dart'; import 'package:lazurite/core/router/route_query.dart'; import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; import 'package:lazurite/features/feed/presentation/feed_management_screen.dart'; @@ -17,6 +18,7 @@ import 'package:lazurite/features/messages/presentation/message_thread_screen.da import 'package:lazurite/features/profile/presentation/profile_edit_screen.dart'; import 'package:lazurite/features/profile/presentation/profile_screen.dart'; import 'package:lazurite/features/search/presentation/search_screen.dart'; +import 'package:poptart_core/poptart_core.dart'; /// Builds the authenticated tab shell and its branch routes. /// @@ -49,12 +51,20 @@ StatefulShellRoute buildAuthenticatedShellRoute({ path: AppRoutePath.feed.childPath, pageBuilder: (context, state) { final query = RouteQuery(state); + final feedUri = query.atUri('uri'); + if (query.hasNonEmpty('uri') && (feedUri == null || !_isFeedGeneratorUri(feedUri))) { + return buildAppRoutePage( + context, + state, + const InvalidRouteScreen(message: 'This feed link is invalid.'), + ); + } return buildAppRoutePage( context, state, contentRouteFactory.feedDetail( context, - feedUri: query.atUri('uri'), + feedUri: feedUri, actor: query.decoded('actor'), rkey: query.decoded('rkey'), provider: query.decoded('provider'), @@ -169,3 +179,5 @@ StatefulShellRoute buildAuthenticatedShellRoute({ ], ); } + +bool _isFeedGeneratorUri(AtUri uri) => uri.collection.toString() == 'app.bsky.feed.generator' && uri.rkey.isNotEmpty; diff --git a/lib/core/router/routes/public_routes.dart b/lib/core/router/routes/public_routes.dart index 551a86b..5320293 100644 --- a/lib/core/router/routes/public_routes.dart +++ b/lib/core/router/routes/public_routes.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/core/router/app_route_page.dart'; import 'package:lazurite/core/router/app_route_paths.dart'; +import 'package:lazurite/core/router/invalid_route_screen.dart'; import 'package:lazurite/core/router/route_query.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/public/presentation/public_route_state.dart'; @@ -55,6 +57,9 @@ List buildPublicRoutes({ pageBuilder: (context, state) { final query = RouteQuery(state); final feedUri = query.atUri('uri'); + if (query.hasNonEmpty('uri') && (feedUri == null || !_isFeedGeneratorUri(feedUri))) { + return buildAppRoutePage(context, state, const InvalidRouteScreen(message: 'This feed link is invalid.')); + } final actor = query.decoded('actor'); final rkey = query.decoded('rkey'); final provider = query.decoded('provider'); @@ -67,14 +72,33 @@ List buildPublicRoutes({ ), GoRoute( path: AppRoutePath.post.path, + redirect: (_, state) { + final postUri = RouteQuery(state).tryAtUri('uri'); + return _canonicalPostLocation(postUri, sourceUri: state.uri); + }, pageBuilder: (context, state) { - final query = RouteQuery(state); - final uri = query.decoded('uri') ?? ''; - final provider = state.uri.queryParameters['provider']; + final postUri = RouteQuery(state).tryAtUri('uri'); + if (postUri == null || !_hasCollection(postUri, _postCollection)) { + return buildAppRoutePage(context, state, const InvalidRouteScreen(message: 'This post link is invalid.')); + } + return buildAppRoutePage( + context, + state, + buildPostThreadRoute(context, postUri: postUri.toString(), provider: state.uri.queryParameters['provider']), + ); + }, + ), + GoRoute( + path: AppRoutePath.postRecord.path, + pageBuilder: (context, state) { + final postUri = _postUriFromPath(state); + if (postUri == null) { + return buildAppRoutePage(context, state, const InvalidRouteScreen(message: 'This post link is invalid.')); + } return buildAppRoutePage( context, state, - buildPostThreadRoute(context, postUri: uri, provider: provider), + buildPostThreadRoute(context, postUri: postUri.toString(), provider: state.uri.queryParameters['provider']), ); }, ), @@ -104,14 +128,28 @@ List buildPublicRoutes({ return buildAppRoutePage( context, state, - buildContextualProfileRoute( - context, - RouteQuery(state).decodedPathOrEmpty('actor'), - provider: provider, - ), + buildContextualProfileRoute(context, RouteQuery(state).decodedPathOrEmpty('actor'), provider: provider), ); }, routes: [ + GoRoute( + path: 'post/:rkey', + pageBuilder: (context, state) { + final postUri = _postUriFromPath(state); + if (postUri == null) { + return buildAppRoutePage(context, state, const InvalidRouteScreen(message: 'This post link is invalid.')); + } + return buildAppRoutePage( + context, + state, + buildPostThreadRoute( + context, + postUri: postUri.toString(), + provider: state.uri.queryParameters['provider'], + ), + ); + }, + ), GoRoute( path: 'connections', redirect: (_, state) => authBloc.state.isAuthenticated ? null : publicProfileLocation(state), @@ -126,11 +164,7 @@ List buildPublicRoutes({ redirect: (_, state) => authBloc.state.isAuthenticated ? null : publicProfileLocation(state), pageBuilder: (context, state) { final actor = RouteQuery(state).decodedPathOrEmpty('actor'); - return buildAppRoutePage( - context, - state, - buildProfileSearchRoute(context, actor), - ); + return buildAppRoutePage(context, state, buildProfileSearchRoute(context, actor)); }, ), ], @@ -142,16 +176,18 @@ List buildPublicRoutes({ typedef PublicHomeRouteBuilder = Widget Function(BuildContext context, PublicRouteState routeState); /// Builds a feed detail screen with provider-aware repository wiring. -typedef FeedDetailRouteBuilder = Widget Function( - BuildContext context, { - required AtUri? feedUri, - required String? actor, - required String? rkey, - required String? provider, -}); +typedef FeedDetailRouteBuilder = + Widget Function( + BuildContext context, { + required AtUri? feedUri, + required String? actor, + required String? rkey, + required String? provider, + }); /// Builds a post thread screen with provider-aware repository wiring. -typedef PostThreadRouteBuilder = Widget Function(BuildContext context, {required String postUri, required String? provider}); +typedef PostThreadRouteBuilder = + Widget Function(BuildContext context, {required String postUri, required String? provider}); /// Builds a topic timeline with authenticated or public search dependencies. typedef TopicRouteBuilder = Widget Function(BuildContext context, {required String topic, required String? provider}); @@ -167,3 +203,48 @@ typedef ProfileSearchRouteBuilder = Widget Function(BuildContext context, String /// Returns the public profile route to use when blocking authenticated-only child routes. typedef PublicProfileLocationBuilder = String Function(GoRouterState state); + +const _postCollection = 'app.bsky.feed.post'; + +bool _isFeedGeneratorUri(AtUri uri) => _hasCollection(uri, 'app.bsky.feed.generator') && uri.rkey.isNotEmpty; + +bool _hasCollection(AtUri uri, String collection) { + try { + return uri.collection.toString() == collection; + } catch (error, stackTrace) { + log.d('Invalid AT-URI collection for public route', error: error, stackTrace: stackTrace); + return false; + } +} + +String? _canonicalPostLocation(AtUri? uri, {required Uri sourceUri}) { + if (uri == null || !_hasCollection(uri, _postCollection)) { + return null; + } + + try { + final actor = Uri.encodeComponent(uri.hostname); + final rkey = Uri.encodeComponent(uri.rkey); + final query = Map.from(sourceUri.queryParameters)..remove('uri'); + final queryString = query.isEmpty ? '' : '?${Uri(queryParameters: query).query}'; + return '/profile/$actor/post/$rkey$queryString'; + } catch (error, stackTrace) { + log.d('Invalid AT-URI record parts for public post route', error: error, stackTrace: stackTrace); + return null; + } +} + +AtUri? _postUriFromPath(GoRouterState state) { + final actor = state.pathParameters['actor']; + final rkey = state.pathParameters['rkey']; + if (actor == null || actor.trim().isEmpty || rkey == null || rkey.trim().isEmpty) { + return null; + } + + try { + return AtUri.parse('at://${Uri.decodeComponent(actor)}/$_postCollection/${Uri.decodeComponent(rkey)}'); + } catch (error, stackTrace) { + log.d('Invalid AT-URI record path params for public post route', error: error, stackTrace: stackTrace); + return null; + } +} diff --git a/lib/core/router/routes/root_routes.dart b/lib/core/router/routes/root_routes.dart index ed4bdeb..94ce74b 100644 --- a/lib/core/router/routes/root_routes.dart +++ b/lib/core/router/routes/root_routes.dart @@ -2,12 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/constellation_client.dart'; import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; import 'package:lazurite/core/router/app_route_page.dart'; import 'package:lazurite/core/router/app_route_paths.dart'; +import 'package:lazurite/core/router/invalid_route_screen.dart'; import 'package:lazurite/core/router/route_query.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/auth/presentation/oauth_callback_screen.dart'; @@ -126,8 +128,38 @@ List buildRootRoutes({ ), GoRoute( path: AppRoutePath.starterPack.path, + redirect: (_, state) { + final packUri = RouteQuery(state).tryAtUri('uri'); + return _canonicalRecordLocation( + packUri, + collection: _starterPackCollection, + routeBase: AppRoutePath.starterPack.path, + sourceUri: state.uri, + ); + }, + pageBuilder: (context, state) { + final packUri = RouteQuery(state).tryAtUri('uri'); + if (packUri == null || !_hasCollection(packUri, _starterPackCollection)) { + return buildAppRoutePage( + context, + state, + const InvalidRouteScreen(message: 'This starter pack link is invalid.'), + ); + } + return buildAppRoutePage(context, state, StarterPackDetailScreen(packUri: packUri)); + }, + ), + GoRoute( + path: AppRoutePath.starterPackRecord.path, pageBuilder: (context, state) { - final packUri = AtUri.parse(RouteQuery(state).decodedOrEmpty('uri')); + final packUri = _recordUriFromPath(state, _starterPackCollection); + if (packUri == null) { + return buildAppRoutePage( + context, + state, + const InvalidRouteScreen(message: 'This starter pack link is invalid.'), + ); + } return buildAppRoutePage(context, state, StarterPackDetailScreen(packUri: packUri)); }, ), @@ -140,25 +172,64 @@ List buildRootRoutes({ ), GoRoute( path: AppRoutePath.list.path, + redirect: (_, state) { + final listUri = RouteQuery(state).tryAtUri('uri'); + return _canonicalRecordLocation( + listUri, + collection: _listCollection, + routeBase: AppRoutePath.list.path, + sourceUri: state.uri, + ); + }, pageBuilder: (context, state) { - final listUri = AtUri.parse(RouteQuery(state).decodedOrEmpty('uri')); + final listUri = RouteQuery(state).tryAtUri('uri'); + if (listUri == null || !_hasCollection(listUri, _listCollection)) { + return buildAppRoutePage(context, state, const InvalidRouteScreen(message: 'This list link is invalid.')); + } return buildAppRoutePage(context, state, ListDetailScreen(listUri: listUri)); }, routes: [ GoRoute( path: 'members', - pageBuilder: (context, state) { - final listUri = AtUri.parse(RouteQuery(state).decodedOrEmpty('uri')); - return buildAppRoutePage( - context, - state, - BlocProvider( - create: (_) => - ListBloc(listRepository: context.read())..add(ListRequested(listUri: listUri)), - child: ListMembersScreen(listUri: listUri), - ), + redirect: (_, state) { + final listUri = RouteQuery(state).tryAtUri('uri'); + return _canonicalRecordLocation( + listUri, + collection: _listCollection, + routeBase: AppRoutePath.list.path, + suffix: 'members', + sourceUri: state.uri, ); }, + pageBuilder: (context, state) { + final listUri = RouteQuery(state).tryAtUri('uri'); + if (listUri == null || !_hasCollection(listUri, _listCollection)) { + return buildAppRoutePage(context, state, const InvalidRouteScreen(message: 'This list link is invalid.')); + } + return _buildListMembersPage(context, state, listUri); + }, + ), + ], + ), + GoRoute( + path: AppRoutePath.listRecord.path, + pageBuilder: (context, state) { + final listUri = _recordUriFromPath(state, _listCollection); + if (listUri == null) { + return buildAppRoutePage(context, state, const InvalidRouteScreen(message: 'This list link is invalid.')); + } + return buildAppRoutePage(context, state, ListDetailScreen(listUri: listUri)); + }, + routes: [ + GoRoute( + path: 'members', + pageBuilder: (context, state) { + final listUri = _recordUriFromPath(state, _listCollection); + if (listUri == null) { + return buildAppRoutePage(context, state, const InvalidRouteScreen(message: 'This list link is invalid.')); + } + return _buildListMembersPage(context, state, listUri); + }, ), ], ), @@ -193,3 +264,68 @@ List buildRootRoutes({ ), ]; } + + +const _listCollection = 'app.bsky.graph.list'; +const _starterPackCollection = 'app.bsky.graph.starterpack'; + +bool _hasCollection(AtUri uri, String collection) { + try { + return uri.collection.toString() == collection; + } catch (error, stackTrace) { + log.d('Invalid AT-URI collection for route', error: error, stackTrace: stackTrace); + return false; + } +} + +String? _canonicalRecordLocation( + AtUri? uri, { + required String collection, + required String routeBase, + required Uri sourceUri, + String? suffix, +}) { + if (uri == null || !_hasCollection(uri, collection)) { + return null; + } + + try { + final encodedActor = Uri.encodeComponent(uri.hostname); + final encodedRkey = Uri.encodeComponent(uri.rkey); + final suffixPath = suffix == null ? '' : '/$suffix'; + final query = Map.from(sourceUri.queryParameters)..remove('uri'); + final queryString = query.isEmpty ? '' : '?${Uri(queryParameters: query).query}'; + return '$routeBase/$encodedActor/$encodedRkey$suffixPath$queryString'; + } catch (error, stackTrace) { + log.d('Invalid AT-URI record parts for route', error: error, stackTrace: stackTrace); + return null; + } +} + +AtUri? _recordUriFromPath(GoRouterState state, String collection) { + final actor = state.pathParameters['actor']; + final rkey = state.pathParameters['rkey']; + if (actor == null || actor.trim().isEmpty || rkey == null || rkey.trim().isEmpty) { + return null; + } + + try { + return AtUri.parse( + 'at://${Uri.decodeComponent(actor)}/$collection/${Uri.decodeComponent(rkey)}', + ); + } catch (error, stackTrace) { + log.d('Invalid AT-URI record path params for route', error: error, stackTrace: stackTrace); + return null; + } +} + +Page _buildListMembersPage(BuildContext context, GoRouterState state, AtUri listUri) { + return buildAppRoutePage( + context, + state, + BlocProvider( + create: (_) => ListBloc(listRepository: context.read())..add(ListRequested(listUri: listUri)), + child: ListMembersScreen(listUri: listUri), + ), + ); +} diff --git a/lib/features/devtools/cubit/dev_tools_state.dart b/lib/features/devtools/cubit/dev_tools_state.dart index 645ca59..798e195 100644 --- a/lib/features/devtools/cubit/dev_tools_state.dart +++ b/lib/features/devtools/cubit/dev_tools_state.dart @@ -24,7 +24,8 @@ class RecordInfo extends Equatable { String get rkey { try { return AtUri.parse(uri).rkey; - } catch (_) { + } catch (error, stackTrace) { + log.d('DevToolsState: unable to read rkey from record URI', error: error, stackTrace: stackTrace); return ''; } } diff --git a/lib/features/feed/cubit/feed_preferences_cubit.dart b/lib/features/feed/cubit/feed_preferences_cubit.dart index 28dc553..3205562 100644 --- a/lib/features/feed/cubit/feed_preferences_cubit.dart +++ b/lib/features/feed/cubit/feed_preferences_cubit.dart @@ -241,7 +241,12 @@ class FeedPreferencesCubit extends Cubit { continue; } feedUris.add(parsed); - } catch (_) { + } catch (error, stackTrace) { + log.d( + 'FeedPreferencesCubit: skipping malformed saved feed URI ${feed.value}', + error: error, + stackTrace: stackTrace, + ); continue; } } @@ -316,7 +321,8 @@ class FeedPreferencesCubit extends Cubit { final left = AtUri.parse(lhs); final right = AtUri.parse(rhs); return left.hostname == right.hostname && left.collection == right.collection && left.rkey == right.rkey; - } catch (_) { + } catch (error, stackTrace) { + log.d('FeedPreferencesCubit: cannot compare malformed feed URIs', error: error, stackTrace: stackTrace); return false; } } @@ -433,7 +439,8 @@ class FeedPreferencesState extends Equatable { final left = AtUri.parse(lhs); final right = AtUri.parse(rhs); return left.hostname == right.hostname && left.collection == right.collection && left.rkey == right.rkey; - } catch (_) { + } catch (error, stackTrace) { + log.d('FeedPreferencesState: cannot compare malformed feed URIs', error: error, stackTrace: stackTrace); return false; } } diff --git a/lib/features/notifications/domain/notification_domain_service.dart b/lib/features/notifications/domain/notification_domain_service.dart index eea7120..0ebc8ec 100644 --- a/lib/features/notifications/domain/notification_domain_service.dart +++ b/lib/features/notifications/domain/notification_domain_service.dart @@ -257,7 +257,8 @@ class NotificationPushPayload { try { final atUri = AtUri.parse(value); return atUri.toString().isNotEmpty; - } catch (_) { + } catch (error, stackTrace) { + log.d('NotificationDomainService: ignoring malformed push record URI', error: error, stackTrace: stackTrace); return false; } } diff --git a/lib/features/notifications/domain/notification_reason_utils.dart b/lib/features/notifications/domain/notification_reason_utils.dart index a38ca21..d689d5c 100644 --- a/lib/features/notifications/domain/notification_reason_utils.dart +++ b/lib/features/notifications/domain/notification_reason_utils.dart @@ -3,6 +3,7 @@ import 'package:bluesky_poptart/app/bsky/notification/list_notifications.dart' a import 'dart:ui'; import 'package:lazurite/core/l10n/app_localizations.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; abstract final class NotificationReasonUtils { @@ -175,7 +176,8 @@ abstract final class NotificationReasonUtils { try { return AtUri.parse(uriValue.trim()); - } catch (_) { + } catch (error, stackTrace) { + log.d('NotificationReasonUtils: ignoring malformed subject URI', error: error, stackTrace: stackTrace); return null; } } diff --git a/lib/features/profile/data/follow_audit_repository.dart b/lib/features/profile/data/follow_audit_repository.dart index e726a96..1009d26 100644 --- a/lib/features/profile/data/follow_audit_repository.dart +++ b/lib/features/profile/data/follow_audit_repository.dart @@ -147,7 +147,17 @@ class FollowAuditRepository { final follow = const GraphFollowRecordConverter().fromJson(value); final uri = raw.uri.toString(); - final rkey = AtUri.parse(uri).rkey; + late final String rkey; + try { + rkey = AtUri.parse(uri).rkey; + } catch (error, stackTrace) { + log.w( + 'FollowAuditRepository: skipping follow record with malformed uri=$uri', + error: error, + stackTrace: stackTrace, + ); + continue; + } records.add(FollowRecord(uri: uri, rkey: rkey, subjectDid: follow.subject)); } diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/profile_repository.dart index 84e4eda..dc1b7f8 100644 --- a/lib/features/profile/data/profile_repository.dart +++ b/lib/features/profile/data/profile_repository.dart @@ -248,7 +248,23 @@ class ProfileRepository { ); final moderationService = _moderationService; final postsByUri = {}; - final subjectUris = likeRecords.map((record) => atp_core.AtUri.parse(record.subjectUri)).toList(growable: false); + final subjectUris = []; + for (final record in likeRecords) { + try { + final subjectUri = atp_core.AtUri.parse(record.subjectUri); + if (subjectUri.collection.toString() != 'app.bsky.feed.post' || subjectUri.rkey.isEmpty) { + log.w('ProfileRepository: skipping malformed liked post URI ${record.subjectUri}'); + continue; + } + subjectUris.add(subjectUri); + } catch (error, stackTrace) { + log.w( + 'ProfileRepository: skipping malformed liked post URI ${record.subjectUri}', + error: error, + stackTrace: stackTrace, + ); + } + } for (var i = 0; i < subjectUris.length; i += _maxPostsHydrationBatchSize) { final batch = subjectUris.sublist(i, (i + _maxPostsHydrationBatchSize).clamp(0, subjectUris.length)); final response = await _authRecovery.run( diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index 8a4a593..e5fe7d4 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -886,6 +886,59 @@ void main() { router.dispose(); }); + testWidgets('invalid list route shows invalid-link screen instead of throwing', (tester) async { + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget(buildSubjectWithRouter(router)); + router.go('/list?uri=not-an-at-uri'); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Invalid link'), findsWidgets); + expect(find.text('This list link is invalid.'), findsOneWidget); + + router.dispose(); + }); + + testWidgets('invalid feed query route shows invalid-link screen instead of resolving as missing', (tester) async { + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget(buildSubjectWithRouter(router)); + router.go('/feed?uri=not-an-at-uri'); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Invalid link'), findsWidgets); + expect(find.text('This feed link is invalid.'), findsOneWidget); + verifyNever( + () => feedRepository.resolveFeedGeneratorUri( + actor: any(named: 'actor'), + rkey: any(named: 'rkey'), + ), + ); + + router.dispose(); + }); + + testWidgets('logged-out invalid feed query route shows invalid-link screen instead of resolving as missing', ( + tester, + ) async { + currentAuthState = const AuthState.unauthenticated(); + when(() => authBloc.state).thenReturn(currentAuthState); + whenListen(authBloc, Stream.value(currentAuthState), initialState: currentAuthState); + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget(buildSubjectWithRouter(router)); + router.go('/feed?uri=not-an-at-uri&provider=blacksky'); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Invalid link'), findsWidgets); + expect(find.text('This feed link is invalid.'), findsOneWidget); + + router.dispose(); + }); + testWidgets('video viewer route without extra redirects home instead of throwing', (tester) async { final router = AppRouter(authBloc: authBloc).router; @@ -899,7 +952,9 @@ void main() { router.dispose(); }); - testWidgets('video viewer route with invalid args redirects home instead of initializing blank video', (tester) async { + testWidgets('video viewer route with invalid args redirects home instead of initializing blank video', ( + tester, + ) async { final router = AppRouter(authBloc: authBloc).router; await tester.pumpWidget(buildSubjectWithRouter(router)); diff --git a/test/features/profile/data/follow_audit_repository_test.dart b/test/features/profile/data/follow_audit_repository_test.dart index cbdc88c..49ffabd 100644 --- a/test/features/profile/data/follow_audit_repository_test.dart +++ b/test/features/profile/data/follow_audit_repository_test.dart @@ -394,6 +394,21 @@ void main() { expect(page.records.first.rkey, 'rkey123'); expect(page.cursor, 'page1'); }); + + test('skips follow records with malformed record URIs', () async { + final client = _bluesky( + pages: [ + [('not-an-at-uri', 'did:plc:bad'), (_uri('did:plc:alice', 'rkey123'), 'did:plc:alice')], + ], + ); + final repo = _repo(client); + + final page = await repo.fetchFollowPage(_ownerDid); + + expect(page.records, hasLength(1)); + expect(page.records.single.subjectDid, 'did:plc:alice'); + expect(page.records.single.rkey, 'rkey123'); + }); }); group('FollowAuditRepository.fetchFollowCount', () { diff --git a/test/features/profile/data/profile_repository_actor_likes_test.dart b/test/features/profile/data/profile_repository_actor_likes_test.dart index 26aa1ce..8a0043f 100644 --- a/test/features/profile/data/profile_repository_actor_likes_test.dart +++ b/test/features/profile/data/profile_repository_actor_likes_test.dart @@ -53,6 +53,7 @@ void main() { test('non-self likes path uses actor PDS listRecords + appview getPosts and keeps record order', () async { const actorDid = 'did:plc:friend'; + const malformedSubject = 'not-an-at-uri'; const firstSubject = 'at://did:plc:author/app.bsky.feed.post/first'; const secondSubject = 'at://did:plc:author/app.bsky.feed.post/second'; final feedService = _FakeFeedService( @@ -62,6 +63,13 @@ void main() { final repoService = _FakeRepoService( recordsData: const _FakeListRecordsData( records: [ + _FakeRepoRecord( + value: { + r'$type': 'app.bsky.feed.like', + 'subject': {'uri': malformedSubject, 'cid': 'cid-malformed'}, + 'createdAt': '2026-05-02T02:00:00.000Z', + }, + ), _FakeRepoRecord( value: { r'$type': 'app.bsky.feed.like', @@ -103,9 +111,12 @@ void main() { expect(feedService.getActorLikesCallCount, 0); expect(feedService.getPostsCallCount, 1); expect(feedService.lastGetPostsServiceHost, 'public.api.bsky.app'); - expect(result.entries.length, 2); - expect(result.entries.first.isAvailable, isFalse); - expect(result.entries.first.subjectUri, firstSubject); + expect(feedService.lastGetPostsUris, [firstSubject, secondSubject]); + expect(result.entries.length, 3); + expect(result.entries[0].isAvailable, isFalse); + expect(result.entries[0].subjectUri, malformedSubject); + expect(result.entries[1].isAvailable, isFalse); + expect(result.entries[1].subjectUri, firstSubject); expect(result.entries.last.isAvailable, isTrue); expect(result.entries.last.feedViewPost?.post.uri.toString(), secondSubject); }); @@ -149,6 +160,7 @@ class _FakeFeedService { int getActorLikesCallCount = 0; int getPostsCallCount = 0; String? lastGetPostsServiceHost; + List lastGetPostsUris = const []; Future<_FakeResponse<_FakeActorLikesData>> getActorLikes({ required String actor, @@ -168,6 +180,7 @@ class _FakeFeedService { }) async { getPostsCallCount++; lastGetPostsServiceHost = $service; + lastGetPostsUris = uris.map((uri) => uri.toString()).toList(growable: false); return _FakeResponse(_FakeGetPostsData(posts: _hydratedPosts)); } } -- 2.51.2