From c83eaa4d9839d30bc2c59176dfbebba49029789e Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 5 Mar 2026 08:30:06 +0100 Subject: [PATCH] Limit direct payload hops --- ios/fastlane/report.xml | 8 +-- lib/providers/app_provider.dart | 37 +++++++++++ lib/providers/image_provider.dart | 7 +++ lib/providers/voice_provider.dart | 7 +++ lib/screens/messages_tab.dart | 2 + .../messages/image_message_bubble.dart | 63 +++++++++++++++++-- .../messages/voice_message_bubble.dart | 59 ++++++++++++++++- 7 files changed, 172 insertions(+), 11 deletions(-) diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 5e09600..f6cef1f 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index f079740..9153d1b 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -20,6 +20,7 @@ import '../utils/image_message_parser.dart'; /// Main App Provider - coordinates all other providers class AppProvider with ChangeNotifier { + static const int _maxDirectPayloadHops = 3; final ConnectionProvider connectionProvider; final ContactsProvider contactsProvider; final MessagesProvider messagesProvider; @@ -565,6 +566,22 @@ class AppProvider with ChangeNotifier { debugPrint( '⚠️ [AppProvider] Voice fetch requester contact not found', ); + messagesProvider.logSystemMessage( + text: + 'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.', + level: 'warning', + ); + return; + } + if (requester.outPathLen > _maxDirectPayloadHops) { + debugPrint( + '⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops', + ); + messagesProvider.logSystemMessage( + text: + 'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).', + level: 'warning', + ); return; } unawaited( @@ -665,6 +682,17 @@ class AppProvider with ChangeNotifier { senderPrefix, ); if (requester != null) { + if (requester.outPathLen > _maxDirectPayloadHops) { + debugPrint( + '⚠️ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops', + ); + messagesProvider.logSystemMessage( + text: + 'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).', + level: 'warning', + ); + return; + } unawaited( imageProvider.serveSessionTo( sessionId: imageFetchRequest.sessionId, @@ -674,6 +702,15 @@ class AppProvider with ChangeNotifier { : null, ), ); + } else { + debugPrint( + '⚠️ [AppProvider] Image fetch requester contact not found', + ); + messagesProvider.logSystemMessage( + text: + 'Cannot fetch image: requester contact is unknown. Add/sync contacts first.', + level: 'warning', + ); } } } diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 62d7313..261ff7c 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -36,6 +36,7 @@ class ImageSession { class ImageProvider with ChangeNotifier { static const String _storageKey = 'stored_image_sessions_v1'; static const Duration _outgoingTtl = Duration(minutes: 15); + static const int maxDirectPayloadHops = 3; /// Incoming sessions keyed by sessionId. final Map _sessions = {}; @@ -190,6 +191,12 @@ class ImageProvider with ChangeNotifier { debugPrint('⚠️ [ImageProvider] ${requester.advName} has no direct path'); return false; } + if (requester.outPathLen > maxDirectPayloadHops) { + debugPrint( + '⚠️ [ImageProvider] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)', + ); + return false; + } for (final fragment in cached.fragments) { if (requestedIndices != null && diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index c59d129..2861bdb 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -36,6 +36,7 @@ class VoiceSession { /// Manages incoming voice packet sessions and coordinates playback. class VoiceProvider with ChangeNotifier { static const String _voiceSessionsStorageKey = 'stored_voice_sessions_v1'; + static const int maxDirectPayloadHops = 3; final VoiceCodecService _codec; final VoicePlayerService _player; late final StreamSubscription _playerEventsSub; @@ -161,6 +162,12 @@ class VoiceProvider with ChangeNotifier { ); return false; } + if (requester.outPathLen > maxDirectPayloadHops) { + debugPrint( + '⚠️ [VoiceProvider] Requester ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)', + ); + return false; + } for (final packet in cached.packets) { if (requestedIndices != null && diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index bfabb05..f966289 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -434,6 +434,8 @@ class _MessagesTabState extends State { ImageSource source = ImageSource.gallery, }) async { if (_isSendingImage) return; + final shouldContinue = await _confirmPublicChannelMediaSend('image'); + if (!shouldContinue) return; final connectionProvider = context.read(); if (!connectionProvider.deviceInfo.isConnected) { ToastLogger.error(context, 'Not connected to device'); diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart index 90e755f..fd37445 100644 --- a/lib/widgets/messages/image_message_bubble.dart +++ b/lib/widgets/messages/image_message_bubble.dart @@ -31,6 +31,7 @@ class ImageMessageBubble extends StatefulWidget { } class _ImageMessageBubbleState extends State { + static const int _maxFetchHops = 3; bool _isRequesting = false; String? _errorText; Timer? _requestTimeoutTimer; @@ -208,15 +209,41 @@ class _ImageMessageBubbleState extends State { sender = _resolveSender(envelope); } if (sender == null) { - setState(() => _errorText = 'Sender not reachable'); + await _showBlockingAlert( + 'Cannot fetch image', + 'Sender contact is unknown. Sync contacts first.', + ); return; } + if (sender.outPathLen < 0) { + await _showBlockingAlert( + 'Cannot fetch image', + 'Sender route is unknown. Sync contacts/path first.', + ); + return; + } + if (sender.outPathLen > _maxFetchHops) { + await _showBlockingAlert( + 'Cannot fetch image', + 'Message is too far (${sender.outPathLen} hops, max $_maxFetchHops).', + ); + return; + } + if (sender.outPathLen >= 2) { + _showToast( + 'Image fetch over ${sender.outPathLen} hops may take a while.', + ); + } + setState(() => _errorText = null); final conn = context.read(); final imageProvider = context.read(); final deviceKey = conn.deviceInfo.publicKey; if (deviceKey == null || deviceKey.length < 6) { - setState(() => _errorText = 'Device key unavailable'); + await _showBlockingAlert( + 'Cannot fetch image', + 'Device key is unavailable.', + ); return; } @@ -227,8 +254,8 @@ class _ImageMessageBubbleState extends State { // If we already have some fragments, request only what's missing. final missing = imageProvider.missingFragmentIndices(envelope.sessionId); - final isPartialResume = missing.isNotEmpty && - missing.length < envelope.total; + final isPartialResume = + missing.isNotEmpty && missing.length < envelope.total; final request = isPartialResume ? ImageFetchRequest( sessionId: envelope.sessionId, @@ -276,7 +303,9 @@ class _ImageMessageBubbleState extends State { _requestTimeoutTimer = TransferTimeout.start( txEstimate: txEstimate, onTimeout: () { - if (mounted && _isRequesting && !imageProvider.isComplete(envelope.sessionId)) { + if (mounted && + _isRequesting && + !imageProvider.isComplete(envelope.sessionId)) { setState(() => _isRequesting = false); } }, @@ -309,6 +338,30 @@ class _ImageMessageBubbleState extends State { return null; } + void _showToast(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 3)), + ); + } + + Future _showBlockingAlert(String title, String message) async { + if (!mounted) return; + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + static String _statusText({ required bool isComplete, required bool isRequesting, diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart index f72f0fa..f88494f 100644 --- a/lib/widgets/messages/voice_message_bubble.dart +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -27,6 +27,7 @@ class VoiceMessageBubble extends StatefulWidget { } class _VoiceMessageBubbleState extends State { + static const int _maxFetchHops = 3; bool _isRequesting = false; bool _autoPlayWhenReady = false; String? _errorText; @@ -207,14 +208,44 @@ class _VoiceMessageBubbleState extends State { sender = _resolveSenderContact(); } if (sender == null) { - _setUnavailable(); + await _showBlockingAlert( + 'Cannot fetch voice', + 'Sender contact is unknown. Sync contacts first.', + ); return; } + if (sender.outPathLen < 0) { + await _showBlockingAlert( + 'Cannot fetch voice', + 'Sender route is unknown. Sync contacts/path first.', + ); + return; + } + if (sender.outPathLen > _maxFetchHops) { + await _showBlockingAlert( + 'Cannot fetch voice', + 'Message is too far (${sender.outPathLen} hops, max $_maxFetchHops).', + ); + return; + } + if (sender.outPathLen >= 2) { + _showToast( + 'Voice fetch over ${sender.outPathLen} hops may take a while.', + ); + } + + if (!mounted) return; + setState(() { + _errorText = null; + }); final connectionProvider = context.read(); final deviceKey = connectionProvider.deviceInfo.publicKey; if (deviceKey == null || deviceKey.length < 6) { - _setUnavailable(); + await _showBlockingAlert( + 'Cannot fetch voice', + 'Device key is unavailable.', + ); return; } @@ -307,6 +338,30 @@ class _VoiceMessageBubbleState extends State { return null; } + void _showToast(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 3)), + ); + } + + Future _showBlockingAlert(String title, String message) async { + if (!mounted) return; + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + static String _formatDuration(double seconds) { final s = seconds.round(); if (s < 60) return '${s}s';