From 1687c4f133b34b1dd6fbfaa329bd9a09274d3702 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:01:59 -0600 Subject: [PATCH 01/30] send images using blossom server --- .../chat/notifiers/chat_room_notifier.dart | 136 ++++++++ .../chat/screens/chat_room_screen.dart | 1 + .../chat/widgets/chat_messages_list.dart | 3 + .../chat/widgets/encrypted_image_message.dart | 314 ++++++++++++++++++ lib/features/chat/widgets/message_bubble.dart | 46 ++- lib/features/chat/widgets/message_input.dart | 113 ++++++- lib/services/blossom_client.dart | 92 +++++ lib/services/blossom_download_service.dart | 167 ++++++++++ .../encrypted_image_upload_service.dart | 232 +++++++++++++ lib/services/encryption_service.dart | 226 +++++++++++++ lib/services/image_upload_service.dart | 95 ++++++ lib/services/media_validation_service.dart | 158 +++++++++ 12 files changed, 1580 insertions(+), 3 deletions(-) create mode 100644 lib/features/chat/widgets/encrypted_image_message.dart create mode 100644 lib/services/blossom_client.dart create mode 100644 lib/services/blossom_download_service.dart create mode 100644 lib/services/encrypted_image_upload_service.dart create mode 100644 lib/services/encryption_service.dart create mode 100644 lib/services/image_upload_service.dart create mode 100644 lib/services/media_validation_service.dart diff --git a/lib/features/chat/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart index c9a4ec3f..8702f528 100644 --- a/lib/features/chat/notifiers/chat_room_notifier.dart +++ b/lib/features/chat/notifiers/chat_room_notifier.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -6,6 +8,7 @@ import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; import 'package:sembast/sembast.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; @@ -145,6 +148,9 @@ class ChatRoomNotifier extends StateNotifier { final chat = await event.p2pUnwrap(session.sharedKey!); + // Process special message types (e.g., encrypted images) + await _processMessageContent(chat); + // Check if message already exists to prevent duplicates final messageExists = state.messages.any((m) => m.id == chat.id); if (!messageExists) { @@ -374,6 +380,136 @@ class ChatRoomNotifier extends StateNotifier { } } + /// Get the shared key for this chat session (used by MessageInput) + Future getSharedKey() async { + final session = ref.read(sessionProvider(orderId)); + if (session == null || session.sharedKey == null) { + throw Exception('Session or shared key not available for orderId: $orderId'); + } + + // Convert from NostrKeyPairs to Uint8List (32 bytes) + final sharedKeyBytes = Uint8List(32); + final hexKey = session.sharedKey!.private; // This should be hex string + + // Convert hex string to bytes + for (int i = 0; i < 32; i++) { + final byte = int.parse(hexKey.substring(i * 2, i * 2 + 2), radix: 16); + sharedKeyBytes[i] = byte; + } + + return sharedKeyBytes; + } + + /// Process special message content (e.g., encrypted images) + Future _processMessageContent(NostrEvent message) async { + try { + final content = message.content; + if (content == null || !content.startsWith('{')) { + // Not a JSON message, treat as regular text + return; + } + + // Try to parse as JSON + Map? jsonContent; + try { + final decoded = jsonDecode(content); + if (decoded is Map) { + jsonContent = decoded; + } + } catch (e) { + // Not valid JSON, treat as regular text + return; + } + + // Check if it's an encrypted image message + if (jsonContent != null && jsonContent['type'] == 'image_encrypted') { + _logger.i('πŸ“Έ Processing encrypted image message'); + await _processEncryptedImageMessage(message, jsonContent); + } + + // Future: Handle other message types here + // else if (jsonContent['type'] == 'document_encrypted') { ... } + + } catch (e) { + _logger.w('Error processing message content: $e'); + // Don't rethrow - message should still be displayed as text + } + } + + /// Process encrypted image message by pre-downloading and caching + Future _processEncryptedImageMessage( + NostrEvent message, + Map imageData + ) async { + try { + // Extract image metadata + final result = EncryptedImageUploadResult.fromJson(imageData); + + _logger.i('πŸ“₯ Pre-downloading encrypted image: ${result.filename}'); + _logger.d('Blossom URL: ${result.blossomUrl}'); + _logger.d('Original size: ${result.originalSize} bytes'); + + // Get shared key for decryption + final sharedKey = await getSharedKey(); + + // Download and decrypt image in background + final uploadService = EncryptedImageUploadService(); + final decryptedImage = await uploadService.downloadAndDecryptImage( + blossomUrl: result.blossomUrl, + nonceHex: result.nonce, + sharedKey: sharedKey, + ); + + _logger.i('βœ… Image downloaded and decrypted successfully: ${decryptedImage.length} bytes'); + + // Cache the decrypted image for immediate display + // You could store it in a Map for quick access + cacheDecryptedImage(message.id!, decryptedImage, result); + + } catch (e) { + _logger.e('❌ Failed to process encrypted image: $e'); + // Don't rethrow - message should still be displayed (maybe with error indicator) + } + } + + // Simple cache for decrypted images + final Map _imageCache = {}; + final Map _imageMetadata = {}; + + /// Cache a decrypted image for quick display + void cacheDecryptedImage( + String messageId, + Uint8List imageData, + EncryptedImageUploadResult metadata + ) { + _imageCache[messageId] = imageData; + _imageMetadata[messageId] = metadata; + _logger.d('πŸ—„οΈ Cached decrypted image for message: $messageId'); + } + + /// Get cached decrypted image data + Uint8List? getCachedImage(String messageId) { + return _imageCache[messageId]; + } + + /// Get cached image metadata + EncryptedImageUploadResult? getImageMetadata(String messageId) { + return _imageMetadata[messageId]; + } + + /// Check if a message is an encrypted image message + bool isEncryptedImageMessage(NostrEvent message) { + try { + final content = message.content; + if (content == null || !content.startsWith('{')) return false; + + final jsonContent = jsonDecode(content); + return jsonContent['type'] == 'image_encrypted'; + } catch (e) { + return false; + } + } + @override void dispose() { _subscription?.cancel(); diff --git a/lib/features/chat/screens/chat_room_screen.dart b/lib/features/chat/screens/chat_room_screen.dart index 0d8a6835..ecca5100 100644 --- a/lib/features/chat/screens/chat_room_screen.dart +++ b/lib/features/chat/screens/chat_room_screen.dart @@ -157,6 +157,7 @@ class _ChatRoomScreenState extends ConsumerState { : ChatMessagesList( chatRoom: chatDetailState, peerPubkey: peer, + orderId: widget.orderId, scrollController: _scrollController, ), ), diff --git a/lib/features/chat/widgets/chat_messages_list.dart b/lib/features/chat/widgets/chat_messages_list.dart index 0712dacd..cf31ebea 100644 --- a/lib/features/chat/widgets/chat_messages_list.dart +++ b/lib/features/chat/widgets/chat_messages_list.dart @@ -7,12 +7,14 @@ import 'package:mostro_mobile/features/chat/widgets/message_bubble.dart'; class ChatMessagesList extends StatefulWidget { final ChatRoom chatRoom; final String peerPubkey; + final String orderId; final ScrollController? scrollController; // Optional external scroll controller const ChatMessagesList({ super.key, required this.chatRoom, required this.peerPubkey, + required this.orderId, this.scrollController, }); @@ -131,6 +133,7 @@ class _ChatMessagesListState extends State { return MessageBubble( message: message, peerPubkey: widget.peerPubkey, + orderId: widget.orderId, ); }, ), diff --git a/lib/features/chat/widgets/encrypted_image_message.dart b/lib/features/chat/widgets/encrypted_image_message.dart new file mode 100644 index 00000000..6dc0ecf0 --- /dev/null +++ b/lib/features/chat/widgets/encrypted_image_message.dart @@ -0,0 +1,314 @@ +import 'dart:typed_data'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; + +class EncryptedImageMessage extends ConsumerStatefulWidget { + final NostrEvent message; + final String orderId; + final bool isOwnMessage; + + const EncryptedImageMessage({ + super.key, + required this.message, + required this.orderId, + required this.isOwnMessage, + }); + + @override + ConsumerState createState() => _EncryptedImageMessageState(); +} + +class _EncryptedImageMessageState extends ConsumerState { + bool _isLoading = false; + String? _errorMessage; + + @override + Widget build(BuildContext context) { + final chatNotifier = ref.read(chatRoomsProvider(widget.orderId).notifier); + + // Check if image is already cached + final cachedImage = chatNotifier.getCachedImage(widget.message.id!); + final imageMetadata = chatNotifier.getImageMetadata(widget.message.id!); + + if (cachedImage != null && imageMetadata != null) { + return _buildImageWidget(cachedImage, imageMetadata); + } + + if (_isLoading) { + return _buildLoadingWidget(); + } + + if (_errorMessage != null) { + return _buildErrorWidget(); + } + + // Try to load the image + _loadImage(); + return _buildLoadingWidget(); + } + + Widget _buildImageWidget(Uint8List imageData, EncryptedImageUploadResult metadata) { + return LayoutBuilder( + builder: (context, constraints) { + // Calculate available space for the image (leave space for info row) + const infoRowHeight = 20.0; // Height for the filename/size row + const spacing = 4.0; + const padding = 8.0; // Total vertical padding + final availableHeight = constraints.maxHeight - infoRowHeight - spacing - padding; + + return Container( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth.clamp(0, 280), + maxHeight: constraints.maxHeight.clamp(0, 400), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Image container + Flexible( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Container( + constraints: BoxConstraints( + maxHeight: availableHeight.clamp(100, 350), // Ensure reasonable min/max + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Image.memory( + imageData, + fit: BoxFit.contain, // Use contain to prevent overflow + errorBuilder: (context, error, stackTrace) { + return _buildErrorWidget(); + }, + ), + ), + ), + ), + const SizedBox(height: spacing), + // Image info + Container( + height: infoRowHeight, + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: [ + Icon( + Icons.image, + size: 12, + color: AppTheme.textSecondary.withValues(alpha: 153), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + metadata.filename, + style: TextStyle( + fontSize: 11, + color: AppTheme.textSecondary.withValues(alpha: 153), + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + _formatFileSize(metadata.originalSize), + style: TextStyle( + fontSize: 11, + color: AppTheme.textSecondary.withValues(alpha: 153), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildLoadingWidget() { + return Container( + constraints: const BoxConstraints( + maxWidth: 280, + maxHeight: 150, + minWidth: 200, + minHeight: 120, + ), + decoration: BoxDecoration( + color: AppTheme.backgroundInput, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 76), + width: 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: AppTheme.mostroGreen, + strokeWidth: 2, + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Decrypting image...', + style: TextStyle( + fontSize: 12, + color: AppTheme.textSecondary.withValues(alpha: 153), + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } + + Widget _buildErrorWidget() { + return Container( + constraints: const BoxConstraints( + maxWidth: 280, + maxHeight: 120, + minWidth: 200, + minHeight: 80, + ), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 25), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.red.withValues(alpha: 127), + width: 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: Colors.red, + size: 24, + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Failed to load image', + style: TextStyle( + fontSize: 12, + color: Colors.red, + ), + textAlign: TextAlign.center, + ), + ), + if (_errorMessage != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + _errorMessage!, + style: TextStyle( + fontSize: 10, + color: Colors.red.withValues(alpha: 153), + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Future _loadImage() async { + if (_isLoading) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final chatNotifier = ref.read(chatRoomsProvider(widget.orderId).notifier); + + // Parse the message content to get image data + final content = widget.message.content; + if (content == null || !content.startsWith('{')) { + throw Exception('Invalid image message format'); + } + + final imageData = EncryptedImageUploadResult.fromJson( + Map.from( + // ignore: avoid_dynamic_calls + jsonDecode(content) as Map + ) + ); + + // Get shared key + final sharedKey = await chatNotifier.getSharedKey(); + + // Download and decrypt image + final uploadService = EncryptedImageUploadService(); + final decryptedImage = await uploadService.downloadAndDecryptImage( + blossomUrl: imageData.blossomUrl, + nonceHex: imageData.nonce, + sharedKey: sharedKey, + ); + + // Cache the image + chatNotifier.cacheDecryptedImage(widget.message.id!, decryptedImage, imageData); + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = e.toString(); + }); + } + } + } + + String _formatFileSize(int bytes) { + if (bytes < 1024) return '${bytes}B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB'; + } +} + +/// Helper function to check if a message is an encrypted image +bool isEncryptedImageMessage(NostrEvent message) { + try { + final content = message.content; + if (content == null || !content.startsWith('{')) return false; + + // ignore: avoid_dynamic_calls + final jsonContent = jsonDecode(content) as Map; + return jsonContent['type'] == 'image_encrypted'; + } catch (e) { + return false; + } +} \ No newline at end of file diff --git a/lib/features/chat/widgets/message_bubble.dart b/lib/features/chat/widgets/message_bubble.dart index 6136dede..8d368d4b 100644 --- a/lib/features/chat/widgets/message_bubble.dart +++ b/lib/features/chat/widgets/message_bubble.dart @@ -1,22 +1,26 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/providers/avatar_provider.dart'; +import 'package:mostro_mobile/features/chat/widgets/encrypted_image_message.dart'; -class MessageBubble extends StatelessWidget { +class MessageBubble extends ConsumerWidget { final NostrEvent message; final String peerPubkey; + final String orderId; const MessageBubble({ super.key, required this.message, required this.peerPubkey, + required this.orderId, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final isFromPeer = message.pubkey == peerPubkey; final content = message.content; @@ -25,6 +29,44 @@ class MessageBubble extends StatelessWidget { return const SizedBox.shrink(); } + // Check if this is an encrypted image message + if (isEncryptedImageMessage(message)) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + alignment: isFromPeer ? Alignment.centerLeft : Alignment.centerRight, + child: Row( + mainAxisAlignment: isFromPeer ? MainAxisAlignment.start : MainAxisAlignment.end, + children: [ + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + minWidth: 0, + ), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isFromPeer ? _getPeerMessageColor(peerPubkey) : AppTheme.purpleAccent, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(isFromPeer ? 4 : 16), + bottomRight: Radius.circular(isFromPeer ? 16 : 4), + ), + ), + child: EncryptedImageMessage( + message: message, + orderId: orderId, + isOwnMessage: !isFromPeer, + ), + ), + ), + ), + ], + ), + ); + } + return Container( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), alignment: isFromPeer ? Alignment.centerLeft : Alignment.centerRight, diff --git a/lib/features/chat/widgets/message_input.dart b/lib/features/chat/widgets/message_input.dart index cad7d83d..02c00fa7 100644 --- a/lib/features/chat/widgets/message_input.dart +++ b/lib/features/chat/widgets/message_input.dart @@ -1,8 +1,13 @@ +import 'dart:io'; +import 'dart:convert'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; class MessageInput extends ConsumerStatefulWidget { final String orderId; @@ -23,6 +28,10 @@ class MessageInput extends ConsumerStatefulWidget { class _MessageInputState extends ConsumerState { final TextEditingController _textController = TextEditingController(); final FocusNode _focusNode = FocusNode(); + final ImagePicker _imagePicker = ImagePicker(); + final EncryptedImageUploadService _imageUploadService = EncryptedImageUploadService(); + + bool _isUploadingImage = false; // For loading indicator @override void initState() { @@ -57,6 +66,75 @@ class _MessageInputState extends ConsumerState { } } + // Handle image selection, encryption and upload + Future _selectAndUploadImage() async { + try { + setState(() { + _isUploadingImage = true; + }); + + // Show image picker modal + final pickedFile = await _imagePicker.pickImage( + source: ImageSource.gallery, + imageQuality: 85, // Compress for faster upload + ); + + if (pickedFile != null) { + // Get shared key for this order/chat + final sharedKey = await _getSharedKeyForOrder(widget.orderId); + + // Upload encrypted image to Blossom + final result = await _imageUploadService.uploadEncryptedImage( + imageFile: File(pickedFile.path), + sharedKey: sharedKey, + filename: pickedFile.name, + ); + + // Send encrypted image message via NIP-59 + await _sendEncryptedImageMessage(result); + } + } catch (e) { + // Show error to user + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error uploading image: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isUploadingImage = false; + }); + } + } + } + + // Get shared key for this order/chat session + Future _getSharedKeyForOrder(String orderId) async { + // Get the chat room notifier to access the shared key + final chatNotifier = ref.read(chatRoomsProvider(orderId).notifier); + return await chatNotifier.getSharedKey(); + } + + // Send encrypted image message via NIP-59 gift wrap + Future _sendEncryptedImageMessage(EncryptedImageUploadResult result) async { + try { + // Create JSON content for the rumor + final imageMessageJson = jsonEncode(result.toJson()); + + // Send via existing chat system (will be wrapped in NIP-59) + await ref + .read(chatRoomsProvider(widget.orderId).notifier) + .sendMessage(imageMessageJson); + + } catch (e) { + throw Exception('Failed to send encrypted image message: $e'); + } + } + @override @@ -84,6 +162,39 @@ class _MessageInputState extends ConsumerState { ), child: Row( children: [ + // + Button for image upload + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: AppTheme.backgroundDark, + shape: BoxShape.circle, + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 76), // 0.3 opacity + width: 1, + ), + ), + child: IconButton( + icon: _isUploadingImage + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: AppTheme.cream1, + strokeWidth: 2, + ), + ) + : Icon( + Icons.add, + color: AppTheme.cream1, + size: 20, + ), + onPressed: _isUploadingImage ? null : _selectAndUploadImage, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ), + const SizedBox(width: 8), Expanded( child: Container( decoration: BoxDecoration( @@ -119,7 +230,7 @@ class _MessageInputState extends ConsumerState { ), ), ), - const SizedBox(width: 12), + const SizedBox(width: 8), Container( width: 42, height: 42, diff --git a/lib/services/blossom_client.dart b/lib/services/blossom_client.dart new file mode 100644 index 00000000..bfe518b1 --- /dev/null +++ b/lib/services/blossom_client.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:http/http.dart' as http; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; + +class BlossomClient { + final String serverUrl; + final Duration timeout; + final Logger _logger = Logger(); + + BlossomClient({ + required this.serverUrl, + this.timeout = const Duration(minutes: 5), + }); + + /// Upload sanitized image to Blossom + Future uploadImage({ + required Uint8List imageData, + required String mimeType, + }) async { + // 1. Calculate SHA-256 hash of data + final hash = sha256.convert(imageData); + final hashHex = hash.toString(); + + _logger.d('Uploading image to Blossom: ${imageData.length} bytes, hash: $hashHex'); + + // 2. Generate unique keys for authentication + final authKeys = NostrUtils.generateKeyPair(); + + // 3. Create Nostr authentication event (kind 24242 for Blossom) + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final authEvent = NostrEvent.fromPartialData( + kind: 24242, + content: 'Upload image', + keyPairs: authKeys, + tags: [ + ['t', 'upload'], + ['x', hashHex], + ['expiration', (timestamp + 3600).toString()], // Valid for 1 hour + ], + createdAt: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + ); + + // 4. Encode authorization + final authBase64 = base64.encode( + utf8.encode(jsonEncode(authEvent.toMap())) + ); + + // 5. HTTP PUT request to Blossom upload endpoint + final url = Uri.parse('$serverUrl/upload'); + + _logger.d('PUT $url'); + + try { + final response = await http.put( + url, + headers: { + 'Content-Type': mimeType, + 'Authorization': 'Nostr $authBase64', + 'User-Agent': 'MostroMobile/1.0', + }, + body: imageData, + ).timeout(timeout); + + if (response.statusCode == 200 || response.statusCode == 201) { + // Blossom returns the file URL in the response, construct from hash + final blossomUrl = '$serverUrl/$hashHex'; + _logger.i('βœ… Image uploaded successfully to Blossom: $blossomUrl'); + return blossomUrl; + } else { + _logger.e('❌ Blossom upload failed: ${response.statusCode} - ${response.body}'); + throw BlossomException( + 'Upload failed: ${response.statusCode} - ${response.body}' + ); + } + } catch (e) { + _logger.e('❌ Blossom upload error: $e'); + rethrow; + } + } +} + +class BlossomException implements Exception { + final String message; + BlossomException(this.message); + + @override + String toString() => 'BlossomException: $message'; +} \ No newline at end of file diff --git a/lib/services/blossom_download_service.dart b/lib/services/blossom_download_service.dart new file mode 100644 index 00000000..a6dc7e5f --- /dev/null +++ b/lib/services/blossom_download_service.dart @@ -0,0 +1,167 @@ +import 'dart:typed_data'; +import 'package:http/http.dart' as http; +import 'package:logger/logger.dart'; + +class BlossomDownloadService { + static final Logger _logger = Logger(); + static const Duration _timeout = Duration(minutes: 2); + + /// Download encrypted blob from Blossom server + static Future downloadFromBlossom(String blossomUrl) async { + _logger.i('πŸ“₯ Starting download from Blossom: $blossomUrl'); + + try { + final uri = Uri.parse(blossomUrl); + _logger.d('GET $uri'); + + final response = await http.get( + uri, + headers: { + 'User-Agent': 'MostroMobile/1.0', + 'Accept': 'application/octet-stream, */*', + }, + ).timeout(_timeout); + + if (response.statusCode == 200) { + final data = response.bodyBytes; + _logger.i('βœ… Download successful: ${data.length} bytes'); + return data; + } else { + _logger.e('❌ Download failed: ${response.statusCode} - ${response.body}'); + throw BlossomDownloadException( + 'Download failed: HTTP ${response.statusCode} - ${response.body}' + ); + } + } catch (e) { + _logger.e('❌ Download error: $e'); + if (e is BlossomDownloadException) { + rethrow; + } + throw BlossomDownloadException('Network error: $e'); + } + } + + /// Download with retry mechanism for better reliability + static Future downloadWithRetry( + String blossomUrl, { + int maxRetries = 3, + Duration retryDelay = const Duration(seconds: 1), + }) async { + _logger.i('πŸ“₯ Download with retry: $blossomUrl (max $maxRetries attempts)'); + + Exception? lastException; + + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + _logger.d('Attempt $attempt/$maxRetries'); + return await downloadFromBlossom(blossomUrl); + } catch (e) { + lastException = e is Exception ? e : Exception(e.toString()); + _logger.w('❌ Attempt $attempt failed: $e'); + + if (attempt < maxRetries) { + _logger.d('⏳ Waiting ${retryDelay.inMilliseconds}ms before retry...'); + await Future.delayed(retryDelay); + // Exponential backoff + retryDelay = Duration(milliseconds: (retryDelay.inMilliseconds * 1.5).round()); + } + } + } + + _logger.e('❌ All download attempts failed'); + throw BlossomDownloadException('All $maxRetries download attempts failed. Last error: $lastException'); + } + + /// Check if a Blossom URL is valid and accessible (HEAD request) + static Future isBlossomUrlAccessible(String blossomUrl) async { + try { + final uri = Uri.parse(blossomUrl); + final response = await http.head(uri).timeout( + const Duration(seconds: 10) + ); + return response.statusCode == 200; + } catch (e) { + _logger.w('πŸ” URL accessibility check failed for $blossomUrl: $e'); + return false; + } + } + + /// Get content length without downloading the full file + static Future getContentLength(String blossomUrl) async { + try { + final uri = Uri.parse(blossomUrl); + final response = await http.head(uri).timeout( + const Duration(seconds: 10) + ); + + if (response.statusCode == 200) { + final contentLength = response.headers['content-length']; + if (contentLength != null) { + return int.tryParse(contentLength); + } + } + return null; + } catch (e) { + _logger.w('πŸ” Content length check failed for $blossomUrl: $e'); + return null; + } + } + + /// Download with progress callback for UI updates + static Future downloadWithProgress( + String blossomUrl, + void Function(int downloaded, int total)? onProgress, + ) async { + _logger.i('πŸ“₯ Download with progress tracking: $blossomUrl'); + + try { + final uri = Uri.parse(blossomUrl); + final request = http.Request('GET', uri); + request.headers['User-Agent'] = 'MostroMobile/1.0'; + request.headers['Accept'] = 'application/octet-stream, */*'; + + final client = http.Client(); + final response = await client.send(request).timeout(_timeout); + + if (response.statusCode != 200) { + throw BlossomDownloadException( + 'Download failed: HTTP ${response.statusCode}' + ); + } + + final contentLength = response.contentLength ?? 0; + final bytes = []; + int downloaded = 0; + + await for (final chunk in response.stream) { + bytes.addAll(chunk); + downloaded += chunk.length; + + if (onProgress != null && contentLength > 0) { + onProgress(downloaded, contentLength); + } + } + + client.close(); + + final data = Uint8List.fromList(bytes); + _logger.i('βœ… Download with progress completed: ${data.length} bytes'); + return data; + + } catch (e) { + _logger.e('❌ Download with progress failed: $e'); + if (e is BlossomDownloadException) { + rethrow; + } + throw BlossomDownloadException('Network error: $e'); + } + } +} + +class BlossomDownloadException implements Exception { + final String message; + BlossomDownloadException(this.message); + + @override + String toString() => 'BlossomDownloadException: $message'; +} \ No newline at end of file diff --git a/lib/services/encrypted_image_upload_service.dart b/lib/services/encrypted_image_upload_service.dart new file mode 100644 index 00000000..5a6f67a0 --- /dev/null +++ b/lib/services/encrypted_image_upload_service.dart @@ -0,0 +1,232 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/services/media_validation_service.dart'; +import 'package:mostro_mobile/services/blossom_client.dart'; +import 'package:mostro_mobile/services/encryption_service.dart'; +import 'package:mostro_mobile/services/blossom_download_service.dart'; + +class EncryptedImageUploadResult { + final String blossomUrl; + final String nonce; // Hex encoded + final String mimeType; + final int originalSize; + final int width; + final int height; + final String filename; + final int encryptedSize; + + EncryptedImageUploadResult({ + required this.blossomUrl, + required this.nonce, + required this.mimeType, + required this.originalSize, + required this.width, + required this.height, + required this.filename, + required this.encryptedSize, + }); + + /// Convert to JSON for NIP-59 rumor content + Map toJson() { + return { + 'type': 'image_encrypted', + 'blossom_url': blossomUrl, + 'nonce': nonce, + 'mime_type': mimeType, + 'original_size': originalSize, + 'width': width, + 'height': height, + 'filename': filename, + 'encrypted_size': encryptedSize, + }; + } + + /// Create from JSON (for receiving messages) + factory EncryptedImageUploadResult.fromJson(Map json) { + return EncryptedImageUploadResult( + blossomUrl: json['blossom_url'], + nonce: json['nonce'], + mimeType: json['mime_type'], + originalSize: json['original_size'], + width: json['width'], + height: json['height'], + filename: json['filename'], + encryptedSize: json['encrypted_size'], + ); + } +} + +class EncryptedImageUploadService { + final Logger _logger = Logger(); + + // List of Blossom servers (with fallbacks) + static const List _blossomServers = [ + 'https://blossom.primal.net', + 'https://blossom.band', + 'https://nostr.media', + 'https://blossom.sector01.com', + 'https://24242.io', + 'https://otherstuff.shaving.kiwi', + 'https://blossom.f7z.io', + 'https://nosto.re', + 'https://blossom.poster.place', + ]; + + EncryptedImageUploadService(); + + /// Upload encrypted image with complete sanitization and encryption + Future uploadEncryptedImage({ + required File imageFile, + required Uint8List sharedKey, + String? filename, + }) async { + _logger.i('πŸ”’ Starting encrypted image upload process...'); + + try { + // 1. Read file + final imageData = await imageFile.readAsBytes(); + _logger.d('Read image file: ${imageData.length} bytes'); + + // 2. Validate and sanitize (like whitenoise) + final validationResult = await MediaValidationService.validateAndSanitizeImage( + imageData + ); + + _logger.i( + 'Image validated and sanitized: ${validationResult.mimeType}, ' + '${validationResult.width}x${validationResult.height}, ' + '${validationResult.validatedData.length} bytes (sanitized)' + ); + + // 3. Encrypt with ChaCha20-Poly1305 + final encryptionResult = EncryptionService.encryptChaCha20Poly1305( + key: sharedKey, + plaintext: validationResult.validatedData, + ); + + final encryptedBlob = encryptionResult.toBlob(); + _logger.i( + 'πŸ” Image encrypted successfully: ${encryptedBlob.length} bytes ' + '(nonce: ${encryptionResult.nonce.length}B, ' + 'data: ${encryptionResult.encryptedData.length}B, ' + 'tag: ${encryptionResult.authTag.length}B)' + ); + + // 4. Upload encrypted blob to Blossom + final blossomUrl = await _uploadWithRetry( + encryptedBlob, + 'application/octet-stream', // Always octet-stream for encrypted data + ); + + // 5. Generate filename if not provided + final finalFilename = filename ?? + 'image_${DateTime.now().millisecondsSinceEpoch}.${validationResult.extension}'; + + final result = EncryptedImageUploadResult( + blossomUrl: blossomUrl, + nonce: _bytesToHex(encryptionResult.nonce), + mimeType: validationResult.mimeType, + originalSize: validationResult.validatedData.length, + width: validationResult.width, + height: validationResult.height, + filename: finalFilename, + encryptedSize: encryptedBlob.length, + ); + + _logger.i('πŸŽ‰ Encrypted image upload completed successfully!'); + _logger.i('πŸ“Έ Blossom URL: ${result.blossomUrl}'); + _logger.i('πŸ”‘ Nonce: ${result.nonce}'); + + return result; + + } catch (e) { + _logger.e('❌ Encrypted image upload failed: $e'); + rethrow; + } + } + + /// Download and decrypt image from Blossom + Future downloadAndDecryptImage({ + required String blossomUrl, + required String nonceHex, + required Uint8List sharedKey, + }) async { + _logger.i('πŸ”“ Starting encrypted image download and decryption...'); + _logger.d('URL: $blossomUrl'); + _logger.d('Nonce: $nonceHex'); + + try { + // 1. Download encrypted blob from Blossom + final encryptedBlob = await _downloadFromBlossom(blossomUrl); + _logger.i('πŸ“₯ Downloaded encrypted blob: ${encryptedBlob.length} bytes'); + + // 2. Decrypt with ChaCha20-Poly1305 + final decryptedImage = EncryptionService.decryptFromBlob( + key: sharedKey, + blob: encryptedBlob, + ); + + _logger.i('πŸ”“ Image decrypted successfully: ${decryptedImage.length} bytes'); + + return decryptedImage; + + } catch (e) { + _logger.e('❌ Image download/decryption failed: $e'); + rethrow; + } + } + + /// Upload with automatic retry to multiple servers + Future _uploadWithRetry(Uint8List encryptedData, String mimeType) async { + final servers = _blossomServers; // Always use real Blossom servers + + for (int i = 0; i < servers.length; i++) { + final serverUrl = servers[i]; + _logger.d('Attempting upload to server ${i + 1}/${servers.length}: $serverUrl'); + + try { + final client = BlossomClient(serverUrl: serverUrl); + final blossomUrl = await client.uploadImage( + imageData: encryptedData, + mimeType: mimeType, + ); + + _logger.i('βœ… Upload successful to: $serverUrl'); + return blossomUrl; + + } catch (e) { + _logger.w('❌ Upload failed to $serverUrl: $e'); + + // If it's the last server, re-throw the error + if (i == servers.length - 1) { + throw BlossomException('All Blossom servers failed. Last error: $e'); + } + + // Continue with next server + continue; + } + } + + throw BlossomException('No Blossom servers available'); + } + + /// Download from Blossom with retry + Future _downloadFromBlossom(String blossomUrl) async { + return await BlossomDownloadService.downloadWithRetry(blossomUrl); + } + + /// Convert bytes to hex string + String _bytesToHex(Uint8List bytes) { + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } +} + +class BlossomException implements Exception { + final String message; + BlossomException(this.message); + + @override + String toString() => 'BlossomException: $message'; +} \ No newline at end of file diff --git a/lib/services/encryption_service.dart b/lib/services/encryption_service.dart new file mode 100644 index 00000000..f71211e0 --- /dev/null +++ b/lib/services/encryption_service.dart @@ -0,0 +1,226 @@ +import 'dart:typed_data'; +import 'dart:math'; +import 'package:pointycastle/export.dart'; +import 'package:logger/logger.dart'; + +class EncryptionResult { + final Uint8List encryptedData; + final Uint8List nonce; + final Uint8List authTag; + + EncryptionResult({ + required this.encryptedData, + required this.nonce, + required this.authTag, + }); + + /// Combine encrypted data and auth tag into a single blob for storage + Uint8List toBlob() { + final blob = Uint8List(nonce.length + encryptedData.length + authTag.length); + int offset = 0; + + // Structure: [nonce][encrypted_data][auth_tag] + blob.setRange(offset, offset + nonce.length, nonce); + offset += nonce.length; + + blob.setRange(offset, offset + encryptedData.length, encryptedData); + offset += encryptedData.length; + + blob.setRange(offset, offset + authTag.length, authTag); + + return blob; + } + + /// Extract components from a blob + static EncryptionResult fromBlob(Uint8List blob) { + if (blob.length < 28) { // 12 (nonce) + 16 (auth tag) = minimum 28 bytes + throw ArgumentError('Blob too small for ChaCha20-Poly1305'); + } + + const nonceLength = 12; + const authTagLength = 16; + + final nonce = blob.sublist(0, nonceLength); + final authTag = blob.sublist(blob.length - authTagLength); + final encryptedData = blob.sublist(nonceLength, blob.length - authTagLength); + + return EncryptionResult( + encryptedData: encryptedData, + nonce: nonce, + authTag: authTag, + ); + } +} + +class EncryptionService { + static final Logger _logger = Logger(); + static final SecureRandom _secureRandom = SecureRandom('Fortuna') + ..seed(KeyParameter(_generateSeed())); + + /// Generate cryptographically secure random bytes + static Uint8List generateSecureRandom(int length) { + final bytes = Uint8List(length); + for (int i = 0; i < length; i++) { + bytes[i] = _secureRandom.nextUint8(); + } + return bytes; + } + + /// Generate random seed for SecureRandom + static Uint8List _generateSeed() { + final random = Random.secure(); + final seed = Uint8List(32); + for (int i = 0; i < seed.length; i++) { + seed[i] = random.nextInt(256); + } + return seed; + } + + /// Encrypt data using ChaCha20-Poly1305 + static EncryptionResult encryptChaCha20Poly1305({ + required Uint8List key, + required Uint8List plaintext, + Uint8List? nonce, + Uint8List? additionalData, + }) { + if (key.length != 32) { + throw ArgumentError('ChaCha20 key must be 32 bytes'); + } + + // Generate nonce if not provided + nonce ??= generateSecureRandom(12); + if (nonce.length != 12) { + throw ArgumentError('ChaCha20-Poly1305 nonce must be 12 bytes'); + } + + _logger.d('Encrypting ${plaintext.length} bytes with ChaCha20-Poly1305'); + + try { + // Create ChaCha20-Poly1305 cipher + final cipher = ChaCha20Poly1305(ChaCha7539Engine(), Poly1305()); + + // Initialize with key and nonce + final params = AEADParameters( + KeyParameter(key), + 128, // 128-bit authentication tag + nonce, + additionalData ?? Uint8List(0), + ); + + cipher.init(true, params); // true for encryption + + // Encrypt the data + final output = Uint8List(cipher.getOutputSize(plaintext.length)); + int len = cipher.processBytes(plaintext, 0, plaintext.length, output, 0); + len += cipher.doFinal(output, len); + + // Split encrypted data and authentication tag + final encryptedData = output.sublist(0, plaintext.length); + final authTag = output.sublist(plaintext.length, len); + + _logger.i('βœ… Encryption successful: ${encryptedData.length} bytes + ${authTag.length} bytes tag'); + + return EncryptionResult( + encryptedData: encryptedData, + nonce: nonce, + authTag: authTag, + ); + } catch (e) { + _logger.e('❌ ChaCha20-Poly1305 encryption failed: $e'); + throw EncryptionException('Encryption failed: $e'); + } + } + + /// Decrypt data using ChaCha20-Poly1305 + static Uint8List decryptChaCha20Poly1305({ + required Uint8List key, + required Uint8List nonce, + required Uint8List encryptedData, + required Uint8List authTag, + Uint8List? additionalData, + }) { + if (key.length != 32) { + throw ArgumentError('ChaCha20 key must be 32 bytes'); + } + if (nonce.length != 12) { + throw ArgumentError('ChaCha20-Poly1305 nonce must be 12 bytes'); + } + if (authTag.length != 16) { + throw ArgumentError('Poly1305 authentication tag must be 16 bytes'); + } + + _logger.d('Decrypting ${encryptedData.length} bytes with ChaCha20-Poly1305'); + + try { + // Create ChaCha20-Poly1305 cipher + final cipher = ChaCha20Poly1305(ChaCha7539Engine(), Poly1305()); + + // Initialize with key and nonce + final params = AEADParameters( + KeyParameter(key), + 128, // 128-bit authentication tag + nonce, + additionalData ?? Uint8List(0), + ); + + cipher.init(false, params); // false for decryption + + // Combine encrypted data and auth tag for input + final cipherInput = Uint8List(encryptedData.length + authTag.length); + cipherInput.setRange(0, encryptedData.length, encryptedData); + cipherInput.setRange(encryptedData.length, cipherInput.length, authTag); + + // Decrypt the data + final output = Uint8List(cipher.getOutputSize(cipherInput.length)); + int len = cipher.processBytes(cipherInput, 0, cipherInput.length, output, 0); + len += cipher.doFinal(output, len); + + final decryptedData = output.sublist(0, len); + + _logger.i('βœ… Decryption successful: ${decryptedData.length} bytes'); + + return decryptedData; + } catch (e) { + _logger.e('❌ ChaCha20-Poly1305 decryption failed: $e'); + throw EncryptionException('Decryption failed: $e'); + } + } + + /// Convenience method to encrypt and return a blob + static Uint8List encryptToBlob({ + required Uint8List key, + required Uint8List plaintext, + Uint8List? additionalData, + }) { + final result = encryptChaCha20Poly1305( + key: key, + plaintext: plaintext, + additionalData: additionalData, + ); + return result.toBlob(); + } + + /// Convenience method to decrypt from a blob + static Uint8List decryptFromBlob({ + required Uint8List key, + required Uint8List blob, + Uint8List? additionalData, + }) { + final result = EncryptionResult.fromBlob(blob); + return decryptChaCha20Poly1305( + key: key, + nonce: result.nonce, + encryptedData: result.encryptedData, + authTag: result.authTag, + additionalData: additionalData, + ); + } +} + +class EncryptionException implements Exception { + final String message; + EncryptionException(this.message); + + @override + String toString() => 'EncryptionException: $message'; +} \ No newline at end of file diff --git a/lib/services/image_upload_service.dart b/lib/services/image_upload_service.dart new file mode 100644 index 00000000..c56ff8dd --- /dev/null +++ b/lib/services/image_upload_service.dart @@ -0,0 +1,95 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/services/media_validation_service.dart'; +import 'package:mostro_mobile/services/blossom_client.dart'; + +class ImageUploadService { + final Logger _logger = Logger(); + + // List of Blossom servers (with fallbacks) + static const List _blossomServers = [ + 'https://blossom.primal.net', + 'https://blossom.band', + 'https://nostr.media', + 'https://blossom.sector01.com', + 'https://24242.io', + 'https://otherstuff.shaving.kiwi', + 'https://blossom.f7z.io', + 'https://nosto.re', + 'https://blossom.poster.place', + ]; + + ImageUploadService(); + + /// Upload image with complete sanitization + Future uploadImage(File imageFile) async { + _logger.i('Starting image upload process...'); + + try { + // 1. Read file + final imageData = await imageFile.readAsBytes(); + _logger.d('Read image file: ${imageData.length} bytes'); + + // 2. Validate and sanitize (like whitenoise) + final validationResult = await MediaValidationService.validateAndSanitizeImage( + imageData + ); + + _logger.i( + 'Image validated and sanitized: ${validationResult.mimeType}, ' + '${validationResult.width}x${validationResult.height}, ' + '${validationResult.validatedData.length} bytes (sanitized)' + ); + + // 3. Upload to Blossom with sanitized data + final blossomUrl = await _uploadWithRetry( + validationResult.validatedData, + validationResult.mimeType, + ); + + _logger.i('πŸŽ‰ Image upload completed successfully!'); + _logger.i('πŸ“Έ Blossom URL: $blossomUrl'); + + return blossomUrl; + + } catch (e) { + _logger.e('❌ Image upload failed: $e'); + rethrow; + } + } + + /// Upload with automatic retry to multiple servers + Future _uploadWithRetry(Uint8List imageData, String mimeType) async { + final servers = _blossomServers; // Always use real Blossom servers + + for (int i = 0; i < servers.length; i++) { + final serverUrl = servers[i]; + _logger.d('Attempting upload to server ${i + 1}/${servers.length}: $serverUrl'); + + try { + final client = BlossomClient(serverUrl: serverUrl); + final blossomUrl = await client.uploadImage( + imageData: imageData, + mimeType: mimeType, + ); + + _logger.i('βœ… Upload successful to: $serverUrl'); + return blossomUrl; + + } catch (e) { + _logger.w('❌ Upload failed to $serverUrl: $e'); + + // If it's the last server, re-throw the error + if (i == servers.length - 1) { + throw BlossomException('All Blossom servers failed. Last error: $e'); + } + + // Continue with next server + continue; + } + } + + throw BlossomException('No Blossom servers available'); + } +} \ No newline at end of file diff --git a/lib/services/media_validation_service.dart b/lib/services/media_validation_service.dart new file mode 100644 index 00000000..0aaabc9a --- /dev/null +++ b/lib/services/media_validation_service.dart @@ -0,0 +1,158 @@ +import 'dart:typed_data'; +import 'package:image/image.dart' as img; +import 'package:mime/mime.dart'; + +enum SupportedImageType { + jpeg, + png, + gif, + webp, +} + +extension SupportedImageTypeExtension on SupportedImageType { + String get mimeType { + switch (this) { + case SupportedImageType.jpeg: + return 'image/jpeg'; + case SupportedImageType.png: + return 'image/png'; + case SupportedImageType.gif: + return 'image/gif'; + case SupportedImageType.webp: + return 'image/webp'; + } + } + + String get extension { + switch (this) { + case SupportedImageType.jpeg: + return 'jpg'; + case SupportedImageType.png: + return 'png'; + case SupportedImageType.gif: + return 'gif'; + case SupportedImageType.webp: + return 'webp'; + } + } + + static const List all = [ + SupportedImageType.jpeg, + SupportedImageType.png, + SupportedImageType.gif, + SupportedImageType.webp, + ]; +} + +class MediaValidationResult { + final SupportedImageType imageType; + final String mimeType; + final String extension; + final Uint8List validatedData; + final int width; + final int height; + + MediaValidationResult({ + required this.imageType, + required this.mimeType, + required this.extension, + required this.validatedData, + required this.width, + required this.height, + }); +} + +class MediaValidationService { + /// Sanitizes and validates image exactly like whitenoise + /// 1. Detects format from data (not extension) + /// 2. Validates that it's a complete and valid image + /// 3. Re-encodes to eliminate malicious metadata + static Future validateAndSanitizeImage( + Uint8List imageData, + ) async { + if (imageData.isEmpty) { + throw MediaValidationException('File is empty'); + } + + // STEP 1: Detect format using magic bytes (like whitenoise) + SupportedImageType? detectedType; + + // Use mime package for initial detection + final mimeType = lookupMimeType('', headerBytes: imageData); + + switch (mimeType) { + case 'image/jpeg': + detectedType = SupportedImageType.jpeg; + break; + case 'image/png': + detectedType = SupportedImageType.png; + break; + case 'image/gif': + detectedType = SupportedImageType.gif; + break; + case 'image/webp': + detectedType = SupportedImageType.webp; + break; + default: + throw MediaValidationException( + 'Unsupported image format: $mimeType. Supported formats: ${SupportedImageTypeExtension.all.map((t) => t.mimeType).join(", ")}' + ); + } + + // STEP 2: Validate using image package (like whitenoise) + // This ensures the image is valid and complete + img.Image? decodedImage; + try { + decodedImage = img.decodeImage(imageData); + if (decodedImage == null) { + throw MediaValidationException( + 'Invalid or corrupted ${detectedType.mimeType} image: Could not decode' + ); + } + } catch (e) { + throw MediaValidationException( + 'Invalid or corrupted ${detectedType.mimeType} image: $e' + ); + } + + // STEP 3: Re-encode to sanitize (removes EXIF and other metadata) + Uint8List sanitizedData; + try { + switch (detectedType) { + case SupportedImageType.jpeg: + sanitizedData = Uint8List.fromList(img.encodeJpg(decodedImage, quality: 90)); + break; + case SupportedImageType.png: + sanitizedData = Uint8List.fromList(img.encodePng(decodedImage)); + break; + case SupportedImageType.gif: + sanitizedData = Uint8List.fromList(img.encodeGif(decodedImage)); + break; + case SupportedImageType.webp: + // Convert WebP to PNG (WebP encoding not available in this image package version) + sanitizedData = Uint8List.fromList(img.encodePng(decodedImage)); + detectedType = SupportedImageType.png; + break; + } + } catch (e) { + throw MediaValidationException('Failed to re-encode image: $e'); + } + + return MediaValidationResult( + imageType: detectedType, + mimeType: detectedType.mimeType, + extension: detectedType.extension, + validatedData: sanitizedData, + width: decodedImage.width, + height: decodedImage.height, + ); + } +} + +class MediaValidationException implements Exception { + final String message; + MediaValidationException(this.message); + + @override + String toString() => 'MediaValidationException: $message'; +} \ No newline at end of file From ef639d54d0060d9700360ffca1c68e9c28ea3eba Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:45:34 -0600 Subject: [PATCH 02/30] update dependencies --- android/app/build.gradle | 9 ++++ pubspec.lock | 110 ++++++++++++++++++++++++++++++++++++--- pubspec.yaml | 8 +++ 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 47bd7711..582938fd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -112,6 +112,15 @@ flutter { source = "../.." } +// Minimal AndroidX conflict resolution - only fix the specific build error +configurations.all { + resolutionStrategy { + // Only force the problematic dependencies, not all AndroidX + force 'androidx.activity:activity:1.9.2' + force 'androidx.activity:activity-ktx:1.9.2' + } +} + dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' } diff --git a/pubspec.lock b/pubspec.lock index 19853249..c24e294e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -417,6 +417,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -784,13 +816,77 @@ packages: source: hosted version: "2.6.7" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" url: "https://pub.dev" source: hosted version: "4.5.4" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" + url: "https://pub.dev" + source: hosted + version: "0.8.13+10" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "997d100ce1dda5b1ba4085194c5e36c9f8a1fb7987f6a36ab677a344cd2dc986" + url: "https://pub.dev" + source: hosted + version: "0.8.13+2" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" integration_test: dependency: "direct dev" description: flutter @@ -957,13 +1053,13 @@ packages: source: hosted version: "1.16.0" mime: - dependency: transitive + dependency: "direct main" description: name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "1.0.6" mockito: dependency: "direct dev" description: @@ -1134,7 +1230,7 @@ packages: source: hosted version: "2.1.8" pointycastle: - dependency: transitive + dependency: "direct main" description: name: pointycastle sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" @@ -1699,5 +1795,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0 <=3.9.9" - flutter: ">=3.27.0" + dart: ">=3.9.0 <=3.9.9" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index bb9caa15..61e3c5c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -83,6 +83,14 @@ dependencies: auto_size_text: ^3.0.0 app_links: ^6.4.0 + + # Dependencies for image upload to Blossom + image_picker: ^1.0.4 + mime: ^1.0.4 + image: ^4.1.3 + + # ChaCha20-Poly1305 encryption + pointycastle: ^3.9.1 dev_dependencies: From a1328eac3dbe58e95d7b986166f8a4b909a95be3 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:24:58 -0600 Subject: [PATCH 03/30] add more files, pdf, txt, videos --- .../chat/notifiers/chat_room_notifier.dart | 108 +++- .../chat/widgets/encrypted_file_message.dart | 522 ++++++++++++++++++ lib/features/chat/widgets/message_bubble.dart | 66 +++ lib/features/chat/widgets/message_input.dart | 62 ++- .../encrypted_file_upload_service.dart | 222 ++++++++ lib/services/file_validation_service.dart | 184 ++++++ pubspec.lock | 72 +++ pubspec.yaml | 4 + 8 files changed, 1204 insertions(+), 36 deletions(-) create mode 100644 lib/features/chat/widgets/encrypted_file_message.dart create mode 100644 lib/services/encrypted_file_upload_service.dart create mode 100644 lib/services/file_validation_service.dart diff --git a/lib/features/chat/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart index 8702f528..2a16257c 100644 --- a/lib/features/chat/notifiers/chat_room_notifier.dart +++ b/lib/features/chat/notifiers/chat_room_notifier.dart @@ -9,6 +9,7 @@ import 'package:mostro_mobile/data/models/chat_room.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; +import 'package:mostro_mobile/services/encrypted_file_upload_service.dart'; import 'package:sembast/sembast.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; @@ -421,15 +422,17 @@ class ChatRoomNotifier extends StateNotifier { return; } - // Check if it's an encrypted image message - if (jsonContent != null && jsonContent['type'] == 'image_encrypted') { - _logger.i('πŸ“Έ Processing encrypted image message'); - await _processEncryptedImageMessage(message, jsonContent); + // Check for encrypted message types + if (jsonContent != null) { + if (jsonContent['type'] == 'image_encrypted') { + _logger.i('πŸ“Έ Processing encrypted image message'); + await _processEncryptedImageMessage(message, jsonContent); + } else if (jsonContent['type'] == 'file_encrypted') { + _logger.i('πŸ“Ž Processing encrypted file message'); + await _processEncryptedFileMessage(message, jsonContent); + } } - // Future: Handle other message types here - // else if (jsonContent['type'] == 'document_encrypted') { ... } - } catch (e) { _logger.w('Error processing message content: $e'); // Don't rethrow - message should still be displayed as text @@ -475,6 +478,10 @@ class ChatRoomNotifier extends StateNotifier { // Simple cache for decrypted images final Map _imageCache = {}; final Map _imageMetadata = {}; + + // Simple cache for decrypted files + final Map _fileCache = {}; + final Map _fileMetadata = {}; /// Cache a decrypted image for quick display void cacheDecryptedImage( @@ -497,6 +504,80 @@ class ChatRoomNotifier extends StateNotifier { return _imageMetadata[messageId]; } + /// Process encrypted file message by pre-downloading and caching + Future _processEncryptedFileMessage( + NostrEvent message, + Map fileData + ) async { + try { + // Extract file metadata + final result = EncryptedFileUploadResult.fromJson(fileData); + + _logger.i('πŸ“₯ File message received: ${result.filename} (${result.fileType})'); + _logger.d('Blossom URL: ${result.blossomUrl}'); + _logger.d('Original size: ${result.originalSize} bytes'); + + // Auto-download images for preview, but not other files + if (result.fileType == 'image') { + _logger.i('πŸ“Έ Auto-downloading image for preview: ${result.filename}'); + + try { + // Get shared key for decryption + final sharedKey = await getSharedKey(); + + // Download and decrypt image in background + final uploadService = EncryptedFileUploadService(); + final decryptedFile = await uploadService.downloadAndDecryptFile( + blossomUrl: result.blossomUrl, + nonceHex: result.nonce, + sharedKey: sharedKey, + ); + + _logger.i('βœ… Image downloaded and decrypted successfully: ${decryptedFile.length} bytes'); + + // Cache the decrypted image for immediate display + cacheDecryptedFile(message.id!, decryptedFile, result); + + } catch (e) { + _logger.e('❌ Failed to auto-download image: $e'); + // Store metadata without file data - user can manually download + cacheDecryptedFile(message.id!, null, result); + } + } else { + // Don't pre-download non-image files - let user choose when to download + // Just store the metadata for display + cacheDecryptedFile(message.id!, null, result); + } + + } catch (e) { + _logger.e('❌ Failed to process encrypted file: $e'); + // Don't rethrow - message should still be displayed (maybe with error indicator) + } + } + + /// Cache a decrypted file for quick display + void cacheDecryptedFile( + String messageId, + Uint8List? fileData, + EncryptedFileUploadResult metadata + ) { + if (fileData != null) { + _fileCache[messageId] = fileData; + } + _fileMetadata[messageId] = metadata; + _logger.d('πŸ—„οΈ Cached file metadata for message: $messageId'); + } + + /// Get cached decrypted file data + Uint8List? getCachedFile(String messageId) { + return _fileCache[messageId]; + } + + /// Get cached file metadata + EncryptedFileUploadResult? getFileMetadata(String messageId) { + return _fileMetadata[messageId]; + } + /// Check if a message is an encrypted image message bool isEncryptedImageMessage(NostrEvent message) { try { @@ -510,6 +591,19 @@ class ChatRoomNotifier extends StateNotifier { } } + /// Check if a message is an encrypted file message + bool isEncryptedFileMessage(NostrEvent message) { + try { + final content = message.content; + if (content == null || !content.startsWith('{')) return false; + + final jsonContent = jsonDecode(content); + return jsonContent['type'] == 'file_encrypted'; + } catch (e) { + return false; + } + } + @override void dispose() { _subscription?.cancel(); diff --git a/lib/features/chat/widgets/encrypted_file_message.dart b/lib/features/chat/widgets/encrypted_file_message.dart new file mode 100644 index 00000000..b754111e --- /dev/null +++ b/lib/features/chat/widgets/encrypted_file_message.dart @@ -0,0 +1,522 @@ +import 'dart:typed_data'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:open_file/open_file.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/services/encrypted_file_upload_service.dart'; +import 'package:mostro_mobile/services/file_validation_service.dart'; + +class EncryptedFileMessage extends ConsumerStatefulWidget { + final NostrEvent message; + final String orderId; + final bool isOwnMessage; + + const EncryptedFileMessage({ + super.key, + required this.message, + required this.orderId, + required this.isOwnMessage, + }); + + @override + ConsumerState createState() => _EncryptedFileMessageState(); +} + +class _EncryptedFileMessageState extends ConsumerState { + bool _isLoading = false; + String? _errorMessage; + + @override + Widget build(BuildContext context) { + final chatNotifier = ref.read(chatRoomsProvider(widget.orderId).notifier); + + // Check if file is already cached + final cachedFile = chatNotifier.getCachedFile(widget.message.id!); + final fileMetadata = chatNotifier.getFileMetadata(widget.message.id!); + + if (cachedFile != null && fileMetadata != null) { + // Check if it's an image and show preview + if (_isImage(fileMetadata.fileType)) { + return _buildImagePreview(cachedFile, fileMetadata); + } else { + return _buildFileWidget(cachedFile, fileMetadata); + } + } + + if (_isLoading) { + return _buildLoadingWidget(); + } + + if (_errorMessage != null) { + return _buildErrorWidget(); + } + + // Try to load the file metadata (for display without downloading) + final metadata = _parseFileMetadata(); + if (metadata != null) { + // For images, try to download automatically for preview + if (_isImage(metadata.fileType)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_isLoading) { + _downloadFile(); + } + }); + } + return _buildFileWidget(null, metadata); + } + + return _buildErrorWidget(); + } + + bool _isImage(String fileType) { + return fileType == 'image'; + } + + Widget _buildImagePreview(Uint8List imageData, EncryptedFileUploadResult metadata) { + return Container( + constraints: const BoxConstraints( + maxWidth: 280, + maxHeight: 300, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + children: [ + // Image preview + Image.memory( + imageData, + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 200, + color: Colors.grey.withValues(alpha: 51), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.broken_image, + color: AppTheme.textSecondary, + size: 48, + ), + const SizedBox(height: 8), + Text( + 'Failed to load image', + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ], + ), + ); + }, + ), + // Image overlay info + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withValues(alpha: 178), + Colors.transparent, + ], + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + metadata.filename, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + _formatFileSize(metadata.originalSize), + style: TextStyle( + color: Colors.white.withValues(alpha: 178), + fontSize: 10, + ), + ), + ], + ), + ), + IconButton( + onPressed: () => _openFile(), + icon: Icon( + Icons.open_in_new, + color: Colors.white.withValues(alpha: 178), + size: 16, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildFileWidget(Uint8List? fileData, EncryptedFileUploadResult metadata) { + final icon = _getFileIcon(metadata.fileType); + final isDownloaded = fileData != null; + + return Container( + constraints: const BoxConstraints( + maxWidth: 280, + minWidth: 200, + ), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 25), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 76), + width: 1, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // File info row + Row( + children: [ + Text( + icon, + style: const TextStyle(fontSize: 32), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + metadata.filename, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.cream1, + ), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + _formatFileSize(metadata.originalSize), + style: TextStyle( + fontSize: 12, + color: AppTheme.textSecondary.withValues(alpha: 153), + ), + ), + const SizedBox(width: 8), + Icon( + Icons.lock, + size: 12, + color: AppTheme.textSecondary.withValues(alpha: 153), + ), + Text( + ' Encrypted', + style: TextStyle( + fontSize: 11, + color: AppTheme.textSecondary.withValues(alpha: 153), + ), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + // Download/Open button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: isDownloaded ? _openFile : _downloadFile, + icon: Icon( + isDownloaded ? Icons.open_in_new : Icons.download, + size: 16, + ), + label: Text( + isDownloaded ? 'Open File' : 'Download', + style: const TextStyle(fontSize: 13), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildLoadingWidget() { + return Container( + constraints: const BoxConstraints( + maxWidth: 280, + minWidth: 200, + maxHeight: 120, + minHeight: 80, + ), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.backgroundInput, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 76), + width: 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: AppTheme.mostroGreen, + strokeWidth: 2, + ), + ), + const SizedBox(height: 8), + Text( + 'Downloading file...', + style: TextStyle( + fontSize: 12, + color: AppTheme.textSecondary.withValues(alpha: 153), + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Widget _buildErrorWidget() { + return Container( + constraints: const BoxConstraints( + maxWidth: 280, + minWidth: 200, + maxHeight: 120, + minHeight: 80, + ), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 25), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.red.withValues(alpha: 127), + width: 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: Colors.red, + size: 24, + ), + const SizedBox(height: 4), + Text( + 'Failed to load file', + style: TextStyle( + fontSize: 12, + color: Colors.red, + ), + textAlign: TextAlign.center, + ), + if (_errorMessage != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + _errorMessage!, + style: TextStyle( + fontSize: 10, + color: Colors.red.withValues(alpha: 153), + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + EncryptedFileUploadResult? _parseFileMetadata() { + try { + final content = widget.message.content; + if (content == null || !content.startsWith('{')) { + return null; + } + + final fileData = EncryptedFileUploadResult.fromJson( + Map.from( + jsonDecode(content) as Map + ) + ); + + return fileData; + } catch (e) { + return null; + } + } + + Future _downloadFile() async { + if (_isLoading) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final chatNotifier = ref.read(chatRoomsProvider(widget.orderId).notifier); + final metadata = _parseFileMetadata(); + + if (metadata == null) { + throw Exception('Invalid file message format'); + } + + // Get shared key + final sharedKey = await chatNotifier.getSharedKey(); + + // Download and decrypt file + final uploadService = EncryptedFileUploadService(); + final decryptedFile = await uploadService.downloadAndDecryptFile( + blossomUrl: metadata.blossomUrl, + nonceHex: metadata.nonce, + sharedKey: sharedKey, + ); + + // Cache the file + chatNotifier.cacheDecryptedFile(widget.message.id!, decryptedFile, metadata); + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = e.toString(); + }); + } + } + } + + Future _openFile() async { + try { + final chatNotifier = ref.read(chatRoomsProvider(widget.orderId).notifier); + final cachedFile = chatNotifier.getCachedFile(widget.message.id!); + final metadata = chatNotifier.getFileMetadata(widget.message.id!); + + if (cachedFile == null || metadata == null) { + throw Exception('File not available'); + } + + // Save file to temporary directory + final tempDir = await getTemporaryDirectory(); + final tempFile = File('${tempDir.path}/${metadata.filename}'); + await tempFile.writeAsBytes(cachedFile); + + // Open file with system default app + final result = await OpenFile.open(tempFile.path); + + if (mounted) { + if (result.type == ResultType.done) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Opening ${metadata.filename}'), + backgroundColor: AppTheme.mostroGreen, + duration: const Duration(seconds: 2), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Could not open file: ${result.message}'), + backgroundColor: Colors.orange, + duration: const Duration(seconds: 3), + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error opening file: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + String _getFileIcon(String fileType) { + return FileValidationService.getFileTypeIcon(fileType); + } + + String _formatFileSize(int bytes) { + if (bytes < 1024) return '${bytes}B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB'; + } +} + +/// Helper function to check if a message is an encrypted file +bool isEncryptedFileMessage(NostrEvent message) { + try { + final content = message.content; + if (content == null || !content.startsWith('{')) return false; + + final jsonContent = jsonDecode(content) as Map; + return jsonContent['type'] == 'file_encrypted'; + } catch (e) { + return false; + } +} \ No newline at end of file diff --git a/lib/features/chat/widgets/message_bubble.dart b/lib/features/chat/widgets/message_bubble.dart index 8d368d4b..a6f9986d 100644 --- a/lib/features/chat/widgets/message_bubble.dart +++ b/lib/features/chat/widgets/message_bubble.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -6,6 +7,33 @@ import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/providers/avatar_provider.dart'; import 'package:mostro_mobile/features/chat/widgets/encrypted_image_message.dart'; +import 'package:mostro_mobile/features/chat/widgets/encrypted_file_message.dart'; + +/// Helper function to check if a message is an encrypted image +bool isEncryptedImageMessage(NostrEvent message) { + try { + final content = message.content; + if (content == null || !content.startsWith('{')) return false; + + final jsonContent = jsonDecode(content); + return jsonContent['type'] == 'image_encrypted'; + } catch (e) { + return false; + } +} + +/// Helper function to check if a message is an encrypted file +bool isEncryptedFileMessage(NostrEvent message) { + try { + final content = message.content; + if (content == null || !content.startsWith('{')) return false; + + final jsonContent = jsonDecode(content); + return jsonContent['type'] == 'file_encrypted'; + } catch (e) { + return false; + } +} class MessageBubble extends ConsumerWidget { final NostrEvent message; @@ -67,6 +95,44 @@ class MessageBubble extends ConsumerWidget { ); } + // Check if this is an encrypted file message + if (isEncryptedFileMessage(message)) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + alignment: isFromPeer ? Alignment.centerLeft : Alignment.centerRight, + child: Row( + mainAxisAlignment: isFromPeer ? MainAxisAlignment.start : MainAxisAlignment.end, + children: [ + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + minWidth: 0, + ), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isFromPeer ? _getPeerMessageColor(peerPubkey) : AppTheme.purpleAccent, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(isFromPeer ? 4 : 16), + bottomRight: Radius.circular(isFromPeer ? 16 : 4), + ), + ), + child: EncryptedFileMessage( + message: message, + orderId: orderId, + isOwnMessage: !isFromPeer, + ), + ), + ), + ), + ], + ), + ); + } + return Container( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), alignment: isFromPeer ? Alignment.centerLeft : Alignment.centerRight, diff --git a/lib/features/chat/widgets/message_input.dart b/lib/features/chat/widgets/message_input.dart index 02c00fa7..49932978 100644 --- a/lib/features/chat/widgets/message_input.dart +++ b/lib/features/chat/widgets/message_input.dart @@ -3,11 +3,12 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:image_picker/image_picker.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/generated/l10n.dart'; -import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; +import 'package:mostro_mobile/services/encrypted_file_upload_service.dart'; +import 'package:mostro_mobile/services/file_validation_service.dart'; class MessageInput extends ConsumerStatefulWidget { final String orderId; @@ -28,10 +29,9 @@ class MessageInput extends ConsumerStatefulWidget { class _MessageInputState extends ConsumerState { final TextEditingController _textController = TextEditingController(); final FocusNode _focusNode = FocusNode(); - final ImagePicker _imagePicker = ImagePicker(); - final EncryptedImageUploadService _imageUploadService = EncryptedImageUploadService(); + final EncryptedFileUploadService _fileUploadService = EncryptedFileUploadService(); - bool _isUploadingImage = false; // For loading indicator + bool _isUploadingFile = false; // For loading indicator @override void initState() { @@ -66,39 +66,43 @@ class _MessageInputState extends ConsumerState { } } - // Handle image selection, encryption and upload - Future _selectAndUploadImage() async { + // Handle file selection, encryption and upload + Future _selectAndUploadFile() async { try { setState(() { - _isUploadingImage = true; + _isUploadingFile = true; }); - // Show image picker modal - final pickedFile = await _imagePicker.pickImage( - source: ImageSource.gallery, - imageQuality: 85, // Compress for faster upload + // Show native file picker + FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: FileValidationService.getSupportedExtensions() + .map((ext) => ext.substring(1)) // Remove the dot from extensions + .toList(), + allowMultiple: false, ); - if (pickedFile != null) { + if (result != null && result.files.single.path != null) { + final file = File(result.files.single.path!); + // Get shared key for this order/chat final sharedKey = await _getSharedKeyForOrder(widget.orderId); - // Upload encrypted image to Blossom - final result = await _imageUploadService.uploadEncryptedImage( - imageFile: File(pickedFile.path), + // Upload encrypted file to Blossom + final uploadResult = await _fileUploadService.uploadEncryptedFile( + file: file, sharedKey: sharedKey, - filename: pickedFile.name, ); - // Send encrypted image message via NIP-59 - await _sendEncryptedImageMessage(result); + // Send encrypted file message via NIP-59 + await _sendEncryptedFileMessage(uploadResult); } } catch (e) { // Show error to user if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error uploading image: $e'), + content: Text('Error uploading file: $e'), backgroundColor: Colors.red, ), ); @@ -106,7 +110,7 @@ class _MessageInputState extends ConsumerState { } finally { if (mounted) { setState(() { - _isUploadingImage = false; + _isUploadingFile = false; }); } } @@ -119,19 +123,19 @@ class _MessageInputState extends ConsumerState { return await chatNotifier.getSharedKey(); } - // Send encrypted image message via NIP-59 gift wrap - Future _sendEncryptedImageMessage(EncryptedImageUploadResult result) async { + // Send encrypted file message via NIP-59 gift wrap + Future _sendEncryptedFileMessage(EncryptedFileUploadResult result) async { try { // Create JSON content for the rumor - final imageMessageJson = jsonEncode(result.toJson()); + final fileMessageJson = jsonEncode(result.toJson()); // Send via existing chat system (will be wrapped in NIP-59) await ref .read(chatRoomsProvider(widget.orderId).notifier) - .sendMessage(imageMessageJson); + .sendMessage(fileMessageJson); } catch (e) { - throw Exception('Failed to send encrypted image message: $e'); + throw Exception('Failed to send encrypted file message: $e'); } } @@ -175,7 +179,7 @@ class _MessageInputState extends ConsumerState { ), ), child: IconButton( - icon: _isUploadingImage + icon: _isUploadingFile ? SizedBox( width: 20, height: 20, @@ -185,11 +189,11 @@ class _MessageInputState extends ConsumerState { ), ) : Icon( - Icons.add, + Icons.attach_file, color: AppTheme.cream1, size: 20, ), - onPressed: _isUploadingImage ? null : _selectAndUploadImage, + onPressed: _isUploadingFile ? null : _selectAndUploadFile, padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), diff --git a/lib/services/encrypted_file_upload_service.dart b/lib/services/encrypted_file_upload_service.dart new file mode 100644 index 00000000..233c7a95 --- /dev/null +++ b/lib/services/encrypted_file_upload_service.dart @@ -0,0 +1,222 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/services/file_validation_service.dart'; +import 'package:mostro_mobile/services/blossom_client.dart'; +import 'package:mostro_mobile/services/encryption_service.dart'; +import 'package:mostro_mobile/services/blossom_download_service.dart'; + +class EncryptedFileUploadResult { + final String blossomUrl; + final String nonce; // Hex encoded + final String mimeType; + final String fileType; + final int originalSize; + final String filename; + final int encryptedSize; + + EncryptedFileUploadResult({ + required this.blossomUrl, + required this.nonce, + required this.mimeType, + required this.fileType, + required this.originalSize, + required this.filename, + required this.encryptedSize, + }); + + /// Convert to JSON for NIP-59 rumor content + Map toJson() { + return { + 'type': 'file_encrypted', + 'file_type': fileType, + 'blossom_url': blossomUrl, + 'nonce': nonce, + 'mime_type': mimeType, + 'original_size': originalSize, + 'filename': filename, + 'encrypted_size': encryptedSize, + }; + } + + /// Create from JSON (for receiving messages) + factory EncryptedFileUploadResult.fromJson(Map json) { + return EncryptedFileUploadResult( + blossomUrl: json['blossom_url'], + nonce: json['nonce'], + mimeType: json['mime_type'], + fileType: json['file_type'], + originalSize: json['original_size'], + filename: json['filename'], + encryptedSize: json['encrypted_size'], + ); + } +} + +class EncryptedFileUploadService { + final Logger _logger = Logger(); + + // List of Blossom servers (with fallbacks) + static const List _blossomServers = [ + 'https://blossom.primal.net', + 'https://blossom.band', + 'https://nostr.media', + 'https://blossom.sector01.com', + 'https://24242.io', + 'https://otherstuff.shaving.kiwi', + 'https://blossom.f7z.io', + 'https://nosto.re', + 'https://blossom.poster.place', + ]; + + EncryptedFileUploadService(); + + /// Upload encrypted file with complete validation and encryption + Future uploadEncryptedFile({ + required File file, + required Uint8List sharedKey, + }) async { + _logger.i('πŸ”’ Starting encrypted file upload process...'); + + try { + // 1. Validate file (size, type, security) + final validationResult = await FileValidationService.validateFile(file); + + _logger.i( + 'File validated: ${validationResult.fileType} (${validationResult.mimeType}), ' + '${validationResult.filename}, ${_formatFileSize(validationResult.size)}' + ); + + // 2. Encrypt with ChaCha20-Poly1305 + final encryptionResult = EncryptionService.encryptChaCha20Poly1305( + key: sharedKey, + plaintext: validationResult.validatedData, + ); + + final encryptedBlob = encryptionResult.toBlob(); + _logger.i( + 'πŸ” File encrypted successfully: ${encryptedBlob.length} bytes ' + '(nonce: ${encryptionResult.nonce.length}B, ' + 'data: ${encryptionResult.encryptedData.length}B, ' + 'tag: ${encryptionResult.authTag.length}B)' + ); + + // 3. Upload encrypted blob to Blossom + final blossomUrl = await _uploadWithRetry( + encryptedBlob, + 'application/octet-stream', // Always octet-stream for encrypted data + ); + + final result = EncryptedFileUploadResult( + blossomUrl: blossomUrl, + nonce: _bytesToHex(encryptionResult.nonce), + mimeType: validationResult.mimeType, + fileType: validationResult.fileType, + originalSize: validationResult.size, + filename: validationResult.filename, + encryptedSize: encryptedBlob.length, + ); + + _logger.i('πŸŽ‰ Encrypted file upload completed successfully!'); + _logger.i('πŸ“Ž File: ${result.filename} (${result.fileType})'); + _logger.i('πŸ”— Blossom URL: ${result.blossomUrl}'); + + return result; + + } catch (e) { + _logger.e('❌ Encrypted file upload failed: $e'); + rethrow; + } + } + + /// Download and decrypt file from Blossom + Future downloadAndDecryptFile({ + required String blossomUrl, + required String nonceHex, + required Uint8List sharedKey, + }) async { + _logger.i('πŸ”“ Starting encrypted file download and decryption...'); + _logger.d('URL: $blossomUrl'); + _logger.d('Nonce: $nonceHex'); + + try { + // 1. Download encrypted blob from Blossom + final encryptedBlob = await _downloadFromBlossom(blossomUrl); + _logger.i('πŸ“₯ Downloaded encrypted blob: ${encryptedBlob.length} bytes'); + + // 2. Decrypt with ChaCha20-Poly1305 + final decryptedFile = EncryptionService.decryptFromBlob( + key: sharedKey, + blob: encryptedBlob, + ); + + _logger.i('πŸ”“ File decrypted successfully: ${decryptedFile.length} bytes'); + + return decryptedFile; + + } catch (e) { + _logger.e('❌ File download/decryption failed: $e'); + rethrow; + } + } + + /// Upload with automatic retry to multiple servers + Future _uploadWithRetry(Uint8List encryptedData, String mimeType) async { + final servers = _blossomServers; + + for (int i = 0; i < servers.length; i++) { + final serverUrl = servers[i]; + _logger.d('Attempting upload to server ${i + 1}/${servers.length}: $serverUrl'); + + try { + final client = BlossomClient(serverUrl: serverUrl); + final blossomUrl = await client.uploadImage( + imageData: encryptedData, + mimeType: mimeType, + ); + + _logger.i('βœ… Upload successful to: $serverUrl'); + return blossomUrl; + + } catch (e) { + _logger.w('❌ Upload failed to $serverUrl: $e'); + + // If it's the last server, re-throw the error + if (i == servers.length - 1) { + throw BlossomException('All Blossom servers failed. Last error: $e'); + } + + // Continue with next server + continue; + } + } + + throw BlossomException('No Blossom servers available'); + } + + /// Download from Blossom with retry + Future _downloadFromBlossom(String blossomUrl) async { + return await BlossomDownloadService.downloadWithRetry(blossomUrl); + } + + /// Convert bytes to hex string + String _bytesToHex(Uint8List bytes) { + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + + /// Format file size in human-readable format + String _formatFileSize(int bytes) { + if (bytes < 1024) return '${bytes}B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB'; + } +} + +class BlossomException implements Exception { + final String message; + BlossomException(this.message); + + @override + String toString() => 'BlossomException: $message'; +} \ No newline at end of file diff --git a/lib/services/file_validation_service.dart b/lib/services/file_validation_service.dart new file mode 100644 index 00000000..4f14c70a --- /dev/null +++ b/lib/services/file_validation_service.dart @@ -0,0 +1,184 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:mime/mime.dart'; +import 'package:logger/logger.dart'; + +class FileValidationResult { + final Uint8List validatedData; + final String mimeType; + final String fileType; + final String extension; + final int size; + final String filename; + + FileValidationResult({ + required this.validatedData, + required this.mimeType, + required this.fileType, + required this.extension, + required this.size, + required this.filename, + }); +} + +class FileValidationService { + static final Logger _logger = Logger(); + + // Maximum file size: 25MB + static const int maxFileSize = 25 * 1024 * 1024; + + // Supported file types for P2P proof of payment exchange + static const Map> supportedTypes = { + 'image': [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp' + ], + 'video': [ + 'video/mp4', + 'video/quicktime', + 'video/x-msvideo', // AVI + 'video/webm' + ], + 'document': [ + 'application/pdf', + 'application/msword', // DOC + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // DOCX + 'text/plain', + 'text/rtf' + ] + }; + + static const Map extensionToMime = { + // Images + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + + // Videos + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + '.avi': 'video/x-msvideo', + '.webm': 'video/webm', + + // Documents + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.txt': 'text/plain', + '.rtf': 'text/rtf' + }; + + /// Validate file for secure P2P exchange + static Future validateFile(File file) async { + final filename = file.path.split('/').last; + final fileData = await file.readAsBytes(); + + _logger.i('πŸ” Validating file: $filename (${fileData.length} bytes)'); + + // 1. Check file size + if (fileData.length > maxFileSize) { + throw FileValidationException( + 'File too large: ${_formatFileSize(fileData.length)}. ' + 'Maximum allowed: ${_formatFileSize(maxFileSize)}' + ); + } + + // 2. Detect MIME type + String? mimeType = lookupMimeType(file.path, headerBytes: fileData); + + // 3. Fallback to extension-based detection + if (mimeType == null) { + final extension = _getFileExtension(filename).toLowerCase(); + mimeType = extensionToMime[extension]; + } + + if (mimeType == null) { + throw FileValidationException('Unsupported file type: $filename'); + } + + // 4. Verify MIME type is supported + final fileType = _getFileType(mimeType); + if (fileType == null) { + throw FileValidationException('Unsupported file type: $mimeType'); + } + + _logger.i('βœ… File validation successful: $fileType ($mimeType)'); + + return FileValidationResult( + validatedData: fileData, + mimeType: mimeType, + fileType: fileType, + extension: _getFileExtension(filename), + size: fileData.length, + filename: filename, + ); + } + + /// Get file type category from MIME type + static String? _getFileType(String mimeType) { + for (final entry in supportedTypes.entries) { + if (entry.value.contains(mimeType)) { + return entry.key; + } + } + return null; + } + + /// Get file extension from filename + static String _getFileExtension(String filename) { + final lastDot = filename.lastIndexOf('.'); + if (lastDot == -1) return ''; + return filename.substring(lastDot); + } + + /// Format file size in human-readable format + static String _formatFileSize(int bytes) { + if (bytes < 1024) return '${bytes}B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB'; + } + + /// Check if file type is supported + static bool isFileTypeSupported(String filename, {String? mimeType}) { + // Check by MIME type first + if (mimeType != null) { + return _getFileType(mimeType) != null; + } + + // Check by extension + final extension = _getFileExtension(filename).toLowerCase(); + return extensionToMime.containsKey(extension); + } + + /// Get supported file extensions for file picker + static List getSupportedExtensions() { + return extensionToMime.keys.toList(); + } + + /// Get file type icon + static String getFileTypeIcon(String fileType) { + switch (fileType) { + case 'image': + return 'πŸ–ΌοΈ'; + case 'video': + return 'πŸŽ₯'; + case 'document': + return 'πŸ“„'; + default: + return 'πŸ“Ž'; + } + } +} + +class FileValidationException implements Exception { + final String message; + FileValidationException(this.message); + + @override + String toString() => 'FileValidationException: $message'; +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index c24e294e..4113eb50 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -417,6 +417,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" file_selector_linux: dependency: transitive description: @@ -1085,6 +1093,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + open_file: + dependency: "direct main" + description: + name: open_file + sha256: d17e2bddf5b278cb2ae18393d0496aa4f162142ba97d1a9e0c30d476adf99c0e + url: "https://pub.dev" + source: hosted + version: "3.5.10" + open_file_android: + dependency: transitive + description: + name: open_file_android + sha256: "58141fcaece2f453a9684509a7275f231ac0e3d6ceb9a5e6de310a7dff9084aa" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + open_file_ios: + dependency: transitive + description: + name: open_file_ios + sha256: "02996f01e5f6863832068e97f8f3a5ef9b613516db6897f373b43b79849e4d07" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + open_file_linux: + dependency: transitive + description: + name: open_file_linux + sha256: d189f799eecbb139c97f8bc7d303f9e720954fa4e0fa1b0b7294767e5f2d7550 + url: "https://pub.dev" + source: hosted + version: "0.0.5" + open_file_mac: + dependency: transitive + description: + name: open_file_mac + sha256: "1440b1e37ceb0642208cfeb2c659c6cda27b25187a90635c9d1acb7d0584d324" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + open_file_platform_interface: + dependency: transitive + description: + name: open_file_platform_interface + sha256: "101b424ca359632699a7e1213e83d025722ab668b9fd1412338221bf9b0e5757" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + open_file_web: + dependency: transitive + description: + name: open_file_web + sha256: e3dbc9584856283dcb30aef5720558b90f88036360bd078e494ab80a80130c4f + url: "https://pub.dev" + source: hosted + version: "0.0.4" + open_file_windows: + dependency: transitive + description: + name: open_file_windows + sha256: d26c31ddf935a94a1a3aa43a23f4fff8a5ff4eea395fe7a8cb819cf55431c875 + url: "https://pub.dev" + source: hosted + version: "0.0.3" package_config: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 61e3c5c7..f11964a5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -91,6 +91,10 @@ dependencies: # ChaCha20-Poly1305 encryption pointycastle: ^3.9.1 + + # File picker for documents, videos, etc. + file_picker: ^8.0.0+1 + open_file: ^3.3.2 dev_dependencies: From 5b41d14b924e08ea71e32b87ec8250f87d5a700f Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:01:28 -0600 Subject: [PATCH 04/30] add documentation and improve send files UI --- ...NCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md | 806 ++++++++++++++++++ .../chat/widgets/encrypted_file_message.dart | 18 +- 2 files changed, 815 insertions(+), 9 deletions(-) create mode 100644 docs/architecture/ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md diff --git a/docs/architecture/ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md b/docs/architecture/ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md new file mode 100644 index 00000000..c8deffee --- /dev/null +++ b/docs/architecture/ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md @@ -0,0 +1,806 @@ +# Encrypted File Messaging Implementation + +## Overview + +This document details the complete implementation of encrypted file messaging for Mostro Mobile's P2P chat system. The feature enables users to send and receive multiple file types securely using ChaCha20-Poly1305 encryption, Blossom servers for decentralized storage, and NIP-59 gift wrap messaging. + +### Supported File Types + +- **Images**: JPG, PNG, GIF, WEBP (with auto-preview) +- **Documents**: PDF, DOC, TXT, RTF +- **Videos**: MP4, MOV, AVI, WEBM +- **Size limit**: 25MB per file + +## Table of Contents + +- [Architecture Overview](#architecture-overview) +- [Core Components](#core-components) +- [Encryption System](#encryption-system) +- [Blossom Server Integration](#blossom-server-integration) +- [Message Flow](#message-flow) +- [Implementation Details](#implementation-details) +- [UI Integration](#ui-integration) +- [Security Considerations](#security-considerations) +- [Error Handling](#error-handling) +- [Performance & Caching](#performance--caching) +- [Testing](#testing) +- [Future Improvements](#future-improvements) + +## Architecture Overview + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ User A β”‚ β”‚ Blossom Servers β”‚ β”‚ User B β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ 1. Select File │───▢│ 2. Store │◀───│ 4. Download β”‚ +β”‚ 2. Encrypt β”‚ β”‚ Encrypted β”‚ β”‚ 5. Decrypt β”‚ +β”‚ 3. Upload β”‚ β”‚ Blob β”‚ β”‚ 6. Display β”‚ +β”‚ 4. Send NIP-59 β”‚ β”‚ β”‚ β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β–² + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + └─────────────▢│ Nostr Relays β”‚β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ (NIP-59 msgs) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Core Components + +### 1. Encryption Service (`lib/services/encryption_service.dart`) + +**Purpose**: Handles ChaCha20-Poly1305 AEAD encryption/decryption + +**Key Features**: +- Secure nonce generation (12 bytes) +- Authenticated encryption with additional data (AEAD) +- Blob structure: `[nonce:12][encrypted_data][auth_tag:16]` +- Mobile-optimized ChaCha20-Poly1305 implementation + +**Core Methods**: +```dart +static EncryptionResult encryptChaCha20Poly1305({ + required Uint8List key, // 32-byte shared key + required Uint8List plaintext, // Original image data + Uint8List? nonce, // Optional nonce (auto-generated if null) + Uint8List? additionalData, // Optional authenticated data +}) + +static Uint8List decryptChaCha20Poly1305({ + required Uint8List key, + required Uint8List nonce, + required Uint8List encryptedData, + required Uint8List authTag, + Uint8List? additionalData, +}) +``` + +### 2. Blossom Download Service (`lib/services/blossom_download_service.dart`) + +**Purpose**: Downloads encrypted blobs from Blossom servers + +**Key Features**: +- HTTP GET requests to Blossom URLs +- Retry logic with exponential backoff +- Progress tracking for large files +- Timeout handling + +### 3. File Upload Services + +**Encrypted Image Upload Service** (`lib/services/encrypted_image_upload_service.dart`) +- Image-specific upload with validation using `MediaValidationService` +- ChaCha20-Poly1305 encryption +- Multi-server upload with fallback + +**Encrypted File Upload Service** (`lib/services/encrypted_file_upload_service.dart`) +- General file upload for documents, videos, etc. +- File type validation and categorization +- Same encryption and upload workflow as images + +**Common Workflow**: +1. Read and validate file +2. Encrypt with ChaCha20-Poly1305 +3. Upload to Blossom server (with retry) +4. Generate metadata for NIP-59 message + +### 4. UI Components + +#### EncryptedImageMessage Widget (`lib/features/chat/widgets/encrypted_image_message.dart`) +- Displays encrypted images with loading states +- Handles image caching and error states +- Auto-preview for images + +#### EncryptedFileMessage Widget (`lib/features/chat/widgets/encrypted_file_message.dart`) +- Displays file messages for documents, videos +- Download-on-demand functionality +- File icon display with metadata +- "Open File" integration with system apps + +#### Enhanced MessageInput (`lib/features/chat/widgets/message_input.dart`) +- File selection via native `FilePicker` +- Supports multiple file types +- Upload progress indication +- Integration with chat sending system + +#### Enhanced MessageBubble (`lib/features/chat/widgets/message_bubble.dart`) +- Auto-detects encrypted file messages +- Renders appropriate UI component (image vs file) +- Maintains consistent chat bubble styling + +## Encryption System + +### ChaCha20-Poly1305 AEAD + +**Why ChaCha20-Poly1305?** +- Better performance on mobile ARM processors vs AES-GCM +- Authenticated encryption prevents tampering +- Nonce-misuse resistant design +- Industry standard (RFC 8439) + +**Key Management**: +- Uses existing chat session `shared_key` (32 bytes) +- Same key used for text messages and images +- Key derived from ECDH key exchange in Mostro protocol + +**Blob Structure**: +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Nonce (12B) β”‚ Encrypted Data β”‚ Auth Tag (16B) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Security Properties**: +- **Confidentiality**: File content hidden from servers and network +- **Integrity**: Tampering detected via authentication tag +- **Authenticity**: Only holders of shared key can decrypt +- **Forward Secrecy**: Compromised key doesn't affect past messages + +## Blossom Server Integration + +### Server List + +The app uses multiple public Blossom servers for redundancy: + +```dart +static const List _blossomServers = [ + 'https://blossom.primal.net', + 'https://blossom.band', + 'https://nostr.media', + 'https://blossom.sector01.com', + 'https://24242.io', + 'https://otherstuff.shaving.kiwi', + 'https://blossom.f7z.io', + 'https://nosto.re', + 'https://blossom.poster.place', +]; +``` + +### Upload Protocol + +**HTTP Request**: +```http +PUT https://blossom.primal.net/upload +Content-Type: application/octet-stream +Content-Length: [encrypted_blob_size] + +[encrypted_blob_bytes] +``` + +**Response**: +```http +HTTP/1.1 200 OK +Content-Type: text/plain + +blossom://blossom.primal.net/[content_hash] +``` + +### Download Protocol + +**HTTP Request**: +```http +GET https://blossom.primal.net/[content_hash] +Accept: application/octet-stream +``` + +**Response**: +```http +HTTP/1.1 200 OK +Content-Type: application/octet-stream +Content-Length: [encrypted_blob_size] + +[encrypted_blob_bytes] +``` + +### Fallback Strategy + +```dart +for (int i = 0; i < servers.length; i++) { + try { + final result = await uploadToServer(servers[i]); + return result; // βœ… Success + } catch (e) { + if (i == servers.length - 1) { + throw BlossomException('All servers failed'); + } + // Continue to next server + } +} +``` + +## Message Flow + +### Sending Flow + +```mermaid +sequenceDiagram + participant U as User + participant MI as MessageInput + participant EIU as EncryptedImageUpload + participant ES as EncryptionService + participant BC as BlossomClient + participant BS as Blossom Server + participant CRN as ChatRoomNotifier + participant NR as Nostr Relays + + U->>MI: Select image + MI->>MI: Show loading indicator + MI->>EIU: uploadEncryptedImage() + EIU->>EIU: Sanitize image + EIU->>ES: Encrypt with shared_key + ES-->>EIU: Encrypted blob + EIU->>BC: Upload blob + BC->>BS: HTTP PUT /upload + BS-->>BC: blossom:// URL + BC-->>EIU: Upload result + EIU-->>MI: Metadata object + MI->>CRN: Send JSON message + CRN->>NR: Publish NIP-59 event + MI->>MI: Hide loading indicator +``` + +### Receiving Flow + +```mermaid +sequenceDiagram + participant NR as Nostr Relays + participant CRN as ChatRoomNotifier + participant EIM as EncryptedImageMsg + participant EIU as EncryptedImageUpload + participant BDS as BlossomDownload + participant BS as Blossom Server + participant ES as EncryptionService + + NR->>CRN: NIP-59 event + CRN->>CRN: Decrypt & parse + CRN->>CRN: Detect image message + CRN->>EIM: Trigger load + EIM->>EIU: downloadAndDecrypt() + EIU->>BDS: Download blob + BDS->>BS: HTTP GET + BS-->>BDS: Encrypted blob + BDS-->>EIU: Blob data + EIU->>ES: Decrypt with shared_key + ES-->>EIU: Original image + EIU-->>EIM: Image data + EIM->>CRN: Cache image + EIM->>EIM: Display image +``` + +## Implementation Details + +### 1. Image Selection and Upload + +```dart +// In MessageInput widget +Future _selectAndUploadImage() async { + setState(() => _isUploadingImage = true); + + final pickedFile = await _imagePicker.pickImage( + source: ImageSource.gallery, + imageQuality: 85, // Compress for faster upload + ); + + if (pickedFile != null) { + final sharedKey = await _getSharedKeyForOrder(widget.orderId); + + final result = await _imageUploadService.uploadEncryptedImage( + imageFile: File(pickedFile.path), + sharedKey: sharedKey, + filename: pickedFile.name, + ); + + await _sendEncryptedImageMessage(result); + } + + setState(() => _isUploadingImage = false); +} +``` + +### 2. NIP-59 Message Formats + +**Image Message**: +```json +{ + "type": "image_encrypted", + "blossom_url": "blossom://blossom.primal.net/a1b2c3d4...", + "nonce": "abcdef1234567890abcdef12", + "mime_type": "image/jpeg", + "original_size": 524288, + "width": 1920, + "height": 1080, + "filename": "image_1234567890.jpg", + "encrypted_size": 524320 +} +``` + +**File Message**: +```json +{ + "type": "file_encrypted", + "blossom_url": "blossom://blossom.primal.net/b2c3d4e5...", + "nonce": "bcdef1234567890abcdef123", + "mime_type": "application/pdf", + "original_size": 1048576, + "filename": "receipt_20240101.pdf", + "encrypted_size": 1048608, + "file_type": "document" +} +``` + +### 3. Message Detection and Processing + +```dart +// In ChatRoomNotifier +Future _processMessageContent(NostrEvent message) async { + final content = message.content; + if (content == null || !content.startsWith('{')) return; + + Map? jsonContent; + try { + final decoded = jsonDecode(content); + if (decoded is Map) { + jsonContent = decoded; + } + } catch (e) { + return; // Not JSON, treat as text + } + + if (jsonContent != null) { + if (jsonContent['type'] == 'image_encrypted') { + await _processEncryptedImageMessage(message, jsonContent); + } else if (jsonContent['type'] == 'file_encrypted') { + await _processEncryptedFileMessage(message, jsonContent); + } + } +} +``` + +### 4. Pre-downloading and Caching + +```dart +// In ChatRoomNotifier +Future _processEncryptedImageMessage( + NostrEvent message, + Map imageData +) async { + final result = EncryptedImageUploadResult.fromJson(imageData); + final sharedKey = await getSharedKey(); + + final uploadService = EncryptedImageUploadService(); + final decryptedImage = await uploadService.downloadAndDecryptImage( + blossomUrl: result.blossomUrl, + nonceHex: result.nonce, + sharedKey: sharedKey, + ); + + // Cache for immediate display + cacheDecryptedImage(message.id!, decryptedImage, result); +} +``` + +## UI Integration + +### Message Bubble Enhancement + +The `MessageBubble` widget was enhanced to detect and render encrypted images: + +```dart +// Check if this is an encrypted image message +if (isEncryptedImageMessage(message)) { + return Container( + // Styled container with bubble appearance + child: EncryptedImageMessage( + message: message, + orderId: orderId, + isOwnMessage: !isFromPeer, + ), + ); +} +``` + +### Responsive Image Layout + +The `EncryptedImageMessage` widget uses `LayoutBuilder` for responsive sizing: + +```dart +Widget _buildImageWidget(Uint8List imageData, EncryptedImageUploadResult metadata) { + return LayoutBuilder( + builder: (context, constraints) { + final availableHeight = constraints.maxHeight - infoRowHeight - spacing; + + return Container( + child: Column( + children: [ + Flexible( + child: Image.memory( + imageData, + fit: BoxFit.contain, // Prevents overflow + ), + ), + // File info row + ], + ), + ); + }, + ); +} +``` + +### Loading and Error States + +```dart +// Loading state +Widget _buildLoadingWidget() { + return Container( + child: Column( + children: [ + CircularProgressIndicator(color: AppTheme.mostroGreen), + Text('Decrypting image...'), + ], + ), + ); +} + +// Error state +Widget _buildErrorWidget() { + return Container( + child: Column( + children: [ + Icon(Icons.error_outline, color: Colors.red), + Text('Failed to load image'), + if (_errorMessage != null) Text(_errorMessage!), + ], + ), + ); +} +``` + +## Security Considerations + +### Threat Model + +**Protected Against**: +- **Server Operators**: Cannot see image content (server-blind storage) +- **Network Eavesdroppers**: Cannot decrypt messages or images +- **Relay Operators**: Only see encrypted NIP-59 events +- **Man-in-the-Middle**: Authenticated encryption prevents tampering + +**Not Protected Against**: +- **Endpoint Compromise**: If device is compromised, images are visible +- **Shared Key Compromise**: All images in that chat session affected +- **Side-Channel Attacks**: Metadata leakage (file sizes, timing) + +### Key Security Features + +1. **End-to-End Encryption**: Only chat participants can decrypt +2. **Forward Secrecy**: Each chat session has unique keys +3. **Server-Blind Storage**: Blossom servers only store encrypted blobs +4. **Authenticated Encryption**: Prevents tampering and forgery +5. **Image Sanitization**: Removes metadata and malicious content + +### Security Best Practices Implemented + +- βœ… Secure random nonce generation +- βœ… Proper key derivation from ECDH +- βœ… No key reuse across different chats +- βœ… Image validation and sanitization +- βœ… Timeout handling prevents hanging operations +- βœ… Error handling doesn't leak sensitive information + +## Error Handling + +### Network Errors + +```dart +try { + final result = await uploadToServer(server); + return result; +} catch (e) { + if (e is SocketException) { + _logger.w('Network error uploading to $server: $e'); + } else if (e is TimeoutException) { + _logger.w('Timeout uploading to $server: $e'); + } else { + _logger.w('Unknown error uploading to $server: $e'); + } + // Continue with next server +} +``` + +### Encryption Errors + +```dart +try { + return EncryptionService.decryptChaCha20Poly1305(...); +} catch (e) { + _logger.e('Decryption failed: $e'); + throw EncryptionException('Failed to decrypt image'); +} +``` + +### UI Error Feedback + +```dart +// In MessageInput +catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error uploading image: $e'), + backgroundColor: Colors.red, + ), + ); + } +} +``` + +## Performance & Caching + +### Current Cache Implementation (Temporary) + +Files and images are currently cached in memory only during the app session: + +```dart +// In ChatRoomNotifier +final Map _imageCache = {}; +final Map _imageMetadata = {}; +final Map _fileCache = {}; +final Map _fileMetadata = {}; +``` + +### Current Cache Limitations + +- **⚠️ Temporary Storage**: Cache is memory-only and cleared when app closes +- **No Persistence**: Files must be re-downloaded after app restart +- **Session-based**: Cache tied to current app session only +- **Size**: No explicit size limits (relies on system memory management) + +### TODO: Persistent Cache Implementation + +A session-linked persistent cache system has been developed but not yet integrated: + +- **SessionFileCache**: Stores files per trading session (72h lifetime) +- **Automatic Cleanup**: Files deleted when sessions expire +- **Organized Storage**: Files grouped by type (images/documents/videos) +- **Metadata Preservation**: JSON metadata stored alongside file data + +This will be implemented in a future update to provide better user experience. + +### Performance Optimizations + +1. **Image Compression**: 85% quality during selection +2. **Lazy Loading**: Images loaded only when message becomes visible +3. **Pre-downloading**: Automatic download when message arrives +4. **Efficient Layouts**: `LayoutBuilder` prevents unnecessary recomputes +5. **Error Recovery**: Graceful degradation on failures + +## Testing + +### Unit Tests + +```dart +// Example test structure +group('EncryptionService', () { + test('should encrypt and decrypt data correctly', () { + final key = EncryptionService.generateSecureRandom(32); + final plaintext = Uint8List.fromList([1, 2, 3, 4, 5]); + + final encrypted = EncryptionService.encryptChaCha20Poly1305( + key: key, + plaintext: plaintext, + ); + + final decrypted = EncryptionService.decryptChaCha20Poly1305( + key: key, + nonce: encrypted.nonce, + encryptedData: encrypted.encryptedData, + authTag: encrypted.authTag, + ); + + expect(decrypted, equals(plaintext)); + }); +}); +``` + +### Integration Tests + +```dart +// Test complete upload/download flow +testWidgets('should upload and display encrypted image', (tester) async { + // Setup mock services + // Trigger image upload + // Verify encrypted blob uploaded to Blossom + // Simulate receiving NIP-59 message + // Verify image displays correctly +}); +``` + +### Manual Testing Checklist + +- [ ] Image selection from gallery +- [ ] Upload progress indication +- [ ] Multiple server fallback +- [ ] Message display in chat +- [ ] Image loading states +- [ ] Error handling +- [ ] Network interruption recovery +- [ ] App restart persistence (should re-download) + +## Future Improvements + +### 1. Persistent Caching + +**Current Limitation**: Images re-download on app restart + +**Proposed Solution**: +```dart +class PersistentImageCache { + static const String _cacheDir = 'encrypted_images'; + + Future cacheImage(String messageId, Uint8List data) async { + final dir = await getApplicationCacheDirectory(); + final file = File('${dir.path}/$_cacheDir/$messageId.enc'); + await file.writeAsBytes(data); + } + + Future getCachedImage(String messageId) async { + final dir = await getApplicationCacheDirectory(); + final file = File('${dir.path}/$_cacheDir/$messageId.enc'); + if (await file.exists()) { + return await file.readAsBytes(); + } + return null; + } +} +``` + +### 2. Nostr Authentication for Blossom + +**Current**: Anonymous uploads to public servers + +**Proposed**: NIP-98 HTTP Auth for Blossom +```dart +class AuthenticatedBlossomClient { + final NostrKeyPairs keyPair; + + Future uploadWithAuth(Uint8List data) async { + final authEvent = await createNIP98AuthEvent( + url: '$serverUrl/upload', + method: 'PUT', + body: data, + ); + + final headers = { + 'Authorization': 'Nostr ${base64Encode(utf8.encode(jsonEncode(authEvent)))}', + 'Content-Type': 'application/octet-stream', + }; + + // ... rest of upload logic + } +} +``` + +### 3. Image Compression Options + +```dart +enum CompressionLevel { low, medium, high, lossless } + +class ImageCompressionService { + static Future compress( + Uint8List imageData, + CompressionLevel level + ) async { + switch (level) { + case CompressionLevel.low: + return await FlutterImageCompress.compressWithList( + imageData, quality: 95 + ); + // ... other levels + } + } +} +``` + +### 4. Multiple Image Selection + +```dart +Future _selectMultipleImages() async { + final pickedFiles = await _imagePicker.pickMultiImage(); + + for (final file in pickedFiles) { + await _uploadSingleImage(file); + } +} +``` + +### 5. Video Support + +```dart +class EncryptedVideoUploadService { + Future uploadEncryptedVideo({ + required File videoFile, + required Uint8List sharedKey, + }) async { + // Similar to image upload but for video files + } +} +``` + +### 6. Download Progress Indication + +```dart +class ProgressTrackingDownloader { + Stream downloadWithProgress(String url) async* { + final request = http.Request('GET', Uri.parse(url)); + final response = await request.send(); + + final contentLength = response.contentLength ?? 0; + int downloadedBytes = 0; + + await for (final chunk in response.stream) { + downloadedBytes += chunk.length; + yield DownloadProgress(downloadedBytes, contentLength); + } + } +} +``` + +### 7. Image Gallery View + +```dart +class ImageGalleryScreen extends StatelessWidget { + final List images; + + @override + Widget build(BuildContext context) { + return PageView.builder( + itemCount: images.length, + itemBuilder: (context, index) { + return InteractiveViewer( + child: images[index], + ); + }, + ); + } +} +``` + +--- + +## Conclusion + +The encrypted file messaging system provides a robust, secure, and user-friendly way to share multiple file types in Mostro Mobile's P2P chat. The implementation balances security, performance, and usability while maintaining compatibility with the existing Mostro protocol and Nostr ecosystem. + +Key achievements: +- βœ… End-to-end encryption using ChaCha20-Poly1305 +- βœ… Support for images, documents (PDF/DOC/TXT), and videos (MP4/MOV) +- βœ… Decentralized storage via Blossom protocol +- βœ… Auto-preview for images, download-on-demand for files +- βœ… Native file picker with system app integration +- βœ… Seamless integration with existing chat UI +- βœ… Automatic fallback across multiple servers +- βœ… Responsive design with proper error handling +- βœ… Zero analyzer errors and passing test suite + +**Current Status**: The system is production-ready with temporary cache storage. Files are re-downloaded after app restart but remain secure and functional. + +**Next Steps**: Integration of persistent cache system (already implemented) for improved user experience with file persistence across app sessions. \ No newline at end of file diff --git a/lib/features/chat/widgets/encrypted_file_message.dart b/lib/features/chat/widgets/encrypted_file_message.dart index b754111e..86dba76a 100644 --- a/lib/features/chat/widgets/encrypted_file_message.dart +++ b/lib/features/chat/widgets/encrypted_file_message.dart @@ -194,7 +194,7 @@ class _EncryptedFileMessageState extends ConsumerState { child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.grey.withValues(alpha: 25), + color: AppTheme.backgroundCard, borderRadius: BorderRadius.circular(12), border: Border.all( color: AppTheme.textSecondary.withValues(alpha: 76), @@ -219,10 +219,10 @@ class _EncryptedFileMessageState extends ConsumerState { children: [ Text( metadata.filename, - style: TextStyle( + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: AppTheme.cream1, + color: Colors.white, ), overflow: TextOverflow.ellipsis, maxLines: 2, @@ -234,20 +234,20 @@ class _EncryptedFileMessageState extends ConsumerState { _formatFileSize(metadata.originalSize), style: TextStyle( fontSize: 12, - color: AppTheme.textSecondary.withValues(alpha: 153), + color: Colors.white.withValues(alpha: 178), ), ), const SizedBox(width: 8), Icon( Icons.lock, size: 12, - color: AppTheme.textSecondary.withValues(alpha: 153), + color: Colors.white.withValues(alpha: 178), ), Text( ' Encrypted', style: TextStyle( fontSize: 11, - color: AppTheme.textSecondary.withValues(alpha: 153), + color: Colors.white.withValues(alpha: 178), ), ), ], @@ -273,7 +273,7 @@ class _EncryptedFileMessageState extends ConsumerState { ), style: ElevatedButton.styleFrom( backgroundColor: AppTheme.mostroGreen, - foregroundColor: Colors.white, + foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(vertical: 8), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), @@ -297,7 +297,7 @@ class _EncryptedFileMessageState extends ConsumerState { ), padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: AppTheme.backgroundInput, + color: AppTheme.backgroundCard, borderRadius: BorderRadius.circular(12), border: Border.all( color: AppTheme.textSecondary.withValues(alpha: 76), @@ -321,7 +321,7 @@ class _EncryptedFileMessageState extends ConsumerState { 'Downloading file...', style: TextStyle( fontSize: 12, - color: AppTheme.textSecondary.withValues(alpha: 153), + color: Colors.white.withValues(alpha: 178), ), textAlign: TextAlign.center, ), From cf7950a470e92a210ad7e12fdba7a115af3c0df5 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:18:35 -0600 Subject: [PATCH 05/30] use localized strings for file message actions --- lib/features/chat/widgets/encrypted_file_message.dart | 7 ++++--- lib/l10n/intl_en.arb | 8 +++++++- lib/l10n/intl_es.arb | 8 +++++++- lib/l10n/intl_it.arb | 8 +++++++- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/features/chat/widgets/encrypted_file_message.dart b/lib/features/chat/widgets/encrypted_file_message.dart index 86dba76a..b7864bd2 100644 --- a/lib/features/chat/widgets/encrypted_file_message.dart +++ b/lib/features/chat/widgets/encrypted_file_message.dart @@ -10,6 +10,7 @@ import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/services/encrypted_file_upload_service.dart'; import 'package:mostro_mobile/services/file_validation_service.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; class EncryptedFileMessage extends ConsumerStatefulWidget { final NostrEvent message; @@ -244,7 +245,7 @@ class _EncryptedFileMessageState extends ConsumerState { color: Colors.white.withValues(alpha: 178), ), Text( - ' Encrypted', + ' ${S.of(context)!.encrypted}', style: TextStyle( fontSize: 11, color: Colors.white.withValues(alpha: 178), @@ -268,7 +269,7 @@ class _EncryptedFileMessageState extends ConsumerState { size: 16, ), label: Text( - isDownloaded ? 'Open File' : 'Download', + isDownloaded ? S.of(context)!.openFile : S.of(context)!.download, style: const TextStyle(fontSize: 13), ), style: ElevatedButton.styleFrom( @@ -318,7 +319,7 @@ class _EncryptedFileMessageState extends ConsumerState { ), const SizedBox(height: 8), Text( - 'Downloading file...', + S.of(context)!.downloadingFile, style: TextStyle( fontSize: 12, color: Colors.white.withValues(alpha: 178), diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index bb9a9cff..0423fc30 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1191,5 +1191,11 @@ "restoreFinalizing": "Finalizing restore...", "restoreCompleted": "Restore completed!", "restoreError": "Restore error", - "restoreErrorMessage": "Error restoring user data. Please check your connection and try again." + "restoreErrorMessage": "Error restoring user data. Please check your connection and try again.", + + "@_comment_file_messaging": "File messaging strings", + "download": "Download", + "openFile": "Open File", + "encrypted": "Encrypted", + "downloadingFile": "Downloading file..." } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index c4ae6db3..f1351088 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1169,6 +1169,12 @@ "restoreFinalizing": "Finalizando restauraciΓ³n...", "restoreCompleted": "Β‘RestauraciΓ³n completada!", "restoreError": "Error de restauraciΓ³n", - "restoreErrorMessage": "Error al restaurar datos del usuario. Verifica tu conexiΓ³n e intΓ©ntalo mΓ‘s tarde." + "restoreErrorMessage": "Error al restaurar datos del usuario. Verifica tu conexiΓ³n e intΓ©ntalo mΓ‘s tarde.", + + "@_comment_file_messaging": "File messaging strings", + "download": "Descargar", + "openFile": "Abrir Archivo", + "encrypted": "Encriptado", + "downloadingFile": "Descargando archivo..." } \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 9b4a9053..41784d44 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1224,5 +1224,11 @@ "restoreFinalizing": "Finalizzazione ripristino...", "restoreCompleted": "Ripristino completato!", "restoreError": "Errore di ripristino", - "restoreErrorMessage": "Errore nel ripristino dei dati utente. Verifica la tua connessione e riprova piΓΉ tardi." + "restoreErrorMessage": "Errore nel ripristino dei dati utente. Verifica la tua connessione e riprova piΓΉ tardi.", + + "@_comment_file_messaging": "File messaging strings", + "download": "Scarica", + "openFile": "Apri File", + "encrypted": "Crittografato", + "downloadingFile": "Scaricamento file..." } \ No newline at end of file From b27ea760b05d3d2419edf1d0c27b143dc0ba2658 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:27:51 -0600 Subject: [PATCH 06/30] Add validation for hex key length before parsing --- .../chat/notifiers/chat_room_notifier.dart | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/features/chat/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart index 2a16257c..2a29c420 100644 --- a/lib/features/chat/notifiers/chat_room_notifier.dart +++ b/lib/features/chat/notifiers/chat_room_notifier.dart @@ -388,14 +388,24 @@ class ChatRoomNotifier extends StateNotifier { throw Exception('Session or shared key not available for orderId: $orderId'); } + final hexKey = session.sharedKey!.private; // This should be hex string + + // Validate hex key length + if (hexKey.length != 64) { + throw Exception('Invalid shared key length for orderId $orderId: expected 64 hex chars, got ${hexKey.length}'); + } + // Convert from NostrKeyPairs to Uint8List (32 bytes) final sharedKeyBytes = Uint8List(32); - final hexKey = session.sharedKey!.private; // This should be hex string - // Convert hex string to bytes - for (int i = 0; i < 32; i++) { - final byte = int.parse(hexKey.substring(i * 2, i * 2 + 2), radix: 16); - sharedKeyBytes[i] = byte; + try { + // Convert hex string to bytes + for (int i = 0; i < 32; i++) { + final byte = int.parse(hexKey.substring(i * 2, i * 2 + 2), radix: 16); + sharedKeyBytes[i] = byte; + } + } catch (e) { + throw Exception('Malformed shared key for orderId $orderId: invalid hex format - $e'); } return sharedKeyBytes; From ae974d637f9295ab9c4b3e975a8721780ddd00c9 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:40:07 -0600 Subject: [PATCH 07/30] Sanitize filename to prevent path traversal --- .../chat/widgets/encrypted_file_message.dart | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/lib/features/chat/widgets/encrypted_file_message.dart b/lib/features/chat/widgets/encrypted_file_message.dart index b7864bd2..b8d418fd 100644 --- a/lib/features/chat/widgets/encrypted_file_message.dart +++ b/lib/features/chat/widgets/encrypted_file_message.dart @@ -459,9 +459,18 @@ class _EncryptedFileMessageState extends ConsumerState { throw Exception('File not available'); } - // Save file to temporary directory + // Save file to temporary directory with sanitized filename final tempDir = await getTemporaryDirectory(); - final tempFile = File('${tempDir.path}/${metadata.filename}'); + final sanitizedFilename = _sanitizeFilename(metadata.filename); + final tempFile = File('${tempDir.path}/$sanitizedFilename'); + + // Verify the resolved path is still within the temp directory to prevent path traversal + final tempDirPath = tempDir.resolveSymbolicLinksSync(); + final tempFilePath = tempFile.absolute.path; + if (!tempFilePath.startsWith('$tempDirPath${Platform.pathSeparator}')) { + throw Exception('Security error: Path traversal attempt detected in filename'); + } + await tempFile.writeAsBytes(cachedFile); // Open file with system default app @@ -507,6 +516,28 @@ class _EncryptedFileMessageState extends ConsumerState { if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB'; } + + /// Sanitize filename to prevent path traversal and other security issues + String _sanitizeFilename(String filename) { + // 1. Get basename only (remove any directory components) + final basename = filename.split(RegExp(r'[/\\]')).last; + + // 2. Remove dangerous characters including null bytes, control chars, and reserved chars + final cleaned = basename.replaceAll(RegExp(r'[<>:"|?*\x00-\x1F]'), '_'); + + // 3. Limit length to reasonable size + final limited = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned; + + // 4. Ensure not empty and not Windows reserved names + if (limited.isEmpty || + ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', + 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', + 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'].contains(limited.toUpperCase())) { + return 'file_${DateTime.now().millisecondsSinceEpoch}'; + } + + return limited; + } } /// Helper function to check if a message is an encrypted file From f833ea4413ca9be8d6acf5106f5e5e1008e586db Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Wed, 26 Nov 2025 20:15:18 -0600 Subject: [PATCH 08/30] remove unuced nonceHex parameter --- lib/features/chat/notifiers/chat_room_notifier.dart | 1 - lib/features/chat/widgets/encrypted_file_message.dart | 1 - lib/services/encrypted_file_upload_service.dart | 3 +-- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/features/chat/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart index 2a29c420..3cb8ed6e 100644 --- a/lib/features/chat/notifiers/chat_room_notifier.dart +++ b/lib/features/chat/notifiers/chat_room_notifier.dart @@ -539,7 +539,6 @@ class ChatRoomNotifier extends StateNotifier { final uploadService = EncryptedFileUploadService(); final decryptedFile = await uploadService.downloadAndDecryptFile( blossomUrl: result.blossomUrl, - nonceHex: result.nonce, sharedKey: sharedKey, ); diff --git a/lib/features/chat/widgets/encrypted_file_message.dart b/lib/features/chat/widgets/encrypted_file_message.dart index b8d418fd..8eef5305 100644 --- a/lib/features/chat/widgets/encrypted_file_message.dart +++ b/lib/features/chat/widgets/encrypted_file_message.dart @@ -427,7 +427,6 @@ class _EncryptedFileMessageState extends ConsumerState { final uploadService = EncryptedFileUploadService(); final decryptedFile = await uploadService.downloadAndDecryptFile( blossomUrl: metadata.blossomUrl, - nonceHex: metadata.nonce, sharedKey: sharedKey, ); diff --git a/lib/services/encrypted_file_upload_service.dart b/lib/services/encrypted_file_upload_service.dart index 233c7a95..7ced4a42 100644 --- a/lib/services/encrypted_file_upload_service.dart +++ b/lib/services/encrypted_file_upload_service.dart @@ -131,14 +131,13 @@ class EncryptedFileUploadService { } /// Download and decrypt file from Blossom + /// The nonce is automatically extracted from the encrypted blob Future downloadAndDecryptFile({ required String blossomUrl, - required String nonceHex, required Uint8List sharedKey, }) async { _logger.i('πŸ”“ Starting encrypted file download and decryption...'); _logger.d('URL: $blossomUrl'); - _logger.d('Nonce: $nonceHex'); try { // 1. Download encrypted blob from Blossom From 385e8041c47892a7570a75673004e5490b47b364 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Thu, 27 Nov 2025 10:32:37 -0600 Subject: [PATCH 09/30] Avoid triggering side effects from build --- .../chat/widgets/encrypted_image_message.dart | 53 +++++++++++++++++-- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/lib/features/chat/widgets/encrypted_image_message.dart b/lib/features/chat/widgets/encrypted_image_message.dart index 6dc0ecf0..5553e773 100644 --- a/lib/features/chat/widgets/encrypted_image_message.dart +++ b/lib/features/chat/widgets/encrypted_image_message.dart @@ -27,13 +27,47 @@ class _EncryptedImageMessageState extends ConsumerState { bool _isLoading = false; String? _errorMessage; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadImageIfNeeded(); + }); + } + + void _loadImageIfNeeded() { + if (!mounted) return; + + final chatNotifier = ref.read(chatRoomsProvider(widget.orderId).notifier); + final messageId = widget.message.id; + if (messageId == null) { + if (mounted) { + setState(() { + _errorMessage = 'Invalid message: missing ID'; + }); + } + return; + } + + final cachedImage = chatNotifier.getCachedImage(messageId); + if (cachedImage == null && !_isLoading) { + _loadImage(); + } + } + @override Widget build(BuildContext context) { final chatNotifier = ref.read(chatRoomsProvider(widget.orderId).notifier); + // Handle null message ID defensively + final messageId = widget.message.id; + if (messageId == null) { + return _buildErrorWidget(); + } + // Check if image is already cached - final cachedImage = chatNotifier.getCachedImage(widget.message.id!); - final imageMetadata = chatNotifier.getImageMetadata(widget.message.id!); + final cachedImage = chatNotifier.getCachedImage(messageId); + final imageMetadata = chatNotifier.getImageMetadata(messageId); if (cachedImage != null && imageMetadata != null) { return _buildImageWidget(cachedImage, imageMetadata); @@ -47,8 +81,7 @@ class _EncryptedImageMessageState extends ConsumerState { return _buildErrorWidget(); } - // Try to load the image - _loadImage(); + // Show loading widget while waiting for initState to trigger the load return _buildLoadingWidget(); } @@ -241,6 +274,16 @@ class _EncryptedImageMessageState extends ConsumerState { Future _loadImage() async { if (_isLoading) return; + + final messageId = widget.message.id; + if (messageId == null) { + if (mounted) { + setState(() { + _errorMessage = 'Invalid message: missing ID'; + }); + } + return; + } setState(() { _isLoading = true; @@ -275,7 +318,7 @@ class _EncryptedImageMessageState extends ConsumerState { ); // Cache the image - chatNotifier.cacheDecryptedImage(widget.message.id!, decryptedImage, imageData); + chatNotifier.cacheDecryptedImage(messageId, decryptedImage, imageData); if (mounted) { setState(() { From 6ac85f0ffcfaef7d0bdd08d2402e9025888ac032 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Thu, 27 Nov 2025 12:27:45 -0600 Subject: [PATCH 10/30] Eliminated code duplication --- .../chat/notifiers/chat_room_notifier.dart | 30 ++----------- .../chat/utils/message_type_helpers.dart | 45 +++++++++++++++++++ .../chat/widgets/encrypted_file_message.dart | 12 ----- .../chat/widgets/encrypted_image_message.dart | 14 ------ lib/features/chat/widgets/message_bubble.dart | 32 ++----------- 5 files changed, 51 insertions(+), 82 deletions(-) create mode 100644 lib/features/chat/utils/message_type_helpers.dart diff --git a/lib/features/chat/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart index 3cb8ed6e..29b5690c 100644 --- a/lib/features/chat/notifiers/chat_room_notifier.dart +++ b/lib/features/chat/notifiers/chat_room_notifier.dart @@ -17,6 +17,7 @@ import 'package:mostro_mobile/features/subscriptions/subscription_manager_provid import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/features/chat/utils/message_type_helpers.dart'; class ChatRoomNotifier extends StateNotifier { /// Reload the chat room by re-subscribing to events. @@ -434,10 +435,10 @@ class ChatRoomNotifier extends StateNotifier { // Check for encrypted message types if (jsonContent != null) { - if (jsonContent['type'] == 'image_encrypted') { + if (MessageTypeUtils.isEncryptedImageMessage(message)) { _logger.i('πŸ“Έ Processing encrypted image message'); await _processEncryptedImageMessage(message, jsonContent); - } else if (jsonContent['type'] == 'file_encrypted') { + } else if (MessageTypeUtils.isEncryptedFileMessage(message)) { _logger.i('πŸ“Ž Processing encrypted file message'); await _processEncryptedFileMessage(message, jsonContent); } @@ -587,31 +588,6 @@ class ChatRoomNotifier extends StateNotifier { return _fileMetadata[messageId]; } - /// Check if a message is an encrypted image message - bool isEncryptedImageMessage(NostrEvent message) { - try { - final content = message.content; - if (content == null || !content.startsWith('{')) return false; - - final jsonContent = jsonDecode(content); - return jsonContent['type'] == 'image_encrypted'; - } catch (e) { - return false; - } - } - - /// Check if a message is an encrypted file message - bool isEncryptedFileMessage(NostrEvent message) { - try { - final content = message.content; - if (content == null || !content.startsWith('{')) return false; - - final jsonContent = jsonDecode(content); - return jsonContent['type'] == 'file_encrypted'; - } catch (e) { - return false; - } - } @override void dispose() { diff --git a/lib/features/chat/utils/message_type_helpers.dart b/lib/features/chat/utils/message_type_helpers.dart new file mode 100644 index 00000000..7ed2cc2f --- /dev/null +++ b/lib/features/chat/utils/message_type_helpers.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; +import 'package:dart_nostr/dart_nostr.dart'; + +/// Utilities for determining message types from NostrEvent content +class MessageTypeUtils { + /// Helper function to check if a message is an encrypted image + static bool isEncryptedImageMessage(NostrEvent message) { + try { + final content = message.content; + if (content == null || !content.startsWith('{')) return false; + + final jsonContent = jsonDecode(content) as Map; + return jsonContent['type'] == 'image_encrypted'; + } catch (e) { + return false; + } + } + + /// Helper function to check if a message is an encrypted file + static bool isEncryptedFileMessage(NostrEvent message) { + try { + final content = message.content; + if (content == null || !content.startsWith('{')) return false; + + final jsonContent = jsonDecode(content) as Map; + return jsonContent['type'] == 'file_encrypted'; + } catch (e) { + return false; + } + } + + /// Get the message type enum for more structured handling + static MessageContentType getMessageType(NostrEvent message) { + if (isEncryptedImageMessage(message)) return MessageContentType.encryptedImage; + if (isEncryptedFileMessage(message)) return MessageContentType.encryptedFile; + return MessageContentType.text; + } +} + +/// Enum representing different types of message content +enum MessageContentType { + text, + encryptedImage, + encryptedFile, +} \ No newline at end of file diff --git a/lib/features/chat/widgets/encrypted_file_message.dart b/lib/features/chat/widgets/encrypted_file_message.dart index 8eef5305..ddfdcbe7 100644 --- a/lib/features/chat/widgets/encrypted_file_message.dart +++ b/lib/features/chat/widgets/encrypted_file_message.dart @@ -539,15 +539,3 @@ class _EncryptedFileMessageState extends ConsumerState { } } -/// Helper function to check if a message is an encrypted file -bool isEncryptedFileMessage(NostrEvent message) { - try { - final content = message.content; - if (content == null || !content.startsWith('{')) return false; - - final jsonContent = jsonDecode(content) as Map; - return jsonContent['type'] == 'file_encrypted'; - } catch (e) { - return false; - } -} \ No newline at end of file diff --git a/lib/features/chat/widgets/encrypted_image_message.dart b/lib/features/chat/widgets/encrypted_image_message.dart index 5553e773..6f75d856 100644 --- a/lib/features/chat/widgets/encrypted_image_message.dart +++ b/lib/features/chat/widgets/encrypted_image_message.dart @@ -340,18 +340,4 @@ class _EncryptedImageMessageState extends ConsumerState { if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB'; } -} - -/// Helper function to check if a message is an encrypted image -bool isEncryptedImageMessage(NostrEvent message) { - try { - final content = message.content; - if (content == null || !content.startsWith('{')) return false; - - // ignore: avoid_dynamic_calls - final jsonContent = jsonDecode(content) as Map; - return jsonContent['type'] == 'image_encrypted'; - } catch (e) { - return false; - } } \ No newline at end of file diff --git a/lib/features/chat/widgets/message_bubble.dart b/lib/features/chat/widgets/message_bubble.dart index a6f9986d..5dd14347 100644 --- a/lib/features/chat/widgets/message_bubble.dart +++ b/lib/features/chat/widgets/message_bubble.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,32 +7,7 @@ import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/providers/avatar_provider.dart'; import 'package:mostro_mobile/features/chat/widgets/encrypted_image_message.dart'; import 'package:mostro_mobile/features/chat/widgets/encrypted_file_message.dart'; - -/// Helper function to check if a message is an encrypted image -bool isEncryptedImageMessage(NostrEvent message) { - try { - final content = message.content; - if (content == null || !content.startsWith('{')) return false; - - final jsonContent = jsonDecode(content); - return jsonContent['type'] == 'image_encrypted'; - } catch (e) { - return false; - } -} - -/// Helper function to check if a message is an encrypted file -bool isEncryptedFileMessage(NostrEvent message) { - try { - final content = message.content; - if (content == null || !content.startsWith('{')) return false; - - final jsonContent = jsonDecode(content); - return jsonContent['type'] == 'file_encrypted'; - } catch (e) { - return false; - } -} +import 'package:mostro_mobile/features/chat/utils/message_type_helpers.dart'; class MessageBubble extends ConsumerWidget { final NostrEvent message; @@ -58,7 +32,7 @@ class MessageBubble extends ConsumerWidget { } // Check if this is an encrypted image message - if (isEncryptedImageMessage(message)) { + if (MessageTypeUtils.isEncryptedImageMessage(message)) { return Container( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), alignment: isFromPeer ? Alignment.centerLeft : Alignment.centerRight, @@ -96,7 +70,7 @@ class MessageBubble extends ConsumerWidget { } // Check if this is an encrypted file message - if (isEncryptedFileMessage(message)) { + if (MessageTypeUtils.isEncryptedFileMessage(message)) { return Container( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), alignment: isFromPeer ? Alignment.centerLeft : Alignment.centerRight, From b576897bf5e2bf4717897dbe6768cf9762007f35 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Thu, 27 Nov 2025 12:44:08 -0600 Subject: [PATCH 11/30] HTTP Client Resource Leak Fixed --- lib/services/blossom_download_service.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/services/blossom_download_service.dart b/lib/services/blossom_download_service.dart index a6dc7e5f..ed9e7d9a 100644 --- a/lib/services/blossom_download_service.dart +++ b/lib/services/blossom_download_service.dart @@ -114,13 +114,13 @@ class BlossomDownloadService { ) async { _logger.i('πŸ“₯ Download with progress tracking: $blossomUrl'); + final client = http.Client(); try { final uri = Uri.parse(blossomUrl); final request = http.Request('GET', uri); request.headers['User-Agent'] = 'MostroMobile/1.0'; request.headers['Accept'] = 'application/octet-stream, */*'; - final client = http.Client(); final response = await client.send(request).timeout(_timeout); if (response.statusCode != 200) { @@ -142,8 +142,6 @@ class BlossomDownloadService { } } - client.close(); - final data = Uint8List.fromList(bytes); _logger.i('βœ… Download with progress completed: ${data.length} bytes'); return data; @@ -154,6 +152,8 @@ class BlossomDownloadService { rethrow; } throw BlossomDownloadException('Network error: $e'); + } finally { + client.close(); } } } From 1aae6ddc84c3d94e63310eb7b16a8f2a64d41cab Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:48:09 -0600 Subject: [PATCH 12/30] Add secure file upload system with format restrictions and image sanitization --- lib/features/chat/widgets/message_input.dart | 74 +++++++- .../encrypted_image_upload_service.dart | 4 +- lib/services/file_validation_service.dart | 160 ++++++++++++++++-- lib/services/media_validation_service.dart | 120 ++++++++++--- 4 files changed, 309 insertions(+), 49 deletions(-) diff --git a/lib/features/chat/widgets/message_input.dart b/lib/features/chat/widgets/message_input.dart index 49932978..e053df42 100644 --- a/lib/features/chat/widgets/message_input.dart +++ b/lib/features/chat/widgets/message_input.dart @@ -4,11 +4,14 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:mime/mime.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/services/encrypted_file_upload_service.dart'; +import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; import 'package:mostro_mobile/services/file_validation_service.dart'; +import 'package:mostro_mobile/services/media_validation_service.dart'; class MessageInput extends ConsumerStatefulWidget { final String orderId; @@ -30,6 +33,7 @@ class _MessageInputState extends ConsumerState { final TextEditingController _textController = TextEditingController(); final FocusNode _focusNode = FocusNode(); final EncryptedFileUploadService _fileUploadService = EncryptedFileUploadService(); + final EncryptedImageUploadService _imageUploadService = EncryptedImageUploadService(); bool _isUploadingFile = false; // For loading indicator @@ -84,18 +88,35 @@ class _MessageInputState extends ConsumerState { if (result != null && result.files.single.path != null) { final file = File(result.files.single.path!); + final filename = result.files.single.name; // Get shared key for this order/chat final sharedKey = await _getSharedKeyForOrder(widget.orderId); - // Upload encrypted file to Blossom - final uploadResult = await _fileUploadService.uploadEncryptedFile( - file: file, - sharedKey: sharedKey, - ); + // Determine if this is an image or other file type + final fileData = await file.readAsBytes(); + final isImage = await _isImageFile(fileData, filename); - // Send encrypted file message via NIP-59 - await _sendEncryptedFileMessage(uploadResult); + if (isImage) { + // Handle as image with light sanitization + final uploadResult = await _imageUploadService.uploadEncryptedImage( + imageFile: file, + sharedKey: sharedKey, + filename: filename, + ); + + // Send encrypted image message via NIP-59 + await _sendEncryptedImageMessage(uploadResult); + } else { + // Handle as document/video with validation + final uploadResult = await _fileUploadService.uploadEncryptedFile( + file: file, + sharedKey: sharedKey, + ); + + // Send encrypted file message via NIP-59 + await _sendEncryptedFileMessage(uploadResult); + } } } catch (e) { // Show error to user @@ -116,6 +137,29 @@ class _MessageInputState extends ConsumerState { } } + // Determine if a file is an image based on content and extension + Future _isImageFile(Uint8List fileData, String filename) async { + try { + // Use mime detection to check if it's an image + final mimeType = await _getMimeType(fileData, filename); + return MediaValidationService.isImageTypeSupported(mimeType ?? ''); + } catch (e) { + return false; + } + } + + // Get MIME type for a file + Future _getMimeType(Uint8List fileData, String filename) async { + // First try magic bytes detection + final mimeFromBytes = lookupMimeType('', headerBytes: fileData); + if (mimeFromBytes != null) { + return mimeFromBytes; + } + + // Fallback to extension-based detection + return lookupMimeType(filename); + } + // Get shared key for this order/chat session Future _getSharedKeyForOrder(String orderId) async { // Get the chat room notifier to access the shared key @@ -139,6 +183,22 @@ class _MessageInputState extends ConsumerState { } } + // Send encrypted image message via NIP-59 gift wrap + Future _sendEncryptedImageMessage(EncryptedImageUploadResult result) async { + try { + // Create JSON content for the rumor + final imageMessageJson = jsonEncode(result.toJson()); + + // Send via existing chat system (will be wrapped in NIP-59) + await ref + .read(chatRoomsProvider(widget.orderId).notifier) + .sendMessage(imageMessageJson); + + } catch (e) { + throw Exception('Failed to send encrypted image message: $e'); + } + } + @override diff --git a/lib/services/encrypted_image_upload_service.dart b/lib/services/encrypted_image_upload_service.dart index 5a6f67a0..6a16fa59 100644 --- a/lib/services/encrypted_image_upload_service.dart +++ b/lib/services/encrypted_image_upload_service.dart @@ -89,8 +89,8 @@ class EncryptedImageUploadService { final imageData = await imageFile.readAsBytes(); _logger.d('Read image file: ${imageData.length} bytes'); - // 2. Validate and sanitize (like whitenoise) - final validationResult = await MediaValidationService.validateAndSanitizeImage( + // 2. Validate and sanitize with light sanitization for better performance + final validationResult = await MediaValidationService.validateAndSanitizeImageLight( imageData ); diff --git a/lib/services/file_validation_service.dart b/lib/services/file_validation_service.dart index 4f14c70a..6a754e4e 100644 --- a/lib/services/file_validation_service.dart +++ b/lib/services/file_validation_service.dart @@ -31,23 +31,17 @@ class FileValidationService { static const Map> supportedTypes = { 'image': [ 'image/jpeg', - 'image/jpg', - 'image/png', - 'image/gif', - 'image/webp' + 'image/png' ], 'video': [ 'video/mp4', 'video/quicktime', - 'video/x-msvideo', // AVI - 'video/webm' + 'video/x-msvideo' // AVI ], 'document': [ 'application/pdf', 'application/msword', // DOC - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // DOCX - 'text/plain', - 'text/rtf' + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' // DOCX ] }; @@ -56,21 +50,16 @@ class FileValidationService { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp', // Videos '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.avi': 'video/x-msvideo', - '.webm': 'video/webm', // Documents '.pdf': 'application/pdf', '.doc': 'application/msword', - '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - '.txt': 'text/plain', - '.rtf': 'text/rtf' + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }; /// Validate file for secure P2P exchange @@ -107,6 +96,9 @@ class FileValidationService { throw FileValidationException('Unsupported file type: $mimeType'); } + // 5. Perform type-specific validation + await _performTypeSpecificValidation(fileData, mimeType, fileType); + _logger.i('βœ… File validation successful: $fileType ($mimeType)'); return FileValidationResult( @@ -160,6 +152,144 @@ class FileValidationService { return extensionToMime.keys.toList(); } + /// Perform type-specific validation based on file type + static Future _performTypeSpecificValidation( + Uint8List fileData, + String mimeType, + String fileType + ) async { + switch (fileType) { + case 'document': + if (mimeType == 'application/pdf') { + await _validatePdfStructure(fileData); + } else if (mimeType == 'application/msword' || + mimeType == 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { + await _validateDocumentStructure(fileData, mimeType); + } + break; + case 'video': + await _validateVideoStructure(fileData, mimeType); + break; + // Images are handled by MediaValidationService + case 'image': + break; + } + } + + /// Validate PDF structure and security + static Future _validatePdfStructure(Uint8List fileData) async { + _logger.d('πŸ” Validating PDF structure...'); + + // Check PDF header + if (fileData.length < 8) { + throw FileValidationException('PDF file too small or corrupted'); + } + + // PDF files must start with %PDF- + final header = String.fromCharCodes(fileData.take(5)); + if (!header.startsWith('%PDF-')) { + throw FileValidationException('Invalid PDF header'); + } + + // Check for PDF trailer (should end with %%EOF or whitespace) + final tail = String.fromCharCodes(fileData.skip(fileData.length - 20).take(20)); + if (!tail.contains('%%EOF')) { + _logger.w('⚠️ PDF may be incomplete (no %%EOF trailer found)'); + } + + _logger.d('βœ… PDF structure validation passed'); + } + + /// Validate DOC/DOCX structure and check for macros + static Future _validateDocumentStructure(Uint8List fileData, String mimeType) async { + _logger.d('πŸ” Validating document structure...'); + + if (mimeType == 'application/msword') { + // DOC file validation - check OLE header + if (fileData.length < 8) { + throw FileValidationException('DOC file too small or corrupted'); + } + + // DOC files start with OLE signature: D0CF11E0A1B11AE1 + final oleHeader = fileData.take(8).toList(); + final expectedHeader = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + + bool headerMatches = true; + for (int i = 0; i < 8; i++) { + if (oleHeader[i] != expectedHeader[i]) { + headerMatches = false; + break; + } + } + + if (!headerMatches) { + throw FileValidationException('Invalid DOC file format'); + } + + // Basic macro detection for DOC files + final fileString = String.fromCharCodes(fileData); + if (fileString.contains('VBA') || fileString.contains('Microsoft Visual Basic')) { + throw FileValidationException('Document contains macros which are not allowed for security reasons'); + } + } + else if (mimeType == 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { + // DOCX file validation - check ZIP header + if (fileData.length < 4) { + throw FileValidationException('DOCX file too small or corrupted'); + } + + // DOCX files are ZIP archives, should start with PK header + if (fileData[0] != 0x50 || fileData[1] != 0x4B) { + throw FileValidationException('Invalid DOCX file format (not a ZIP archive)'); + } + + // Basic macro detection - look for vbaProject.bin in the ZIP structure + final fileString = String.fromCharCodes(fileData); + if (fileString.contains('vbaProject.bin') || fileString.contains('macros/')) { + throw FileValidationException('Document contains macros which are not allowed for security reasons'); + } + } + + _logger.d('βœ… Document structure validation passed'); + } + + /// Validate video file structure + static Future _validateVideoStructure(Uint8List fileData, String mimeType) async { + _logger.d('πŸ” Validating video structure...'); + + if (fileData.length < 12) { + throw FileValidationException('Video file too small or corrupted'); + } + + switch (mimeType) { + case 'video/mp4': + // MP4 files should have 'ftyp' box near the beginning + final header = String.fromCharCodes(fileData.skip(4).take(4)); + if (header != 'ftyp') { + throw FileValidationException('Invalid MP4 file structure'); + } + break; + case 'video/quicktime': + // MOV files also use 'ftyp' box or 'moov' box + final header1 = String.fromCharCodes(fileData.skip(4).take(4)); + final header2 = String.fromCharCodes(fileData.skip(8).take(4)); + if (header1 != 'ftyp' && header1 != 'moov' && header2 != 'moov') { + throw FileValidationException('Invalid MOV file structure'); + } + break; + case 'video/x-msvideo': + // AVI files start with RIFF header followed by AVI + final riff = String.fromCharCodes(fileData.take(4)); + final avi = String.fromCharCodes(fileData.skip(8).take(3)); + if (riff != 'RIFF' || avi != 'AVI') { + throw FileValidationException('Invalid AVI file structure'); + } + break; + } + + _logger.d('βœ… Video structure validation passed'); + } + /// Get file type icon static String getFileTypeIcon(String fileType) { switch (fileType) { diff --git a/lib/services/media_validation_service.dart b/lib/services/media_validation_service.dart index 0aaabc9a..12406c5c 100644 --- a/lib/services/media_validation_service.dart +++ b/lib/services/media_validation_service.dart @@ -1,12 +1,11 @@ import 'dart:typed_data'; import 'package:image/image.dart' as img; import 'package:mime/mime.dart'; +import 'package:logger/logger.dart'; enum SupportedImageType { jpeg, png, - gif, - webp, } extension SupportedImageTypeExtension on SupportedImageType { @@ -16,10 +15,6 @@ extension SupportedImageTypeExtension on SupportedImageType { return 'image/jpeg'; case SupportedImageType.png: return 'image/png'; - case SupportedImageType.gif: - return 'image/gif'; - case SupportedImageType.webp: - return 'image/webp'; } } @@ -29,18 +24,12 @@ extension SupportedImageTypeExtension on SupportedImageType { return 'jpg'; case SupportedImageType.png: return 'png'; - case SupportedImageType.gif: - return 'gif'; - case SupportedImageType.webp: - return 'webp'; } } static const List all = [ SupportedImageType.jpeg, SupportedImageType.png, - SupportedImageType.gif, - SupportedImageType.webp, ]; } @@ -63,6 +52,8 @@ class MediaValidationResult { } class MediaValidationService { + static final Logger _logger = Logger(); + /// Sanitizes and validates image exactly like whitenoise /// 1. Detects format from data (not extension) /// 2. Validates that it's a complete and valid image @@ -70,11 +61,24 @@ class MediaValidationService { static Future validateAndSanitizeImage( Uint8List imageData, ) async { + return _sanitizeImageHeavy(imageData); + } + + /// Light image sanitization for better performance + /// 1. Detects format using magic bytes + /// 2. Basic integrity check via decode + /// 3. Strips metadata only (no heavy pixel validation) + /// 4. Quick re-encode with minimal quality loss + static Future validateAndSanitizeImageLight( + Uint8List imageData, + ) async { + _logger.i('πŸ“Έ Light image sanitization started: ${imageData.length} bytes'); + if (imageData.isEmpty) { throw MediaValidationException('File is empty'); } - // STEP 1: Detect format using magic bytes (like whitenoise) + // STEP 1: Detect format using magic bytes SupportedImageType? detectedType; // Use mime package for initial detection @@ -87,11 +91,78 @@ class MediaValidationService { case 'image/png': detectedType = SupportedImageType.png; break; - case 'image/gif': - detectedType = SupportedImageType.gif; + default: + throw MediaValidationException( + 'Unsupported image format: $mimeType. Supported formats: ${SupportedImageTypeExtension.all.map((t) => t.mimeType).join(", ")}' + ); + } + + // STEP 2: Basic integrity check via decode (no pixel validation) + img.Image? decodedImage; + try { + decodedImage = img.decodeImage(imageData); + if (decodedImage == null) { + throw MediaValidationException( + 'Invalid or corrupted ${detectedType.mimeType} image: Could not decode' + ); + } + } catch (e) { + throw MediaValidationException( + 'Invalid or corrupted ${detectedType.mimeType} image: $e' + ); + } + + // STEP 3: Quick re-encode to strip metadata with minimal quality loss + Uint8List sanitizedData; + try { + switch (detectedType) { + case SupportedImageType.jpeg: + // Use quality 95 to minimize quality loss while stripping EXIF + sanitizedData = Uint8List.fromList(img.encodeJpg(decodedImage, quality: 95)); + break; + case SupportedImageType.png: + // PNG is lossless, so no quality concerns + sanitizedData = Uint8List.fromList(img.encodePng(decodedImage)); + break; + } + } catch (e) { + throw MediaValidationException('Failed to re-encode image: $e'); + } + + _logger.i('βœ… Light image sanitization completed: ${sanitizedData.length} bytes'); + + return MediaValidationResult( + imageType: detectedType, + mimeType: detectedType.mimeType, + extension: detectedType.extension, + validatedData: sanitizedData, + width: decodedImage.width, + height: decodedImage.height, + ); + } + + /// Heavy image sanitization (original method) for maximum security + static Future _sanitizeImageHeavy( + Uint8List imageData, + ) async { + _logger.i('πŸ”’ Heavy image sanitization started: ${imageData.length} bytes'); + + if (imageData.isEmpty) { + throw MediaValidationException('File is empty'); + } + + // STEP 1: Detect format using magic bytes (like whitenoise) + SupportedImageType? detectedType; + + // Use mime package for initial detection + final mimeType = lookupMimeType('', headerBytes: imageData); + + switch (mimeType) { + case 'image/jpeg': + detectedType = SupportedImageType.jpeg; break; - case 'image/webp': - detectedType = SupportedImageType.webp; + case 'image/png': + detectedType = SupportedImageType.png; break; default: throw MediaValidationException( @@ -125,19 +196,13 @@ class MediaValidationService { case SupportedImageType.png: sanitizedData = Uint8List.fromList(img.encodePng(decodedImage)); break; - case SupportedImageType.gif: - sanitizedData = Uint8List.fromList(img.encodeGif(decodedImage)); - break; - case SupportedImageType.webp: - // Convert WebP to PNG (WebP encoding not available in this image package version) - sanitizedData = Uint8List.fromList(img.encodePng(decodedImage)); - detectedType = SupportedImageType.png; - break; } } catch (e) { throw MediaValidationException('Failed to re-encode image: $e'); } + _logger.i('βœ… Heavy image sanitization completed: ${sanitizedData.length} bytes'); + return MediaValidationResult( imageType: detectedType, mimeType: detectedType.mimeType, @@ -147,6 +212,11 @@ class MediaValidationService { height: decodedImage.height, ); } + + /// Check if image type is supported in the new format restrictions + static bool isImageTypeSupported(String mimeType) { + return mimeType == 'image/jpeg' || mimeType == 'image/png'; + } } class MediaValidationException implements Exception { From 004ecbc82ff8fa1af67afe515d88d9ded80cda83 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Thu, 27 Nov 2025 19:10:41 -0600 Subject: [PATCH 13/30] sanitize files name --- .../chat/widgets/encrypted_file_message.dart | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/lib/features/chat/widgets/encrypted_file_message.dart b/lib/features/chat/widgets/encrypted_file_message.dart index ddfdcbe7..3734cb6e 100644 --- a/lib/features/chat/widgets/encrypted_file_message.dart +++ b/lib/features/chat/widgets/encrypted_file_message.dart @@ -463,11 +463,11 @@ class _EncryptedFileMessageState extends ConsumerState { final sanitizedFilename = _sanitizeFilename(metadata.filename); final tempFile = File('${tempDir.path}/$sanitizedFilename'); - // Verify the resolved path is still within the temp directory to prevent path traversal - final tempDirPath = tempDir.resolveSymbolicLinksSync(); - final tempFilePath = tempFile.absolute.path; - if (!tempFilePath.startsWith('$tempDirPath${Platform.pathSeparator}')) { - throw Exception('Security error: Path traversal attempt detected in filename'); + // Basic security check: ensure sanitized filename is safe + // The sanitization function already handles most security concerns + if (sanitizedFilename.contains('/') || sanitizedFilename.contains('\\') || + sanitizedFilename.contains('..') || sanitizedFilename.trim().isEmpty) { + throw Exception('Security error: Invalid characters in sanitized filename'); } await tempFile.writeAsBytes(cachedFile); @@ -521,21 +521,49 @@ class _EncryptedFileMessageState extends ConsumerState { // 1. Get basename only (remove any directory components) final basename = filename.split(RegExp(r'[/\\]')).last; - // 2. Remove dangerous characters including null bytes, control chars, and reserved chars - final cleaned = basename.replaceAll(RegExp(r'[<>:"|?*\x00-\x1F]'), '_'); + // 2. Normalize accented characters to prevent encoding issues + String normalized = basename + .replaceAll('Γ‘', 'a').replaceAll('Γ©', 'e').replaceAll('Γ­', 'i') + .replaceAll('Γ³', 'o').replaceAll('ΓΊ', 'u').replaceAll('Γ±', 'n') + .replaceAll('ΓΌ', 'u').replaceAll('Á', 'A').replaceAll('Γ‰', 'E') + .replaceAll('Í', 'I').replaceAll('Γ“', 'O').replaceAll('Ú', 'U') + .replaceAll('Γ‘', 'N').replaceAll('Ü', 'U'); - // 3. Limit length to reasonable size - final limited = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned; + // 3. Replace spaces with underscores and remove dangerous characters + final cleaned = normalized + .replaceAll(RegExp(r'\s+'), '_') // Replace spaces with underscores + .replaceAll(RegExp(r'[<>:"|?*\x00-\x1F]'), '_') // Remove dangerous chars + .replaceAll('..', '_'); // Prevent directory traversal patterns - // 4. Ensure not empty and not Windows reserved names - if (limited.isEmpty || + // 4. Preserve file extension + String sanitized = cleaned; + if (sanitized.contains('.')) { + final parts = sanitized.split('.'); + if (parts.length > 1) { + final extension = parts.last; + final nameWithoutExt = parts.sublist(0, parts.length - 1).join('_'); + final maxNameLength = 100 - extension.length - 1; + final truncatedName = nameWithoutExt.length > maxNameLength + ? nameWithoutExt.substring(0, maxNameLength) + : nameWithoutExt; + sanitized = '$truncatedName.$extension'; + } + } else { + // No extension, just limit length + sanitized = sanitized.length > 100 ? sanitized.substring(0, 100) : sanitized; + } + + // 5. Ensure not empty and not Windows reserved names + final nameOnly = sanitized.contains('.') ? sanitized.split('.').first : sanitized; + if (sanitized.isEmpty || nameOnly.isEmpty || ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', - 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'].contains(limited.toUpperCase())) { - return 'file_${DateTime.now().millisecondsSinceEpoch}'; + 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'].contains(nameOnly.toUpperCase())) { + final extension = sanitized.contains('.') ? '.${sanitized.split('.').last}' : ''; + return 'file_${DateTime.now().millisecondsSinceEpoch}$extension'; } - return limited; + return sanitized; } } From 5a42c479af6bd0a5ddf674167ae982f30205b316 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:54:19 -0600 Subject: [PATCH 14/30] update dependencies --- pubspec.lock | 8 ++++---- pubspec.yaml | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 4113eb50..573d63e8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -421,10 +421,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + sha256: "7872545770c277236fd32b022767576c562ba28366204ff1a5628853cf8f2200" url: "https://pub.dev" source: hosted - version: "8.3.7" + version: "10.3.7" file_selector_linux: dependency: transitive description: @@ -1064,10 +1064,10 @@ packages: dependency: "direct main" description: name: mime - sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.6" + version: "2.0.0" mockito: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index f11964a5..b5a31b65 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -85,16 +85,17 @@ dependencies: app_links: ^6.4.0 # Dependencies for image upload to Blossom - image_picker: ^1.0.4 - mime: ^1.0.4 - image: ^4.1.3 + image_picker: ^1.2.1 + mime: ^2.0.0 + image: ^4.5.4 # ChaCha20-Poly1305 encryption + # Pinned to ^3.9.1 due to bip32 ^2.0.0 dependency conflict pointycastle: ^3.9.1 # File picker for documents, videos, etc. - file_picker: ^8.0.0+1 - open_file: ^3.3.2 + file_picker: ^10.3.7 + open_file: ^3.5.10 dev_dependencies: From 71b67173d6e1715aa3b911bd6af82026e734b008 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 1 Dec 2025 18:16:57 -0600 Subject: [PATCH 15/30] code rabbit sugestions --- .../chat/widgets/encrypted_file_message.dart | 26 +++++----- lib/services/file_validation_service.dart | 47 +++++++++++++++++-- lib/services/media_validation_service.dart | 4 ++ 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/lib/features/chat/widgets/encrypted_file_message.dart b/lib/features/chat/widgets/encrypted_file_message.dart index 3734cb6e..b3f04986 100644 --- a/lib/features/chat/widgets/encrypted_file_message.dart +++ b/lib/features/chat/widgets/encrypted_file_message.dart @@ -96,7 +96,7 @@ class _EncryptedFileMessageState extends ConsumerState { errorBuilder: (context, error, stackTrace) { return Container( height: 200, - color: Colors.grey.withValues(alpha: 51), + color: Colors.grey.withValues(alpha: 0.2), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -130,7 +130,7 @@ class _EncryptedFileMessageState extends ConsumerState { begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ - Colors.black.withValues(alpha: 178), + Colors.black.withValues(alpha: 0.7), Colors.transparent, ], ), @@ -156,7 +156,7 @@ class _EncryptedFileMessageState extends ConsumerState { Text( _formatFileSize(metadata.originalSize), style: TextStyle( - color: Colors.white.withValues(alpha: 178), + color: Colors.white.withValues(alpha: 0.7), fontSize: 10, ), ), @@ -167,7 +167,7 @@ class _EncryptedFileMessageState extends ConsumerState { onPressed: () => _openFile(), icon: Icon( Icons.open_in_new, - color: Colors.white.withValues(alpha: 178), + color: Colors.white.withValues(alpha: 0.7), size: 16, ), padding: EdgeInsets.zero, @@ -198,7 +198,7 @@ class _EncryptedFileMessageState extends ConsumerState { color: AppTheme.backgroundCard, borderRadius: BorderRadius.circular(12), border: Border.all( - color: AppTheme.textSecondary.withValues(alpha: 76), + color: AppTheme.textSecondary.withValues(alpha: 0.3), width: 1, ), ), @@ -235,20 +235,20 @@ class _EncryptedFileMessageState extends ConsumerState { _formatFileSize(metadata.originalSize), style: TextStyle( fontSize: 12, - color: Colors.white.withValues(alpha: 178), + color: Colors.white.withValues(alpha: 0.7), ), ), const SizedBox(width: 8), Icon( Icons.lock, size: 12, - color: Colors.white.withValues(alpha: 178), + color: Colors.white.withValues(alpha: 0.7), ), Text( ' ${S.of(context)!.encrypted}', style: TextStyle( fontSize: 11, - color: Colors.white.withValues(alpha: 178), + color: Colors.white.withValues(alpha: 0.7), ), ), ], @@ -301,7 +301,7 @@ class _EncryptedFileMessageState extends ConsumerState { color: AppTheme.backgroundCard, borderRadius: BorderRadius.circular(12), border: Border.all( - color: AppTheme.textSecondary.withValues(alpha: 76), + color: AppTheme.textSecondary.withValues(alpha: 0.3), width: 1, ), ), @@ -322,7 +322,7 @@ class _EncryptedFileMessageState extends ConsumerState { S.of(context)!.downloadingFile, style: TextStyle( fontSize: 12, - color: Colors.white.withValues(alpha: 178), + color: Colors.white.withValues(alpha: 0.7), ), textAlign: TextAlign.center, ), @@ -341,10 +341,10 @@ class _EncryptedFileMessageState extends ConsumerState { ), padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 25), + color: Colors.red.withValues(alpha: 0.098), borderRadius: BorderRadius.circular(12), border: Border.all( - color: Colors.red.withValues(alpha: 127), + color: Colors.red.withValues(alpha: 0.5), width: 1, ), ), @@ -373,7 +373,7 @@ class _EncryptedFileMessageState extends ConsumerState { _errorMessage!, style: TextStyle( fontSize: 10, - color: Colors.red.withValues(alpha: 153), + color: Colors.red.withValues(alpha: 0.6), ), textAlign: TextAlign.center, maxLines: 2, diff --git a/lib/services/file_validation_service.dart b/lib/services/file_validation_service.dart index 6a754e4e..e945fa38 100644 --- a/lib/services/file_validation_service.dart +++ b/lib/services/file_validation_service.dart @@ -226,9 +226,8 @@ class FileValidationService { throw FileValidationException('Invalid DOC file format'); } - // Basic macro detection for DOC files - final fileString = String.fromCharCodes(fileData); - if (fileString.contains('VBA') || fileString.contains('Microsoft Visual Basic')) { + // Basic macro detection for DOC files using byte pattern search + if (_containsMacroPatterns(fileData)) { throw FileValidationException('Document contains macros which are not allowed for security reasons'); } } @@ -244,8 +243,7 @@ class FileValidationService { } // Basic macro detection - look for vbaProject.bin in the ZIP structure - final fileString = String.fromCharCodes(fileData); - if (fileString.contains('vbaProject.bin') || fileString.contains('macros/')) { + if (_containsMacroPatterns(fileData)) { throw FileValidationException('Document contains macros which are not allowed for security reasons'); } } @@ -290,6 +288,45 @@ class FileValidationService { _logger.d('βœ… Video structure validation passed'); } + /// Check if file contains macro patterns using efficient byte search + static bool _containsMacroPatterns(Uint8List fileData) { + // Patterns to search for (ASCII byte sequences) + final patterns = [ + 'VBA'.codeUnits, // VBA macro indicator + 'Microsoft Visual Basic'.codeUnits, // VBA full name + 'vbaProject.bin'.codeUnits, // DOCX macro file + 'macros/'.codeUnits, // DOCX macro directory + ]; + + // Search for any of the patterns + for (final pattern in patterns) { + if (_containsPattern(fileData, pattern)) { + return true; + } + } + + return false; + } + + /// Efficient byte pattern search using sliding window approach + static bool _containsPattern(Uint8List data, List pattern) { + if (pattern.isEmpty || data.length < pattern.length) { + return false; + } + + // Sliding window search - stops as soon as pattern is found + outer: for (int i = 0; i <= data.length - pattern.length; i++) { + for (int j = 0; j < pattern.length; j++) { + if (data[i + j] != pattern[j]) { + continue outer; + } + } + return true; // Pattern found - early exit + } + + return false; + } + /// Get file type icon static String getFileTypeIcon(String fileType) { switch (fileType) { diff --git a/lib/services/media_validation_service.dart b/lib/services/media_validation_service.dart index 12406c5c..32ea10e0 100644 --- a/lib/services/media_validation_service.dart +++ b/lib/services/media_validation_service.dart @@ -106,6 +106,8 @@ class MediaValidationService { 'Invalid or corrupted ${detectedType.mimeType} image: Could not decode' ); } + } on MediaValidationException { + rethrow; } catch (e) { throw MediaValidationException( 'Invalid or corrupted ${detectedType.mimeType} image: $e' @@ -180,6 +182,8 @@ class MediaValidationService { 'Invalid or corrupted ${detectedType.mimeType} image: Could not decode' ); } + } on MediaValidationException { + rethrow; } catch (e) { throw MediaValidationException( 'Invalid or corrupted ${detectedType.mimeType} image: $e' From 0f87a2336fee874c4b638b76810df5954f5aa274 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 1 Dec 2025 18:35:17 -0600 Subject: [PATCH 16/30] Add a confirmation message before sending the file --- lib/features/chat/widgets/message_input.dart | 85 +++++++++++++++++++- lib/l10n/intl_en.arb | 7 +- lib/l10n/intl_es.arb | 7 +- lib/l10n/intl_it.arb | 7 +- 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/lib/features/chat/widgets/message_input.dart b/lib/features/chat/widgets/message_input.dart index e053df42..b7f7bb66 100644 --- a/lib/features/chat/widgets/message_input.dart +++ b/lib/features/chat/widgets/message_input.dart @@ -90,6 +90,12 @@ class _MessageInputState extends ConsumerState { final file = File(result.files.single.path!); final filename = result.files.single.name; + // Show confirmation dialog before uploading + final shouldUpload = await _showFileConfirmationDialog(filename); + if (!shouldUpload) { + return; // User cancelled, exit without uploading + } + // Get shared key for this order/chat final sharedKey = await _getSharedKeyForOrder(widget.orderId); @@ -199,7 +205,84 @@ class _MessageInputState extends ConsumerState { } } - + // Show confirmation dialog for file upload + Future _showFileConfirmationDialog(String filename) async { + final result = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + backgroundColor: AppTheme.backgroundCard, + title: Text( + S.of(context)!.confirmFileUpload, + style: TextStyle( + color: AppTheme.cream1, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + S.of(context)!.sendThisFile, + style: TextStyle( + color: AppTheme.cream1, + fontSize: 16, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppTheme.backgroundInput, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + filename, + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + S.of(context)!.cancel, + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + ), + ), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + child: Text( + S.of(context)!.send, + style: const TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + }, + ); + + return result ?? false; // If dialog is dismissed, treat as cancelled + } @override Widget build(BuildContext context) { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 0423fc30..2f0aa90d 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1197,5 +1197,10 @@ "download": "Download", "openFile": "Open File", "encrypted": "Encrypted", - "downloadingFile": "Downloading file..." + "downloadingFile": "Downloading file...", + + "@_comment_file_confirmation": "File confirmation dialog strings", + "confirmFileUpload": "Confirm Upload", + "sendThisFile": "Send this file?", + "send": "Send" } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index f1351088..c4b5848b 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1175,6 +1175,11 @@ "download": "Descargar", "openFile": "Abrir Archivo", "encrypted": "Encriptado", - "downloadingFile": "Descargando archivo..." + "downloadingFile": "Descargando archivo...", + + "@_comment_file_confirmation": "File confirmation dialog strings", + "confirmFileUpload": "Confirmar EnvΓ­o", + "sendThisFile": "ΒΏEnviar este archivo?", + "send": "Enviar" } \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 41784d44..e0e36d28 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1230,5 +1230,10 @@ "download": "Scarica", "openFile": "Apri File", "encrypted": "Crittografato", - "downloadingFile": "Scaricamento file..." + "downloadingFile": "Scaricamento file...", + + "@_comment_file_confirmation": "File confirmation dialog strings", + "confirmFileUpload": "Conferma Invio", + "sendThisFile": "Inviare questo file?", + "send": "Invia" } \ No newline at end of file From dbf2ab5db1050a40bbc90941921d141e9411c0b9 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:27:31 -0600 Subject: [PATCH 17/30] tap to open images --- .../chat/widgets/encrypted_file_message.dart | 10 +- .../chat/widgets/encrypted_image_message.dart | 108 +++++++++++++++++- 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/lib/features/chat/widgets/encrypted_file_message.dart b/lib/features/chat/widgets/encrypted_file_message.dart index b3f04986..f3edb193 100644 --- a/lib/features/chat/widgets/encrypted_file_message.dart +++ b/lib/features/chat/widgets/encrypted_file_message.dart @@ -476,15 +476,7 @@ class _EncryptedFileMessageState extends ConsumerState { final result = await OpenFile.open(tempFile.path); if (mounted) { - if (result.type == ResultType.done) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Opening ${metadata.filename}'), - backgroundColor: AppTheme.mostroGreen, - duration: const Duration(seconds: 2), - ), - ); - } else { + if (result.type != ResultType.done) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Could not open file: ${result.message}'), diff --git a/lib/features/chat/widgets/encrypted_image_message.dart b/lib/features/chat/widgets/encrypted_image_message.dart index 6f75d856..fd43eecb 100644 --- a/lib/features/chat/widgets/encrypted_image_message.dart +++ b/lib/features/chat/widgets/encrypted_image_message.dart @@ -1,8 +1,11 @@ import 'dart:typed_data'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dart_nostr/dart_nostr.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:open_file/open_file.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; @@ -121,12 +124,15 @@ class _EncryptedImageMessageState extends ConsumerState { ), ], ), - child: Image.memory( - imageData, - fit: BoxFit.contain, // Use contain to prevent overflow - errorBuilder: (context, error, stackTrace) { - return _buildErrorWidget(); - }, + child: GestureDetector( + onTap: () => _openImage(imageData, metadata), + child: Image.memory( + imageData, + fit: BoxFit.contain, // Use contain to prevent overflow + errorBuilder: (context, error, stackTrace) { + return _buildErrorWidget(); + }, + ), ), ), ), @@ -340,4 +346,94 @@ class _EncryptedImageMessageState extends ConsumerState { if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB'; } + + Future _openImage(Uint8List imageData, EncryptedImageUploadResult metadata) async { + try { + // Save image to temporary directory + final tempDir = await getTemporaryDirectory(); + final sanitizedFilename = _sanitizeFilename(metadata.filename); + final tempFile = File('${tempDir.path}/$sanitizedFilename'); + + // Security check + if (sanitizedFilename.contains('/') || sanitizedFilename.contains('\\') || + sanitizedFilename.contains('..') || sanitizedFilename.trim().isEmpty) { + throw Exception('Security error: Invalid characters in sanitized filename'); + } + + await tempFile.writeAsBytes(imageData); + + // Open image with system default app + final result = await OpenFile.open(tempFile.path); + + if (mounted) { + if (result.type != ResultType.done) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Could not open file: ${result.message}'), + backgroundColor: Colors.orange, + duration: const Duration(seconds: 3), + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error opening file: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + /// Sanitize filename to prevent path traversal and other security issues + String _sanitizeFilename(String filename) { + // Get basename only (remove any directory components) + final basename = filename.split(RegExp(r'[/\\]')).last; + + // Normalize accented characters + String normalized = basename + .replaceAll('Γ‘', 'a').replaceAll('Γ©', 'e').replaceAll('Γ­', 'i') + .replaceAll('Γ³', 'o').replaceAll('ΓΊ', 'u').replaceAll('Γ±', 'n') + .replaceAll('ΓΌ', 'u').replaceAll('Á', 'A').replaceAll('Γ‰', 'E') + .replaceAll('Í', 'I').replaceAll('Γ“', 'O').replaceAll('Ú', 'U') + .replaceAll('Γ‘', 'N').replaceAll('Ü', 'U'); + + // Replace spaces and remove dangerous characters + final cleaned = normalized + .replaceAll(RegExp(r'\s+'), '_') + .replaceAll(RegExp(r'[<>:"|?*\x00-\x1F]'), '_') + .replaceAll('..', '_'); + + // Preserve file extension + String sanitized = cleaned; + if (sanitized.contains('.')) { + final parts = sanitized.split('.'); + if (parts.length > 1) { + final extension = parts.last; + final nameWithoutExt = parts.sublist(0, parts.length - 1).join('_'); + final maxNameLength = 100 - extension.length - 1; + final truncatedName = nameWithoutExt.length > maxNameLength + ? nameWithoutExt.substring(0, maxNameLength) + : nameWithoutExt; + sanitized = '$truncatedName.$extension'; + } + } else { + sanitized = sanitized.length > 100 ? sanitized.substring(0, 100) : sanitized; + } + + // Ensure not empty and not Windows reserved names + final nameOnly = sanitized.contains('.') ? sanitized.split('.').first : sanitized; + if (sanitized.isEmpty || nameOnly.isEmpty || + ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', + 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', + 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'].contains(nameOnly.toUpperCase())) { + final extension = sanitized.contains('.') ? '.${sanitized.split('.').last}' : ''; + return 'image_${DateTime.now().millisecondsSinceEpoch}$extension'; + } + + return sanitized; + } } \ No newline at end of file From b7a90e7f998f9cf1b5f22054233cabf22fef8b2d Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:47:38 -0600 Subject: [PATCH 18/30] coderabbit sugesions --- .../chat/widgets/encrypted_file_message.dart | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/lib/features/chat/widgets/encrypted_file_message.dart b/lib/features/chat/widgets/encrypted_file_message.dart index f3edb193..3d9f5dc7 100644 --- a/lib/features/chat/widgets/encrypted_file_message.dart +++ b/lib/features/chat/widgets/encrypted_file_message.dart @@ -36,9 +36,15 @@ class _EncryptedFileMessageState extends ConsumerState { Widget build(BuildContext context) { final chatNotifier = ref.read(chatRoomsProvider(widget.orderId).notifier); + // Check if message ID is valid + final messageId = widget.message.id; + if (messageId == null) { + return _buildErrorWidget(); + } + // Check if file is already cached - final cachedFile = chatNotifier.getCachedFile(widget.message.id!); - final fileMetadata = chatNotifier.getFileMetadata(widget.message.id!); + final cachedFile = chatNotifier.getCachedFile(messageId); + final fileMetadata = chatNotifier.getFileMetadata(messageId); if (cachedFile != null && fileMetadata != null) { // Check if it's an image and show preview @@ -62,11 +68,12 @@ class _EncryptedFileMessageState extends ConsumerState { if (metadata != null) { // For images, try to download automatically for preview if (_isImage(metadata.fileType)) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!_isLoading) { + if (!_isLoading) { + setState(() => _isLoading = true); + WidgetsBinding.instance.addPostFrameCallback((_) { _downloadFile(); - } - }); + }); + } } return _buildFileWidget(null, metadata); } @@ -407,10 +414,12 @@ class _EncryptedFileMessageState extends ConsumerState { Future _downloadFile() async { if (_isLoading) return; - setState(() { - _isLoading = true; - _errorMessage = null; - }); + if (mounted) { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + } try { final chatNotifier = ref.read(chatRoomsProvider(widget.orderId).notifier); @@ -431,7 +440,10 @@ class _EncryptedFileMessageState extends ConsumerState { ); // Cache the file - chatNotifier.cacheDecryptedFile(widget.message.id!, decryptedFile, metadata); + final messageId = widget.message.id; + if (messageId != null) { + chatNotifier.cacheDecryptedFile(messageId, decryptedFile, metadata); + } if (mounted) { setState(() { @@ -450,9 +462,14 @@ class _EncryptedFileMessageState extends ConsumerState { Future _openFile() async { try { + final messageId = widget.message.id; + if (messageId == null) { + throw Exception('Invalid message: missing ID'); + } + final chatNotifier = ref.read(chatRoomsProvider(widget.orderId).notifier); - final cachedFile = chatNotifier.getCachedFile(widget.message.id!); - final metadata = chatNotifier.getFileMetadata(widget.message.id!); + final cachedFile = chatNotifier.getCachedFile(messageId); + final metadata = chatNotifier.getFileMetadata(messageId); if (cachedFile == null || metadata == null) { throw Exception('File not available'); From 32f37ab87751f157395fefa2731b9cf6ae995b94 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:19:50 -0600 Subject: [PATCH 19/30] Add comprehensive file messaging tests - Add 31 tests covering encryption, validation, upload/download - Include edge cases and security validation --- test/features/chat/file_messaging_test.dart | 578 ++++++++++++++++++++ test/mocks.dart | 13 + 2 files changed, 591 insertions(+) create mode 100644 test/features/chat/file_messaging_test.dart diff --git a/test/features/chat/file_messaging_test.dart b/test/features/chat/file_messaging_test.dart new file mode 100644 index 00000000..394c1310 --- /dev/null +++ b/test/features/chat/file_messaging_test.dart @@ -0,0 +1,578 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mockito/mockito.dart'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:mostro_mobile/core/config.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; +import 'package:mostro_mobile/data/models/peer.dart'; +import 'package:mostro_mobile/features/key_manager/key_derivator.dart'; +import 'package:mostro_mobile/services/encryption_service.dart'; +import 'package:mostro_mobile/services/blossom_client.dart'; +import 'package:mostro_mobile/services/file_validation_service.dart'; +import 'package:mostro_mobile/services/media_validation_service.dart'; +import 'package:mostro_mobile/services/encrypted_file_upload_service.dart' hide BlossomException; +import 'package:mostro_mobile/services/encrypted_image_upload_service.dart' hide BlossomException; +import 'package:mostro_mobile/services/blossom_download_service.dart'; +import 'package:mostro_mobile/features/chat/notifiers/chat_room_notifier.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; + +import '../../mocks.mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Use valid keys from NIP-06 test vectors (same pattern as mostro_service_test.dart) + const validMnemonic = 'leader monkey parrot ring guide accident before fence cannon height naive bean'; + final keyDerivator = KeyDerivator(Config.keyDerivationPath); + final extendedPrivKey = keyDerivator.extendedKeyFromMnemonic(validMnemonic); + final masterPrivKey = keyDerivator.derivePrivateKey(extendedPrivKey, 0); + final tradePrivKey = keyDerivator.derivePrivateKey(extendedPrivKey, 1); + final peerPrivKey = keyDerivator.derivePrivateKey(extendedPrivKey, 2); + final peerPublicKey = keyDerivator.privateToPublicKey(peerPrivKey); + + provideDummy(Session( + masterKey: NostrKeyPairs(private: masterPrivKey), + tradeKey: NostrKeyPairs(private: tradePrivKey), + keyIndex: 1, + fullPrivacy: false, + startTime: DateTime.now(), + orderId: 'test-order-id', + role: Role.buyer, + peer: Peer(publicKey: peerPublicKey), + )); + + provideDummy(MockBlossomClient()); + provideDummy(MockEncryptedFileUploadService()); + provideDummy(MockEncryptedImageUploadService()); + provideDummy(MockBlossomDownloadService()); + provideDummy(MockChatRoomNotifier()); + + group('File Messaging System', () { + late ProviderContainer container; + late MockBlossomClient mockBlossomClient; + late MockEncryptedFileUploadService mockFileUploadService; + late MockEncryptedImageUploadService mockImageUploadService; + late MockBlossomDownloadService mockDownloadService; + late MockChatRoomNotifier mockChatRoomNotifier; + late Session testSession; + + setUp(() { + mockBlossomClient = MockBlossomClient(); + mockFileUploadService = MockEncryptedFileUploadService(); + mockImageUploadService = MockEncryptedImageUploadService(); + mockDownloadService = MockBlossomDownloadService(); + mockChatRoomNotifier = MockChatRoomNotifier(); + + // Use same valid key derivation pattern as mostro_service_test.dart + testSession = Session( + masterKey: NostrKeyPairs(private: masterPrivKey), + tradeKey: NostrKeyPairs(private: tradePrivKey), + keyIndex: 1, + fullPrivacy: false, + startTime: DateTime.now(), + orderId: 'test-order-id', + role: Role.buyer, + peer: Peer(publicKey: peerPublicKey), + ); + + container = ProviderContainer(); + }); + + tearDown(() { + container.dispose(); + }); + + group('Encryption & Key Management', () { + test('derives identical shared keys for both parties', () { + // Use valid keys from key derivation (same pattern as mostro_service_test.dart) + final userA_privateKey = tradePrivKey; + final userA_publicKey = keyDerivator.privateToPublicKey(userA_privateKey); + final userB_privateKey = peerPrivKey; + final userB_publicKey = peerPublicKey; + + final sharedKeyA = NostrUtils.computeSharedKey(userA_privateKey, userB_publicKey); + final sharedKeyB = NostrUtils.computeSharedKey(userB_privateKey, userA_publicKey); + + expect(sharedKeyA.private, equals(sharedKeyB.private)); + }); + + test('shared key used for both text and files', () async { + final sharedKeyHex = testSession.sharedKey!.private; + final expectedBytes = Uint8List(32); + for (int i = 0; i < 32; i++) { + final byte = int.parse(sharedKeyHex.substring(i * 2, i * 2 + 2), radix: 16); + expectedBytes[i] = byte; + } + + when(mockChatRoomNotifier.getSharedKey()).thenAnswer((_) async => expectedBytes); + + final result = await mockChatRoomNotifier.getSharedKey(); + expect(result.length, equals(32)); + expect(result, equals(expectedBytes)); + }); + + test('encrypts and decrypts files correctly', () { + final testData = Uint8List.fromList(List.generate(1000, (i) => i % 256)); + final key = Uint8List.fromList(List.generate(32, (i) => i)); + + final encryptionResult = EncryptionService.encryptChaCha20Poly1305( + key: key, + plaintext: testData, + ); + + expect(encryptionResult.encryptedData.length, equals(testData.length)); + expect(encryptionResult.nonce.length, equals(12)); + expect(encryptionResult.authTag.length, equals(16)); + + final decryptedData = EncryptionService.decryptChaCha20Poly1305( + key: key, + nonce: encryptionResult.nonce, + encryptedData: encryptionResult.encryptedData, + authTag: encryptionResult.authTag, + ); + + expect(decryptedData, equals(testData)); + }); + + test('fails decryption with wrong shared key', () { + final testData = Uint8List.fromList([1, 2, 3, 4, 5]); + final correctKey = Uint8List.fromList(List.generate(32, (i) => i)); + final wrongKey = Uint8List.fromList(List.generate(32, (i) => 255 - i)); + + final encryptionResult = EncryptionService.encryptChaCha20Poly1305( + key: correctKey, + plaintext: testData, + ); + + expect( + () => EncryptionService.decryptChaCha20Poly1305( + key: wrongKey, + nonce: encryptionResult.nonce, + encryptedData: encryptionResult.encryptedData, + authTag: encryptionResult.authTag, + ), + throwsA(isA()), + ); + }); + + test('generates unique nonces for each encryption', () { + final testData = Uint8List.fromList([1, 2, 3, 4, 5]); + final key = Uint8List.fromList(List.generate(32, (i) => i)); + final nonces = {}; + + for (int i = 0; i < 100; i++) { + final result = EncryptionService.encryptChaCha20Poly1305( + key: key, + plaintext: testData, + ); + final nonceHex = result.nonce.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + nonces.add(nonceHex); + } + + expect(nonces.length, equals(100)); + }); + + test('handles blob format correctly', () { + final testData = Uint8List.fromList([1, 2, 3, 4, 5]); + final key = Uint8List.fromList(List.generate(32, (i) => i)); + + final encryptionResult = EncryptionService.encryptChaCha20Poly1305( + key: key, + plaintext: testData, + ); + + final blob = encryptionResult.toBlob(); + final reconstructed = EncryptionResult.fromBlob(blob); + + expect(reconstructed.nonce, equals(encryptionResult.nonce)); + expect(reconstructed.encryptedData, equals(encryptionResult.encryptedData)); + expect(reconstructed.authTag, equals(encryptionResult.authTag)); + }); + }); + + group('File Validation', () { + test('accepts files within 25MB limit', () { + final file24MB = Uint8List(24 * 1024 * 1024); + expect(file24MB.length, lessThanOrEqualTo(FileValidationService.maxFileSize)); + + final file25MB = Uint8List(25 * 1024 * 1024); + expect(file25MB.length, lessThanOrEqualTo(FileValidationService.maxFileSize)); + }); + + test('rejects oversized files', () { + final file26MB = Uint8List(26 * 1024 * 1024); + expect(file26MB.length, greaterThan(FileValidationService.maxFileSize)); + }); + + test('validates supported extensions', () { + final supportedExtensions = FileValidationService.getSupportedExtensions(); + + expect(supportedExtensions, contains('.jpg')); + expect(supportedExtensions, contains('.png')); + expect(supportedExtensions, contains('.pdf')); + expect(supportedExtensions, contains('.mp4')); + expect(supportedExtensions, contains('.doc')); + expect(supportedExtensions, isNot(contains('.exe'))); + }); + + test('checks file type support by filename', () { + expect(FileValidationService.isFileTypeSupported('test.jpg'), isTrue); + expect(FileValidationService.isFileTypeSupported('test.png'), isTrue); + expect(FileValidationService.isFileTypeSupported('test.pdf'), isTrue); + expect(FileValidationService.isFileTypeSupported('test.exe'), isFalse); + expect(FileValidationService.isFileTypeSupported('malware.bat'), isFalse); + }); + + test('checks file type support by MIME type', () { + expect(FileValidationService.isFileTypeSupported('test.unknown', mimeType: 'image/jpeg'), isTrue); + expect(FileValidationService.isFileTypeSupported('test.unknown', mimeType: 'application/pdf'), isTrue); + expect(FileValidationService.isFileTypeSupported('test.unknown', mimeType: 'application/x-executable'), isFalse); + }); + + test('handles zero-byte files', () { + final emptyFile = Uint8List(0); + expect(emptyFile.length, equals(0)); + expect(emptyFile.length, lessThan(FileValidationService.maxFileSize)); + }); + }); + + group('Media Validation', () { + test('supported image types are defined', () { + final jpegType = SupportedImageType.jpeg; + final pngType = SupportedImageType.png; + + expect(jpegType.mimeType, equals('image/jpeg')); + expect(jpegType.extension, equals('jpg')); + expect(pngType.mimeType, equals('image/png')); + expect(pngType.extension, equals('png')); + }); + + test('validates image format requirements', () { + final testImageData = _createTestJpeg(); + expect(testImageData.length, greaterThan(10)); + expect(testImageData[0], equals(0xFF)); + expect(testImageData[1], equals(0xD8)); + }); + + test('handles different image formats', () { + final jpegData = _createTestJpeg(); + final pngData = _createTestPng(); + + expect(jpegData[0], equals(0xFF)); + expect(jpegData[1], equals(0xD8)); + expect(pngData[0], equals(0x89)); + expect(pngData[1], equals(0x50)); + }); + }); + + group('Blossom Upload', () { + test('creates proper HTTP authorization header', () async { + final testData = Uint8List.fromList([1, 2, 3, 4, 5]); + const mimeType = 'image/jpeg'; + + when(mockBlossomClient.uploadImage( + imageData: testData, + mimeType: mimeType, + )).thenAnswer((_) async => 'https://blossom.server.com/hash123'); + + final result = await mockBlossomClient.uploadImage( + imageData: testData, + mimeType: mimeType, + ); + + expect(result, equals('https://blossom.server.com/hash123')); + verify(mockBlossomClient.uploadImage( + imageData: testData, + mimeType: mimeType, + )).called(1); + }); + + test('handles upload success response', () async { + final blossomClient = BlossomClient(serverUrl: 'https://blossom.server.com'); + final testData = Uint8List.fromList([1, 2, 3, 4, 5]); + + // We can't easily test the actual HTTP without mocking http.Client + // This test verifies the URL construction logic + expect(blossomClient.serverUrl, equals('https://blossom.server.com')); + }); + + test('handles upload error responses', () async { + const mimeType = 'image/jpeg'; + + when(mockBlossomClient.uploadImage( + imageData: anyNamed('imageData'), + mimeType: mimeType, + )).thenThrow(BlossomException('Upload failed: 413 - File too large')); + + expect( + () => mockBlossomClient.uploadImage( + imageData: Uint8List.fromList([1, 2, 3, 4, 5]), + mimeType: mimeType + ), + throwsA(isA()), + ); + }); + }); + + group('Gift Wrap Message Creation', () { + test('creates file message with correct metadata', () { + final fileMessage = { + 'type': 'file_encrypted', + 'file_type': 'document', + 'blossom_url': 'https://blossom.server.com/hash123', + 'nonce': 'abcdef123456789012345678', + 'mime_type': 'application/pdf', + 'original_size': 12345, + 'filename': 'document.pdf', + 'encrypted_size': 12389, + }; + + expect(fileMessage['type'], equals('file_encrypted')); + expect(fileMessage['nonce'], isA()); + expect(fileMessage['blossom_url'], startsWith('https://')); + expect(fileMessage.containsKey('encryption_key'), isFalse); + }); + + test('includes nonce but not encryption key', () { + final imageMessage = { + 'type': 'image_encrypted', + 'file_type': 'image', + 'blossom_url': 'https://blossom.server.com/hash456', + 'nonce': '1234567890abcdef12345678', + 'mime_type': 'image/jpeg', + 'original_size': 54321, + 'filename': 'photo.jpg', + 'encrypted_size': 54365, + }; + + expect(imageMessage.containsKey('nonce'), isTrue); + expect(imageMessage.containsKey('encryption_key'), isFalse); + expect(imageMessage.containsKey('shared_key'), isFalse); + }); + + test('formats JSON correctly for different file types', () { + final videoMessage = { + 'type': 'file_encrypted', + 'file_type': 'video', + 'blossom_url': 'https://blossom.server.com/hash789', + 'nonce': 'fedcba987654321098765432', + 'mime_type': 'video/mp4', + 'original_size': 9876543, + 'filename': 'video.mp4', + 'encrypted_size': 9876587, + }; + + final jsonString = jsonEncode(videoMessage); + final decoded = jsonDecode(jsonString); + + expect(decoded['type'], equals('file_encrypted')); + expect(decoded['file_type'], equals('video')); + expect(decoded['mime_type'], equals('video/mp4')); + }); + }); + + group('File Download & Decryption', () { + test('download service can be mocked', () { + expect(mockDownloadService, isNotNull); + expect(mockDownloadService, isA()); + }); + + test('verifies file integrity after decryption', () { + final testData = Uint8List.fromList([1, 2, 3, 4, 5]); + final key = Uint8List.fromList(List.generate(32, (i) => i)); + + final encrypted = EncryptionService.encryptChaCha20Poly1305( + key: key, + plaintext: testData, + ); + + final decrypted = EncryptionService.decryptChaCha20Poly1305( + key: key, + nonce: encrypted.nonce, + encryptedData: encrypted.encryptedData, + authTag: encrypted.authTag, + ); + + expect(decrypted.length, equals(testData.length)); + expect(decrypted, equals(testData)); + }); + }); + + group('Complete File Sharing Flows', () { + test('image upload service can be mocked', () { + expect(mockImageUploadService, isNotNull); + expect(mockImageUploadService, isA()); + }); + + test('file upload service can be mocked', () { + expect(mockFileUploadService, isNotNull); + expect(mockFileUploadService, isA()); + }); + + test('handles file sharing between different sessions', () { + // Create session 1 with different derived keys + final session1_tradeKey = keyDerivator.derivePrivateKey(extendedPrivKey, 10); + final session1_peerKey = keyDerivator.derivePrivateKey(extendedPrivKey, 11); + final session1_peerPublic = keyDerivator.privateToPublicKey(session1_peerKey); + + final session1 = Session( + masterKey: NostrKeyPairs(private: masterPrivKey), + tradeKey: NostrKeyPairs(private: session1_tradeKey), + keyIndex: 10, + fullPrivacy: false, + startTime: DateTime.now(), + orderId: 'order-1', + role: Role.buyer, + peer: Peer(publicKey: session1_peerPublic), + ); + + // Create session 2 with different derived keys + final session2_tradeKey = keyDerivator.derivePrivateKey(extendedPrivKey, 20); + final session2_peerKey = keyDerivator.derivePrivateKey(extendedPrivKey, 21); + final session2_peerPublic = keyDerivator.privateToPublicKey(session2_peerKey); + + final session2 = Session( + masterKey: NostrKeyPairs(private: masterPrivKey), + tradeKey: NostrKeyPairs(private: session2_tradeKey), + keyIndex: 20, + fullPrivacy: false, + startTime: DateTime.now(), + orderId: 'order-2', + role: Role.seller, + peer: Peer(publicKey: session2_peerPublic), + ); + + expect(session1.sharedKey!.private, isNot(equals(session2.sharedKey!.private))); + }); + }); + + group('Security & Edge Cases', () { + test('prevents cross-session file decryption', () { + final testData = Uint8List.fromList([1, 2, 3, 4, 5]); + final key1 = Uint8List.fromList(List.generate(32, (i) => i)); + final key2 = Uint8List.fromList(List.generate(32, (i) => 255 - i)); + + final encrypted = EncryptionService.encryptChaCha20Poly1305( + key: key1, + plaintext: testData, + ); + + expect( + () => EncryptionService.decryptChaCha20Poly1305( + key: key2, + nonce: encrypted.nonce, + encryptedData: encrypted.encryptedData, + authTag: encrypted.authTag, + ), + throwsA(isA()), + ); + }); + + test('handles corrupted encryption data', () { + final testData = Uint8List.fromList([1, 2, 3, 4, 5]); + final key = Uint8List.fromList(List.generate(32, (i) => i)); + + final encrypted = EncryptionService.encryptChaCha20Poly1305( + key: key, + plaintext: testData, + ); + + // Corrupt the auth tag + final corruptedAuthTag = Uint8List.fromList(encrypted.authTag); + corruptedAuthTag[0] = (corruptedAuthTag[0] + 1) % 256; + + bool threwEncryptionException = false; + try { + EncryptionService.decryptChaCha20Poly1305( + key: key, + nonce: encrypted.nonce, + encryptedData: encrypted.encryptedData, + authTag: corruptedAuthTag, + ); + } catch (e) { + threwEncryptionException = e is EncryptionException; + } + + expect(threwEncryptionException, isTrue); + }); + + test('validates session isolation', () { + final orderId1 = 'order-123'; + final orderId2 = 'order-456'; + + expect(orderId1, isNot(equals(orderId2))); + expect(testSession.orderId, equals('test-order-id')); + expect(testSession.orderId, isNot(equals(orderId1))); + }); + + test('handles malformed blob data', () { + final tooSmallBlob = Uint8List(10); + + expect( + () => EncryptionResult.fromBlob(tooSmallBlob), + throwsA(isA()), + ); + }); + + test('validates key format and length', () { + final validKey = Uint8List.fromList(List.generate(32, (i) => i)); + final invalidKey = Uint8List(16); + final testData = Uint8List.fromList([1, 2, 3, 4, 5]); + + // Valid key should work + final result = EncryptionService.encryptChaCha20Poly1305( + key: validKey, + plaintext: testData, + ); + expect(result.encryptedData, isNotNull); + expect(result.nonce.length, equals(12)); + expect(result.authTag.length, equals(16)); + + // Invalid key should throw ArgumentError + bool threwArgumentError = false; + try { + EncryptionService.encryptChaCha20Poly1305( + key: invalidKey, + plaintext: testData, + ); + } catch (e) { + threwArgumentError = e is ArgumentError; + } + + expect(threwArgumentError, isTrue); + }); + }); + }); +} + +// Helper functions for test data creation +Uint8List _createTestJpeg() { + // Minimal valid JPEG header + return Uint8List.fromList([ + 0xFF, 0xD8, // JPEG SOI marker + 0xFF, 0xE0, // JFIF APP0 marker + 0x00, 0x10, // Length + 0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0" + 0x01, 0x01, // Version 1.1 + 0x01, 0x00, 0x48, 0x00, 0x48, // Aspect ratio and resolution + 0x00, 0x00, // No thumbnail + 0xFF, 0xD9, // JPEG EOI marker + ]); +} + +Uint8List _createTestPng() { + // Minimal valid PNG header + return Uint8List.fromList([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR chunk length + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x01, // Width: 1 + 0x00, 0x00, 0x00, 0x01, // Height: 1 + 0x08, 0x06, 0x00, 0x00, 0x00, // Bit depth, color type, etc. + 0x1F, 0x15, 0xC4, 0x89, // CRC + ]); +} + diff --git a/test/mocks.dart b/test/mocks.dart index 56a35e2f..eb4bf0b8 100644 --- a/test/mocks.dart +++ b/test/mocks.dart @@ -19,6 +19,14 @@ import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; import 'package:mostro_mobile/features/order/models/order_state.dart'; import 'package:mostro_mobile/features/order/notfiers/order_notifier.dart'; +import 'package:mostro_mobile/services/blossom_client.dart'; +import 'package:mostro_mobile/services/encryption_service.dart'; +import 'package:mostro_mobile/services/encrypted_file_upload_service.dart'; +import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; +import 'package:mostro_mobile/services/file_validation_service.dart'; +import 'package:mostro_mobile/services/media_validation_service.dart'; +import 'package:mostro_mobile/services/blossom_download_service.dart'; +import 'package:mostro_mobile/features/chat/notifiers/chat_room_notifier.dart'; import 'package:sembast/sembast.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -40,6 +48,11 @@ import 'mocks.mocks.dart'; OrderState, OrderNotifier, NostrKeyPairs, + BlossomClient, + EncryptedFileUploadService, + EncryptedImageUploadService, + BlossomDownloadService, + ChatRoomNotifier, ]) // Custom mock for SettingsNotifier that returns a specific Settings object From 375ea016b31ffa3a2bf9bb1d1fddd3a78a66a83e Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:41:21 -0600 Subject: [PATCH 20/30] fix tests --- test/features/chat/file_messaging_test.dart | 33 ++++++++++----------- test/mocks.dart | 3 -- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/test/features/chat/file_messaging_test.dart b/test/features/chat/file_messaging_test.dart index 394c1310..1582eb39 100644 --- a/test/features/chat/file_messaging_test.dart +++ b/test/features/chat/file_messaging_test.dart @@ -88,13 +88,13 @@ void main() { group('Encryption & Key Management', () { test('derives identical shared keys for both parties', () { // Use valid keys from key derivation (same pattern as mostro_service_test.dart) - final userA_privateKey = tradePrivKey; - final userA_publicKey = keyDerivator.privateToPublicKey(userA_privateKey); - final userB_privateKey = peerPrivKey; - final userB_publicKey = peerPublicKey; + final userAPrivateKey = tradePrivKey; + final userAPublicKey = keyDerivator.privateToPublicKey(userAPrivateKey); + final userBPrivateKey = peerPrivKey; + final userBPublicKey = peerPublicKey; - final sharedKeyA = NostrUtils.computeSharedKey(userA_privateKey, userB_publicKey); - final sharedKeyB = NostrUtils.computeSharedKey(userB_privateKey, userA_publicKey); + final sharedKeyA = NostrUtils.computeSharedKey(userAPrivateKey, userBPublicKey); + final sharedKeyB = NostrUtils.computeSharedKey(userBPrivateKey, userAPublicKey); expect(sharedKeyA.private, equals(sharedKeyB.private)); }); @@ -292,7 +292,6 @@ void main() { test('handles upload success response', () async { final blossomClient = BlossomClient(serverUrl: 'https://blossom.server.com'); - final testData = Uint8List.fromList([1, 2, 3, 4, 5]); // We can't easily test the actual HTTP without mocking http.Client // This test verifies the URL construction logic @@ -414,35 +413,35 @@ void main() { test('handles file sharing between different sessions', () { // Create session 1 with different derived keys - final session1_tradeKey = keyDerivator.derivePrivateKey(extendedPrivKey, 10); - final session1_peerKey = keyDerivator.derivePrivateKey(extendedPrivKey, 11); - final session1_peerPublic = keyDerivator.privateToPublicKey(session1_peerKey); + final session1TradeKey = keyDerivator.derivePrivateKey(extendedPrivKey, 10); + final session1PeerKey = keyDerivator.derivePrivateKey(extendedPrivKey, 11); + final session1PeerPublic = keyDerivator.privateToPublicKey(session1PeerKey); final session1 = Session( masterKey: NostrKeyPairs(private: masterPrivKey), - tradeKey: NostrKeyPairs(private: session1_tradeKey), + tradeKey: NostrKeyPairs(private: session1TradeKey), keyIndex: 10, fullPrivacy: false, startTime: DateTime.now(), orderId: 'order-1', role: Role.buyer, - peer: Peer(publicKey: session1_peerPublic), + peer: Peer(publicKey: session1PeerPublic), ); // Create session 2 with different derived keys - final session2_tradeKey = keyDerivator.derivePrivateKey(extendedPrivKey, 20); - final session2_peerKey = keyDerivator.derivePrivateKey(extendedPrivKey, 21); - final session2_peerPublic = keyDerivator.privateToPublicKey(session2_peerKey); + final session2TradeKey = keyDerivator.derivePrivateKey(extendedPrivKey, 20); + final session2PeerKey = keyDerivator.derivePrivateKey(extendedPrivKey, 21); + final session2PeerPublic = keyDerivator.privateToPublicKey(session2PeerKey); final session2 = Session( masterKey: NostrKeyPairs(private: masterPrivKey), - tradeKey: NostrKeyPairs(private: session2_tradeKey), + tradeKey: NostrKeyPairs(private: session2TradeKey), keyIndex: 20, fullPrivacy: false, startTime: DateTime.now(), orderId: 'order-2', role: Role.seller, - peer: Peer(publicKey: session2_peerPublic), + peer: Peer(publicKey: session2PeerPublic), ); expect(session1.sharedKey!.private, isNot(equals(session2.sharedKey!.private))); diff --git a/test/mocks.dart b/test/mocks.dart index eb4bf0b8..1c9fadc2 100644 --- a/test/mocks.dart +++ b/test/mocks.dart @@ -20,11 +20,8 @@ import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; import 'package:mostro_mobile/features/order/models/order_state.dart'; import 'package:mostro_mobile/features/order/notfiers/order_notifier.dart'; import 'package:mostro_mobile/services/blossom_client.dart'; -import 'package:mostro_mobile/services/encryption_service.dart'; import 'package:mostro_mobile/services/encrypted_file_upload_service.dart'; import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; -import 'package:mostro_mobile/services/file_validation_service.dart'; -import 'package:mostro_mobile/services/media_validation_service.dart'; import 'package:mostro_mobile/services/blossom_download_service.dart'; import 'package:mostro_mobile/features/chat/notifiers/chat_room_notifier.dart'; import 'package:sembast/sembast.dart'; From 46c06681a7f28e746c228c09d36235937ca2eebb Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Wed, 3 Dec 2025 19:12:03 -0600 Subject: [PATCH 21/30] Generated mocks for CI compatibility and coderabbit suggestion --- test/features/chat/file_messaging_test.dart | 11 +- test/mocks.mocks.dart | 629 +++++++++++++++++--- 2 files changed, 554 insertions(+), 86 deletions(-) diff --git a/test/features/chat/file_messaging_test.dart b/test/features/chat/file_messaging_test.dart index 1582eb39..c270bedf 100644 --- a/test/features/chat/file_messaging_test.dart +++ b/test/features/chat/file_messaging_test.dart @@ -269,7 +269,7 @@ void main() { }); group('Blossom Upload', () { - test('creates proper HTTP authorization header', () async { + test('delegates to BlossomClient uploadImage method', () async { final testData = Uint8List.fromList([1, 2, 3, 4, 5]); const mimeType = 'image/jpeg'; @@ -290,11 +290,10 @@ void main() { )).called(1); }); - test('handles upload success response', () async { + test('exposes configured server URL', () async { final blossomClient = BlossomClient(serverUrl: 'https://blossom.server.com'); - // We can't easily test the actual HTTP without mocking http.Client - // This test verifies the URL construction logic + // Verify URL construction logic expect(blossomClient.serverUrl, equals('https://blossom.server.com')); }); @@ -308,8 +307,8 @@ void main() { expect( () => mockBlossomClient.uploadImage( - imageData: Uint8List.fromList([1, 2, 3, 4, 5]), - mimeType: mimeType + imageData: Uint8List.fromList([1, 2, 3, 4, 5]), + mimeType: mimeType, ), throwsA(isA()), ); diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index 2d89bea4..3f3455a8 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -4,36 +4,47 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i5; +import 'dart:io' as _i34; +import 'dart:typed_data' as _i33; import 'package:dart_nostr/dart_nostr.dart' as _i3; -import 'package:dart_nostr/nostr/model/relay_informations.dart' as _i15; +import 'package:dart_nostr/nostr/model/relay_informations.dart' as _i18; import 'package:flutter_riverpod/flutter_riverpod.dart' as _i4; import 'package:logger/logger.dart' as _i13; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i16; -import 'package:mostro_mobile/data/enums.dart' as _i27; +import 'package:mockito/src/dummies.dart' as _i19; +import 'package:mostro_mobile/data/enums.dart' as _i30; import 'package:mostro_mobile/data/models.dart' as _i7; -import 'package:mostro_mobile/data/models/order.dart' as _i17; -import 'package:mostro_mobile/data/repositories/mostro_storage.dart' as _i24; +import 'package:mostro_mobile/data/models/chat_room.dart' as _i16; +import 'package:mostro_mobile/data/models/order.dart' as _i20; +import 'package:mostro_mobile/data/repositories/mostro_storage.dart' as _i27; import 'package:mostro_mobile/data/repositories/open_orders_repository.dart' - as _i19; -import 'package:mostro_mobile/data/repositories/session_storage.dart' as _i22; -import 'package:mostro_mobile/features/key_manager/key_manager.dart' as _i23; + as _i22; +import 'package:mostro_mobile/data/repositories/session_storage.dart' as _i25; +import 'package:mostro_mobile/features/chat/notifiers/chat_room_notifier.dart' + as _i36; +import 'package:mostro_mobile/features/key_manager/key_manager.dart' as _i26; import 'package:mostro_mobile/features/order/models/order_state.dart' as _i11; import 'package:mostro_mobile/features/order/notfiers/order_notifier.dart' - as _i28; -import 'package:mostro_mobile/features/relays/relay.dart' as _i25; + as _i31; +import 'package:mostro_mobile/features/relays/relay.dart' as _i28; import 'package:mostro_mobile/features/relays/relays_notifier.dart' as _i10; import 'package:mostro_mobile/features/settings/settings.dart' as _i2; import 'package:mostro_mobile/features/settings/settings_notifier.dart' as _i9; -import 'package:mostro_mobile/services/deep_link_service.dart' as _i18; +import 'package:mostro_mobile/services/blossom_client.dart' as _i32; +import 'package:mostro_mobile/services/blossom_download_service.dart' as _i35; +import 'package:mostro_mobile/services/deep_link_service.dart' as _i21; +import 'package:mostro_mobile/services/encrypted_file_upload_service.dart' + as _i14; +import 'package:mostro_mobile/services/encrypted_image_upload_service.dart' + as _i15; import 'package:mostro_mobile/services/mostro_service.dart' as _i12; -import 'package:mostro_mobile/services/nostr_service.dart' as _i14; +import 'package:mostro_mobile/services/nostr_service.dart' as _i17; import 'package:riverpod/src/internals.dart' as _i8; import 'package:sembast/sembast.dart' as _i6; -import 'package:sembast/src/api/transaction.dart' as _i21; -import 'package:shared_preferences/src/shared_preferences_async.dart' as _i20; -import 'package:state_notifier/state_notifier.dart' as _i26; +import 'package:sembast/src/api/transaction.dart' as _i24; +import 'package:shared_preferences/src/shared_preferences_async.dart' as _i23; +import 'package:state_notifier/state_notifier.dart' as _i29; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -247,10 +258,52 @@ class _FakeLogger_18 extends _i1.SmartFake implements _i13.Logger { ); } +class _FakeDuration_19 extends _i1.SmartFake implements Duration { + _FakeDuration_19( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeEncryptedFileUploadResult_20 extends _i1.SmartFake + implements _i14.EncryptedFileUploadResult { + _FakeEncryptedFileUploadResult_20( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeEncryptedImageUploadResult_21 extends _i1.SmartFake + implements _i15.EncryptedImageUploadResult { + _FakeEncryptedImageUploadResult_21( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeChatRoom_22 extends _i1.SmartFake implements _i16.ChatRoom { + _FakeChatRoom_22( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + /// A class which mocks [NostrService]. /// /// See the documentation for Mockito's code generation for more information. -class MockNostrService extends _i1.Mock implements _i14.NostrService { +class MockNostrService extends _i1.Mock implements _i17.NostrService { MockNostrService() { _i1.throwOnMissingStub(this); } @@ -292,14 +345,14 @@ class MockNostrService extends _i1.Mock implements _i14.NostrService { ) as _i5.Future); @override - _i5.Future<_i15.RelayInformations?> getRelayInfo(String? relayUrl) => + _i5.Future<_i18.RelayInformations?> getRelayInfo(String? relayUrl) => (super.noSuchMethod( Invocation.method( #getRelayInfo, [relayUrl], ), - returnValue: _i5.Future<_i15.RelayInformations?>.value(), - ) as _i5.Future<_i15.RelayInformations?>); + returnValue: _i5.Future<_i18.RelayInformations?>.value(), + ) as _i5.Future<_i18.RelayInformations?>); @override _i5.Future publishEvent(_i3.NostrEvent? event) => (super.noSuchMethod( @@ -382,7 +435,7 @@ class MockNostrService extends _i1.Mock implements _i14.NostrService { #getMostroPubKey, [], ), - returnValue: _i16.dummyValue( + returnValue: _i19.dummyValue( this, Invocation.method( #getMostroPubKey, @@ -461,7 +514,7 @@ class MockNostrService extends _i1.Mock implements _i14.NostrService { content, ], ), - returnValue: _i5.Future.value(_i16.dummyValue( + returnValue: _i5.Future.value(_i19.dummyValue( this, Invocation.method( #createRumor, @@ -492,7 +545,7 @@ class MockNostrService extends _i1.Mock implements _i14.NostrService { encryptedContent, ], ), - returnValue: _i5.Future.value(_i16.dummyValue( + returnValue: _i5.Future.value(_i19.dummyValue( this, Invocation.method( #createSeal, @@ -544,7 +597,7 @@ class MockNostrService extends _i1.Mock implements _i14.NostrService { ); @override - _i5.Future<_i17.Order?> fetchEventById( + _i5.Future<_i20.Order?> fetchEventById( String? eventId, [ List? specificRelays, ]) => @@ -556,11 +609,11 @@ class MockNostrService extends _i1.Mock implements _i14.NostrService { specificRelays, ], ), - returnValue: _i5.Future<_i17.Order?>.value(), - ) as _i5.Future<_i17.Order?>); + returnValue: _i5.Future<_i20.Order?>.value(), + ) as _i5.Future<_i20.Order?>); @override - _i5.Future<_i18.OrderInfo?> fetchOrderInfoByEventId( + _i5.Future<_i21.OrderInfo?> fetchOrderInfoByEventId( String? eventId, [ List? specificRelays, ]) => @@ -572,8 +625,8 @@ class MockNostrService extends _i1.Mock implements _i14.NostrService { specificRelays, ], ), - returnValue: _i5.Future<_i18.OrderInfo?>.value(), - ) as _i5.Future<_i18.OrderInfo?>); + returnValue: _i5.Future<_i21.OrderInfo?>.value(), + ) as _i5.Future<_i21.OrderInfo?>); } /// A class which mocks [MostroService]. @@ -759,7 +812,7 @@ class MockMostroService extends _i1.Mock implements _i12.MostroService { /// /// See the documentation for Mockito's code generation for more information. class MockOpenOrdersRepository extends _i1.Mock - implements _i19.OpenOrdersRepository { + implements _i22.OpenOrdersRepository { MockOpenOrdersRepository() { _i1.throwOnMissingStub(this); } @@ -861,7 +914,7 @@ class MockOpenOrdersRepository extends _i1.Mock /// See the documentation for Mockito's code generation for more information. // ignore: must_be_immutable class MockSharedPreferencesAsync extends _i1.Mock - implements _i20.SharedPreferencesAsync { + implements _i23.SharedPreferencesAsync { MockSharedPreferencesAsync() { _i1.throwOnMissingStub(this); } @@ -1067,7 +1120,7 @@ class MockDatabase extends _i1.Mock implements _i6.Database { @override String get path => (super.noSuchMethod( Invocation.getter(#path), - returnValue: _i16.dummyValue( + returnValue: _i19.dummyValue( this, Invocation.getter(#path), ), @@ -1075,14 +1128,14 @@ class MockDatabase extends _i1.Mock implements _i6.Database { @override _i5.Future transaction( - _i5.FutureOr Function(_i21.Transaction)? action) => + _i5.FutureOr Function(_i24.Transaction)? action) => (super.noSuchMethod( Invocation.method( #transaction, [action], ), - returnValue: _i16.ifNotNull( - _i16.dummyValueOrNull( + returnValue: _i19.ifNotNull( + _i19.dummyValueOrNull( this, Invocation.method( #transaction, @@ -1113,7 +1166,7 @@ class MockDatabase extends _i1.Mock implements _i6.Database { /// A class which mocks [SessionStorage]. /// /// See the documentation for Mockito's code generation for more information. -class MockSessionStorage extends _i1.Mock implements _i22.SessionStorage { +class MockSessionStorage extends _i1.Mock implements _i25.SessionStorage { MockSessionStorage() { _i1.throwOnMissingStub(this); } @@ -1376,7 +1429,7 @@ class MockSessionStorage extends _i1.Mock implements _i22.SessionStorage { /// A class which mocks [KeyManager]. /// /// See the documentation for Mockito's code generation for more information. -class MockKeyManager extends _i1.Mock implements _i23.KeyManager { +class MockKeyManager extends _i1.Mock implements _i26.KeyManager { MockKeyManager() { _i1.throwOnMissingStub(this); } @@ -1536,7 +1589,7 @@ class MockKeyManager extends _i1.Mock implements _i23.KeyManager { /// A class which mocks [MostroStorage]. /// /// See the documentation for Mockito's code generation for more information. -class MockMostroStorage extends _i1.Mock implements _i24.MostroStorage { +class MockMostroStorage extends _i1.Mock implements _i27.MostroStorage { MockMostroStorage() { _i1.throwOnMissingStub(this); } @@ -1927,7 +1980,7 @@ class MockSettings extends _i1.Mock implements _i2.Settings { @override String get mostroPublicKey => (super.noSuchMethod( Invocation.getter(#mostroPublicKey), - returnValue: _i16.dummyValue( + returnValue: _i19.dummyValue( this, Invocation.getter(#mostroPublicKey), ), @@ -2027,7 +2080,7 @@ class MockRef extends _i1.Mock #refresh, [provider], ), - returnValue: _i16.dummyValue( + returnValue: _i19.dummyValue( this, Invocation.method( #refresh, @@ -2134,7 +2187,7 @@ class MockRef extends _i1.Mock #read, [provider], ), - returnValue: _i16.dummyValue( + returnValue: _i19.dummyValue( this, Invocation.method( #read, @@ -2158,7 +2211,7 @@ class MockRef extends _i1.Mock #watch, [provider], ), - returnValue: _i16.dummyValue( + returnValue: _i19.dummyValue( this, Invocation.method( #watch, @@ -2254,7 +2307,7 @@ class MockProviderSubscription extends _i1.Mock #read, [], ), - returnValue: _i16.dummyValue( + returnValue: _i19.dummyValue( this, Invocation.method( #read, @@ -2306,10 +2359,10 @@ class MockRelaysNotifier extends _i1.Mock implements _i10.RelaysNotifier { ) as List); @override - List<_i25.MostroRelayInfo> get mostroRelaysWithStatus => (super.noSuchMethod( + List<_i28.MostroRelayInfo> get mostroRelaysWithStatus => (super.noSuchMethod( Invocation.getter(#mostroRelaysWithStatus), - returnValue: <_i25.MostroRelayInfo>[], - ) as List<_i25.MostroRelayInfo>); + returnValue: <_i28.MostroRelayInfo>[], + ) as List<_i28.MostroRelayInfo>); @override bool get mounted => (super.noSuchMethod( @@ -2318,22 +2371,22 @@ class MockRelaysNotifier extends _i1.Mock implements _i10.RelaysNotifier { ) as bool); @override - _i5.Stream> get stream => (super.noSuchMethod( + _i5.Stream> get stream => (super.noSuchMethod( Invocation.getter(#stream), - returnValue: _i5.Stream>.empty(), - ) as _i5.Stream>); + returnValue: _i5.Stream>.empty(), + ) as _i5.Stream>); @override - List<_i25.Relay> get state => (super.noSuchMethod( + List<_i28.Relay> get state => (super.noSuchMethod( Invocation.getter(#state), - returnValue: <_i25.Relay>[], - ) as List<_i25.Relay>); + returnValue: <_i28.Relay>[], + ) as List<_i28.Relay>); @override - List<_i25.Relay> get debugState => (super.noSuchMethod( + List<_i28.Relay> get debugState => (super.noSuchMethod( Invocation.getter(#debugState), - returnValue: <_i25.Relay>[], - ) as List<_i25.Relay>); + returnValue: <_i28.Relay>[], + ) as List<_i28.Relay>); @override bool get hasListeners => (super.noSuchMethod( @@ -2351,7 +2404,7 @@ class MockRelaysNotifier extends _i1.Mock implements _i10.RelaysNotifier { ); @override - set state(List<_i25.Relay>? value) => super.noSuchMethod( + set state(List<_i28.Relay>? value) => super.noSuchMethod( Invocation.setter( #state, value, @@ -2360,7 +2413,7 @@ class MockRelaysNotifier extends _i1.Mock implements _i10.RelaysNotifier { ); @override - _i5.Future addRelay(_i25.Relay? relay) => (super.noSuchMethod( + _i5.Future addRelay(_i28.Relay? relay) => (super.noSuchMethod( Invocation.method( #addRelay, [relay], @@ -2371,8 +2424,8 @@ class MockRelaysNotifier extends _i1.Mock implements _i10.RelaysNotifier { @override _i5.Future updateRelay( - _i25.Relay? oldRelay, - _i25.Relay? updatedRelay, + _i28.Relay? oldRelay, + _i28.Relay? updatedRelay, ) => (super.noSuchMethod( Invocation.method( @@ -2539,8 +2592,8 @@ class MockRelaysNotifier extends _i1.Mock implements _i10.RelaysNotifier { @override bool updateShouldNotify( - List<_i25.Relay>? old, - List<_i25.Relay>? current, + List<_i28.Relay>? old, + List<_i28.Relay>? current, ) => (super.noSuchMethod( Invocation.method( @@ -2555,7 +2608,7 @@ class MockRelaysNotifier extends _i1.Mock implements _i10.RelaysNotifier { @override _i4.RemoveListener addListener( - _i26.Listener>? listener, { + _i29.Listener>? listener, { bool? fireImmediately = true, }) => (super.noSuchMethod( @@ -2577,22 +2630,22 @@ class MockOrderState extends _i1.Mock implements _i11.OrderState { } @override - _i27.Status get status => (super.noSuchMethod( + _i30.Status get status => (super.noSuchMethod( Invocation.getter(#status), - returnValue: _i27.Status.active, - ) as _i27.Status); + returnValue: _i30.Status.active, + ) as _i30.Status); @override - _i27.Action get action => (super.noSuchMethod( + _i30.Action get action => (super.noSuchMethod( Invocation.getter(#action), - returnValue: _i27.Action.newOrder, - ) as _i27.Action); + returnValue: _i30.Action.newOrder, + ) as _i30.Action); @override _i11.OrderState copyWith({ - _i27.Status? status, - _i27.Action? action, - _i17.Order? order, + _i30.Status? status, + _i30.Action? action, + _i20.Order? order, _i7.PaymentRequest? paymentRequest, _i7.CantDo? cantDo, _i7.Dispute? dispute, @@ -2650,19 +2703,19 @@ class MockOrderState extends _i1.Mock implements _i11.OrderState { ) as _i11.OrderState); @override - List<_i27.Action> getActions(_i27.Role? role) => (super.noSuchMethod( + List<_i30.Action> getActions(_i30.Role? role) => (super.noSuchMethod( Invocation.method( #getActions, [role], ), - returnValue: <_i27.Action>[], - ) as List<_i27.Action>); + returnValue: <_i30.Action>[], + ) as List<_i30.Action>); } /// A class which mocks [OrderNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockOrderNotifier extends _i1.Mock implements _i28.OrderNotifier { +class MockOrderNotifier extends _i1.Mock implements _i31.OrderNotifier { MockOrderNotifier() { _i1.throwOnMissingStub(this); } @@ -2688,7 +2741,7 @@ class MockOrderNotifier extends _i1.Mock implements _i28.OrderNotifier { @override String get orderId => (super.noSuchMethod( Invocation.getter(#orderId), - returnValue: _i16.dummyValue( + returnValue: _i19.dummyValue( this, Invocation.getter(#orderId), ), @@ -2982,7 +3035,7 @@ class MockOrderNotifier extends _i1.Mock implements _i28.OrderNotifier { @override void sendNotification( - _i27.Action? action, { + _i30.Action? action, { Map? values, bool? isTemporary = false, String? eventId, @@ -3018,7 +3071,7 @@ class MockOrderNotifier extends _i1.Mock implements _i28.OrderNotifier { @override _i4.RemoveListener addListener( - _i26.Listener<_i11.OrderState>? listener, { + _i29.Listener<_i11.OrderState>? listener, { bool? fireImmediately = true, }) => (super.noSuchMethod( @@ -3042,7 +3095,7 @@ class MockNostrKeyPairs extends _i1.Mock implements _i3.NostrKeyPairs { @override String get private => (super.noSuchMethod( Invocation.getter(#private), - returnValue: _i16.dummyValue( + returnValue: _i19.dummyValue( this, Invocation.getter(#private), ), @@ -3051,7 +3104,7 @@ class MockNostrKeyPairs extends _i1.Mock implements _i3.NostrKeyPairs { @override String get public => (super.noSuchMethod( Invocation.getter(#public), - returnValue: _i16.dummyValue( + returnValue: _i19.dummyValue( this, Invocation.getter(#public), ), @@ -3078,7 +3131,7 @@ class MockNostrKeyPairs extends _i1.Mock implements _i3.NostrKeyPairs { #sign, [message], ), - returnValue: _i16.dummyValue( + returnValue: _i19.dummyValue( this, Invocation.method( #sign, @@ -3087,3 +3140,419 @@ class MockNostrKeyPairs extends _i1.Mock implements _i3.NostrKeyPairs { ), ) as String); } + +/// A class which mocks [BlossomClient]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockBlossomClient extends _i1.Mock implements _i32.BlossomClient { + MockBlossomClient() { + _i1.throwOnMissingStub(this); + } + + @override + String get serverUrl => (super.noSuchMethod( + Invocation.getter(#serverUrl), + returnValue: _i19.dummyValue( + this, + Invocation.getter(#serverUrl), + ), + ) as String); + + @override + Duration get timeout => (super.noSuchMethod( + Invocation.getter(#timeout), + returnValue: _FakeDuration_19( + this, + Invocation.getter(#timeout), + ), + ) as Duration); + + @override + _i5.Future uploadImage({ + required _i33.Uint8List? imageData, + required String? mimeType, + }) => + (super.noSuchMethod( + Invocation.method( + #uploadImage, + [], + { + #imageData: imageData, + #mimeType: mimeType, + }, + ), + returnValue: _i5.Future.value(_i19.dummyValue( + this, + Invocation.method( + #uploadImage, + [], + { + #imageData: imageData, + #mimeType: mimeType, + }, + ), + )), + ) as _i5.Future); +} + +/// A class which mocks [EncryptedFileUploadService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockEncryptedFileUploadService extends _i1.Mock + implements _i14.EncryptedFileUploadService { + MockEncryptedFileUploadService() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future<_i14.EncryptedFileUploadResult> uploadEncryptedFile({ + required _i34.File? file, + required _i33.Uint8List? sharedKey, + }) => + (super.noSuchMethod( + Invocation.method( + #uploadEncryptedFile, + [], + { + #file: file, + #sharedKey: sharedKey, + }, + ), + returnValue: _i5.Future<_i14.EncryptedFileUploadResult>.value( + _FakeEncryptedFileUploadResult_20( + this, + Invocation.method( + #uploadEncryptedFile, + [], + { + #file: file, + #sharedKey: sharedKey, + }, + ), + )), + ) as _i5.Future<_i14.EncryptedFileUploadResult>); + + @override + _i5.Future<_i33.Uint8List> downloadAndDecryptFile({ + required String? blossomUrl, + required _i33.Uint8List? sharedKey, + }) => + (super.noSuchMethod( + Invocation.method( + #downloadAndDecryptFile, + [], + { + #blossomUrl: blossomUrl, + #sharedKey: sharedKey, + }, + ), + returnValue: _i5.Future<_i33.Uint8List>.value(_i33.Uint8List(0)), + ) as _i5.Future<_i33.Uint8List>); +} + +/// A class which mocks [EncryptedImageUploadService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockEncryptedImageUploadService extends _i1.Mock + implements _i15.EncryptedImageUploadService { + MockEncryptedImageUploadService() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future<_i15.EncryptedImageUploadResult> uploadEncryptedImage({ + required _i34.File? imageFile, + required _i33.Uint8List? sharedKey, + String? filename, + }) => + (super.noSuchMethod( + Invocation.method( + #uploadEncryptedImage, + [], + { + #imageFile: imageFile, + #sharedKey: sharedKey, + #filename: filename, + }, + ), + returnValue: _i5.Future<_i15.EncryptedImageUploadResult>.value( + _FakeEncryptedImageUploadResult_21( + this, + Invocation.method( + #uploadEncryptedImage, + [], + { + #imageFile: imageFile, + #sharedKey: sharedKey, + #filename: filename, + }, + ), + )), + ) as _i5.Future<_i15.EncryptedImageUploadResult>); + + @override + _i5.Future<_i33.Uint8List> downloadAndDecryptImage({ + required String? blossomUrl, + required String? nonceHex, + required _i33.Uint8List? sharedKey, + }) => + (super.noSuchMethod( + Invocation.method( + #downloadAndDecryptImage, + [], + { + #blossomUrl: blossomUrl, + #nonceHex: nonceHex, + #sharedKey: sharedKey, + }, + ), + returnValue: _i5.Future<_i33.Uint8List>.value(_i33.Uint8List(0)), + ) as _i5.Future<_i33.Uint8List>); +} + +/// A class which mocks [BlossomDownloadService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockBlossomDownloadService extends _i1.Mock + implements _i35.BlossomDownloadService { + MockBlossomDownloadService() { + _i1.throwOnMissingStub(this); + } +} + +/// A class which mocks [ChatRoomNotifier]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockChatRoomNotifier extends _i1.Mock implements _i36.ChatRoomNotifier { + MockChatRoomNotifier() { + _i1.throwOnMissingStub(this); + } + + @override + String get orderId => (super.noSuchMethod( + Invocation.getter(#orderId), + returnValue: _i19.dummyValue( + this, + Invocation.getter(#orderId), + ), + ) as String); + + @override + _i4.Ref get ref => (super.noSuchMethod( + Invocation.getter(#ref), + returnValue: _FakeRef_3( + this, + Invocation.getter(#ref), + ), + ) as _i4.Ref); + + @override + bool get isInitialized => (super.noSuchMethod( + Invocation.getter(#isInitialized), + returnValue: false, + ) as bool); + + @override + bool get mounted => (super.noSuchMethod( + Invocation.getter(#mounted), + returnValue: false, + ) as bool); + + @override + _i5.Stream<_i16.ChatRoom> get stream => (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i5.Stream<_i16.ChatRoom>.empty(), + ) as _i5.Stream<_i16.ChatRoom>); + + @override + _i16.ChatRoom get state => (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _FakeChatRoom_22( + this, + Invocation.getter(#state), + ), + ) as _i16.ChatRoom); + + @override + _i16.ChatRoom get debugState => (super.noSuchMethod( + Invocation.getter(#debugState), + returnValue: _FakeChatRoom_22( + this, + Invocation.getter(#debugState), + ), + ) as _i16.ChatRoom); + + @override + bool get hasListeners => (super.noSuchMethod( + Invocation.getter(#hasListeners), + returnValue: false, + ) as bool); + + @override + set onError(_i4.ErrorListener? _onError) => super.noSuchMethod( + Invocation.setter( + #onError, + _onError, + ), + returnValueForMissingStub: null, + ); + + @override + set state(_i16.ChatRoom? value) => super.noSuchMethod( + Invocation.setter( + #state, + value, + ), + returnValueForMissingStub: null, + ); + + @override + void reload() => super.noSuchMethod( + Invocation.method( + #reload, + [], + ), + returnValueForMissingStub: null, + ); + + @override + _i5.Future initialize() => (super.noSuchMethod( + Invocation.method( + #initialize, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + void subscribe() => super.noSuchMethod( + Invocation.method( + #subscribe, + [], + ), + returnValueForMissingStub: null, + ); + + @override + _i5.Future sendMessage(String? text) => (super.noSuchMethod( + Invocation.method( + #sendMessage, + [text], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future<_i33.Uint8List> getSharedKey() => (super.noSuchMethod( + Invocation.method( + #getSharedKey, + [], + ), + returnValue: _i5.Future<_i33.Uint8List>.value(_i33.Uint8List(0)), + ) as _i5.Future<_i33.Uint8List>); + + @override + void cacheDecryptedImage( + String? messageId, + _i33.Uint8List? imageData, + _i15.EncryptedImageUploadResult? metadata, + ) => + super.noSuchMethod( + Invocation.method( + #cacheDecryptedImage, + [ + messageId, + imageData, + metadata, + ], + ), + returnValueForMissingStub: null, + ); + + @override + _i33.Uint8List? getCachedImage(String? messageId) => + (super.noSuchMethod(Invocation.method( + #getCachedImage, + [messageId], + )) as _i33.Uint8List?); + + @override + _i15.EncryptedImageUploadResult? getImageMetadata(String? messageId) => + (super.noSuchMethod(Invocation.method( + #getImageMetadata, + [messageId], + )) as _i15.EncryptedImageUploadResult?); + + @override + void cacheDecryptedFile( + String? messageId, + _i33.Uint8List? fileData, + _i14.EncryptedFileUploadResult? metadata, + ) => + super.noSuchMethod( + Invocation.method( + #cacheDecryptedFile, + [ + messageId, + fileData, + metadata, + ], + ), + returnValueForMissingStub: null, + ); + + @override + _i33.Uint8List? getCachedFile(String? messageId) => + (super.noSuchMethod(Invocation.method( + #getCachedFile, + [messageId], + )) as _i33.Uint8List?); + + @override + _i14.EncryptedFileUploadResult? getFileMetadata(String? messageId) => + (super.noSuchMethod(Invocation.method( + #getFileMetadata, + [messageId], + )) as _i14.EncryptedFileUploadResult?); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); + + @override + bool updateShouldNotify( + _i16.ChatRoom? old, + _i16.ChatRoom? current, + ) => + (super.noSuchMethod( + Invocation.method( + #updateShouldNotify, + [ + old, + current, + ], + ), + returnValue: false, + ) as bool); + + @override + _i4.RemoveListener addListener( + _i29.Listener<_i16.ChatRoom>? listener, { + bool? fireImmediately = true, + }) => + (super.noSuchMethod( + Invocation.method( + #addListener, + [listener], + {#fireImmediately: fireImmediately}, + ), + returnValue: () {}, + ) as _i4.RemoveListener); +} From b16a8ed04e4472270b0bc1ffac7b6b2f71179cf7 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Wed, 3 Dec 2025 19:54:26 -0600 Subject: [PATCH 22/30] Improve gift wrap tests --- test/features/chat/file_messaging_test.dart | 124 ++++++++++++-------- 1 file changed, 74 insertions(+), 50 deletions(-) diff --git a/test/features/chat/file_messaging_test.dart b/test/features/chat/file_messaging_test.dart index c270bedf..0d3c3e50 100644 --- a/test/features/chat/file_messaging_test.dart +++ b/test/features/chat/file_messaging_test.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -316,59 +315,84 @@ void main() { }); group('Gift Wrap Message Creation', () { - test('creates file message with correct metadata', () { - final fileMessage = { - 'type': 'file_encrypted', - 'file_type': 'document', - 'blossom_url': 'https://blossom.server.com/hash123', - 'nonce': 'abcdef123456789012345678', - 'mime_type': 'application/pdf', - 'original_size': 12345, - 'filename': 'document.pdf', - 'encrypted_size': 12389, - }; - - expect(fileMessage['type'], equals('file_encrypted')); - expect(fileMessage['nonce'], isA()); - expect(fileMessage['blossom_url'], startsWith('https://')); - expect(fileMessage.containsKey('encryption_key'), isFalse); + test('creates file message with correct metadata using real code', () { + final uploadResult = EncryptedFileUploadResult( + blossomUrl: 'https://blossom.server.com/hash123', + nonce: 'abcdef123456789012345678', + fileType: 'document', + mimeType: 'application/pdf', + originalSize: 12345, + filename: 'document.pdf', + encryptedSize: 12389, + ); + + final json = uploadResult.toJson(); + + expect(json['type'], equals('file_encrypted')); + expect(json['nonce'], isA()); + expect(json['blossom_url'], startsWith('https://')); + expect(json.containsKey('encryption_key'), isFalse); + expect(json['file_type'], equals('document')); + expect(json['mime_type'], equals('application/pdf')); + expect(json['original_size'], equals(12345)); + expect(json['encrypted_size'], equals(12389)); }); - test('includes nonce but not encryption key', () { - final imageMessage = { - 'type': 'image_encrypted', - 'file_type': 'image', - 'blossom_url': 'https://blossom.server.com/hash456', - 'nonce': '1234567890abcdef12345678', - 'mime_type': 'image/jpeg', - 'original_size': 54321, - 'filename': 'photo.jpg', - 'encrypted_size': 54365, - }; - - expect(imageMessage.containsKey('nonce'), isTrue); - expect(imageMessage.containsKey('encryption_key'), isFalse); - expect(imageMessage.containsKey('shared_key'), isFalse); + test('creates image message with correct structure using real code', () { + final uploadResult = EncryptedImageUploadResult( + blossomUrl: 'https://blossom.server.com/hash456', + nonce: '1234567890abcdef12345678', + mimeType: 'image/jpeg', + originalSize: 54321, + width: 1920, + height: 1080, + filename: 'photo.jpg', + encryptedSize: 54365, + ); + + final json = uploadResult.toJson(); + + expect(json['type'], equals('image_encrypted')); + expect(json.containsKey('nonce'), isTrue); + expect(json.containsKey('encryption_key'), isFalse); + expect(json.containsKey('shared_key'), isFalse); + expect(json['mime_type'], equals('image/jpeg')); + expect(json['width'], equals(1920)); + expect(json['height'], equals(1080)); + expect(json['encrypted_size'], equals(54365)); }); - test('formats JSON correctly for different file types', () { - final videoMessage = { - 'type': 'file_encrypted', - 'file_type': 'video', - 'blossom_url': 'https://blossom.server.com/hash789', - 'nonce': 'fedcba987654321098765432', - 'mime_type': 'video/mp4', - 'original_size': 9876543, - 'filename': 'video.mp4', - 'encrypted_size': 9876587, - }; - - final jsonString = jsonEncode(videoMessage); - final decoded = jsonDecode(jsonString); - - expect(decoded['type'], equals('file_encrypted')); - expect(decoded['file_type'], equals('video')); - expect(decoded['mime_type'], equals('video/mp4')); + test('file and image messages have different type fields', () { + final fileResult = EncryptedFileUploadResult( + blossomUrl: 'https://blossom.server.com/file', + nonce: 'nonce123', + fileType: 'video', + mimeType: 'video/mp4', + originalSize: 9876543, + filename: 'video.mp4', + encryptedSize: 9876587, + ); + + final imageResult = EncryptedImageUploadResult( + blossomUrl: 'https://blossom.server.com/image', + nonce: 'nonce456', + mimeType: 'image/png', + originalSize: 123456, + width: 800, + height: 600, + filename: 'image.png', + encryptedSize: 123500, + ); + + final fileJson = fileResult.toJson(); + final imageJson = imageResult.toJson(); + + expect(fileJson['type'], equals('file_encrypted')); + expect(imageJson['type'], equals('image_encrypted')); + expect(fileJson['file_type'], equals('video')); + expect(imageJson.containsKey('width'), isTrue); + expect(imageJson.containsKey('height'), isTrue); + expect(fileJson.containsKey('width'), isFalse); }); }); From b98964b88e593560cbfe6a51722afd2e9f679c7c Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:16:09 -0600 Subject: [PATCH 23/30] nitpick rabbit suggestions --- test/features/chat/file_messaging_test.dart | 210 +++++++++++++++++++- 1 file changed, 200 insertions(+), 10 deletions(-) diff --git a/test/features/chat/file_messaging_test.dart b/test/features/chat/file_messaging_test.dart index 0d3c3e50..6aea2a11 100644 --- a/test/features/chat/file_messaging_test.dart +++ b/test/features/chat/file_messaging_test.dart @@ -1,8 +1,10 @@ import 'dart:typed_data'; +import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mockito/mockito.dart'; import 'package:dart_nostr/dart_nostr.dart'; +import 'package:image/image.dart' as img; import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/models/enums/role.dart'; @@ -17,8 +19,10 @@ import 'package:mostro_mobile/services/encrypted_image_upload_service.dart' hide import 'package:mostro_mobile/services/blossom_download_service.dart'; import 'package:mostro_mobile/features/chat/notifiers/chat_room_notifier.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; import '../../mocks.mocks.dart'; +import '../../mocks.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -55,7 +59,6 @@ void main() { late MockEncryptedFileUploadService mockFileUploadService; late MockEncryptedImageUploadService mockImageUploadService; late MockBlossomDownloadService mockDownloadService; - late MockChatRoomNotifier mockChatRoomNotifier; late Session testSession; setUp(() { @@ -63,7 +66,6 @@ void main() { mockFileUploadService = MockEncryptedFileUploadService(); mockImageUploadService = MockEncryptedImageUploadService(); mockDownloadService = MockBlossomDownloadService(); - mockChatRoomNotifier = MockChatRoomNotifier(); // Use same valid key derivation pattern as mostro_service_test.dart testSession = Session( @@ -77,7 +79,20 @@ void main() { peer: Peer(publicKey: peerPublicKey), ); - container = ProviderContainer(); + // Create container with mock session notifier override + container = ProviderContainer(overrides: [ + sessionNotifierProvider.overrideWith((ref) { + final mockSessionNotifier = MockSessionNotifier( + ref, + MockKeyManager(), + MockSessionStorage(), + MockSettings(), + ); + // Configure the mock to return our test session + mockSessionNotifier.setMockSession(testSession); + return mockSessionNotifier; + }), + ]); }); tearDown(() { @@ -98,19 +113,39 @@ void main() { expect(sharedKeyA.private, equals(sharedKeyB.private)); }); - test('shared key used for both text and files', () async { + test('shared key used for both text and files', () { + // Test that the same shared key derivation logic is used for files as for text messages + // This test validates the actual key derivation, not mocks final sharedKeyHex = testSession.sharedKey!.private; + + // Simulate the same conversion logic as ChatRoomNotifier.getSharedKey() final expectedBytes = Uint8List(32); for (int i = 0; i < 32; i++) { final byte = int.parse(sharedKeyHex.substring(i * 2, i * 2 + 2), radix: 16); expectedBytes[i] = byte; } - when(mockChatRoomNotifier.getSharedKey()).thenAnswer((_) async => expectedBytes); + // Test that we can encrypt and decrypt file data with the derived shared key + final testFileData = Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]); + + final encryptionResult = EncryptionService.encryptChaCha20Poly1305( + key: expectedBytes, + plaintext: testFileData, + ); - final result = await mockChatRoomNotifier.getSharedKey(); - expect(result.length, equals(32)); - expect(result, equals(expectedBytes)); + final decryptedData = EncryptionService.decryptChaCha20Poly1305( + key: expectedBytes, + nonce: encryptionResult.nonce, + encryptedData: encryptionResult.encryptedData, + authTag: encryptionResult.authTag, + ); + + // Verify the key works for file encryption/decryption + expect(decryptedData, equals(testFileData)); + expect(expectedBytes.length, equals(32)); + + // Verify the hex conversion logic matches what ChatRoomNotifier would produce + expect(sharedKeyHex.length, equals(64)); // 32 bytes * 2 hex chars }); test('encrypts and decrypts files correctly', () { @@ -236,6 +271,74 @@ void main() { expect(emptyFile.length, equals(0)); expect(emptyFile.length, lessThan(FileValidationService.maxFileSize)); }); + + test('validates real JPEG file with API', () async { + // Create a temporary JPEG file with valid headers + final tempDir = await Directory.systemTemp.createTemp('file_validation_test'); + final jpegFile = File('${tempDir.path}/test.jpg'); + await jpegFile.writeAsBytes(_createTestJpeg()); + + final result = await FileValidationService.validateFile(jpegFile); + + expect(result.mimeType, equals('image/jpeg')); + expect(result.fileType, equals('image')); + expect(result.extension, equals('.jpg')); + expect(result.size, equals(_createTestJpeg().length)); + expect(result.filename, equals('test.jpg')); + + // Cleanup + await tempDir.delete(recursive: true); + }); + + test('validates real PNG file with API', () async { + final tempDir = await Directory.systemTemp.createTemp('file_validation_test'); + final pngFile = File('${tempDir.path}/test.png'); + await pngFile.writeAsBytes(_createTestPng()); + + final result = await FileValidationService.validateFile(pngFile); + + expect(result.mimeType, equals('image/png')); + expect(result.fileType, equals('image')); + expect(result.extension, equals('.png')); + expect(result.size, equals(_createTestPng().length)); + + await tempDir.delete(recursive: true); + }); + + test('rejects oversized file with API', () async { + final tempDir = await Directory.systemTemp.createTemp('file_validation_test'); + final oversizedFile = File('${tempDir.path}/large.txt'); + + // Create a file that exceeds 25MB limit + final largeData = Uint8List(26 * 1024 * 1024); // 26MB + await oversizedFile.writeAsBytes(largeData); + + expect( + () async => await FileValidationService.validateFile(oversizedFile), + throwsA(isA()), + ); + + await tempDir.delete(recursive: true); + }); + + test('rejects unsupported file type with API', () async { + final tempDir = await Directory.systemTemp.createTemp('file_validation_test'); + final exeFile = File('${tempDir.path}/malware.exe'); + + // Create a fake exe file + final exeData = Uint8List.fromList([ + 0x4D, 0x5A, // PE executable header + ...List.generate(100, (i) => i % 256), + ]); + await exeFile.writeAsBytes(exeData); + + expect( + () async => await FileValidationService.validateFile(exeFile), + throwsA(isA()), + ); + + await tempDir.delete(recursive: true); + }); }); group('Media Validation', () { @@ -265,6 +368,74 @@ void main() { expect(pngData[0], equals(0x89)); expect(pngData[1], equals(0x50)); }); + + test('validates real JPEG with MediaValidationService API', () async { + final jpegData = _createRealJpeg(); + + final result = await MediaValidationService.validateAndSanitizeImageLight(jpegData); + + expect(result.imageType, equals(SupportedImageType.jpeg)); + expect(result.mimeType, equals('image/jpeg')); + expect(result.extension, equals('jpg')); + expect(result.validatedData, isNotNull); + expect(result.validatedData.length, greaterThan(0)); + expect(result.width, equals(10)); + expect(result.height, equals(10)); + }); + + test('validates real PNG with MediaValidationService API', () async { + final pngData = _createRealPng(); + + final result = await MediaValidationService.validateAndSanitizeImageLight(pngData); + + expect(result.imageType, equals(SupportedImageType.png)); + expect(result.mimeType, equals('image/png')); + expect(result.extension, equals('png')); + expect(result.validatedData, isNotNull); + expect(result.validatedData.length, greaterThan(0)); + expect(result.width, equals(10)); + expect(result.height, equals(10)); + }); + + test('rejects invalid image data with MediaValidationService API', () async { + final invalidData = Uint8List.fromList([0x42, 0x41, 0x44, 0x00]); // Invalid header + + expect( + () async => await MediaValidationService.validateAndSanitizeImageLight(invalidData), + throwsA(isA()), + ); + }); + + test('rejects empty image data with MediaValidationService API', () async { + final emptyData = Uint8List(0); + + expect( + () async => await MediaValidationService.validateAndSanitizeImageLight(emptyData), + throwsA(isA()), + ); + }); + + test('heavy sanitization works with MediaValidationService API', () async { + final jpegData = _createRealJpeg(); + + final result = await MediaValidationService.validateAndSanitizeImage(jpegData); + + expect(result.imageType, equals(SupportedImageType.jpeg)); + expect(result.mimeType, equals('image/jpeg')); + expect(result.extension, equals('jpg')); + expect(result.validatedData, isNotNull); + expect(result.validatedData.length, greaterThan(0)); + expect(result.width, equals(10)); + expect(result.height, equals(10)); + }); + + test('checks image type support with MediaValidationService API', () { + expect(MediaValidationService.isImageTypeSupported('image/jpeg'), isTrue); + expect(MediaValidationService.isImageTypeSupported('image/png'), isTrue); + expect(MediaValidationService.isImageTypeSupported('image/gif'), isFalse); + expect(MediaValidationService.isImageTypeSupported('image/bmp'), isFalse); + expect(MediaValidationService.isImageTypeSupported('application/pdf'), isFalse); + }); }); group('Blossom Upload', () { @@ -571,8 +742,10 @@ void main() { } // Helper functions for test data creation + +/// Creates a valid JPEG image using the image package for header validation tests Uint8List _createTestJpeg() { - // Minimal valid JPEG header + // Minimal valid JPEG header for basic header tests return Uint8List.fromList([ 0xFF, 0xD8, // JPEG SOI marker 0xFF, 0xE0, // JFIF APP0 marker @@ -585,8 +758,9 @@ Uint8List _createTestJpeg() { ]); } +/// Creates a valid PNG image using the image package for header validation tests Uint8List _createTestPng() { - // Minimal valid PNG header + // Minimal valid PNG header for basic header tests return Uint8List.fromList([ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature 0x00, 0x00, 0x00, 0x0D, // IHDR chunk length @@ -598,3 +772,19 @@ Uint8List _createTestPng() { ]); } +/// Creates a real valid JPEG image for MediaValidationService API tests +Uint8List _createRealJpeg() { + // Create a simple 10x10 red image + final image = img.Image(width: 10, height: 10); + img.fill(image, color: img.ColorRgb8(255, 0, 0)); // Fill with red + return Uint8List.fromList(img.encodeJpg(image, quality: 90)); +} + +/// Creates a real valid PNG image for MediaValidationService API tests +Uint8List _createRealPng() { + // Create a simple 10x10 blue image + final image = img.Image(width: 10, height: 10); + img.fill(image, color: img.ColorRgb8(0, 0, 255)); // Fill with blue + return Uint8List.fromList(img.encodePng(image)); +} + From 771074c49f6e73e7469846598aacc39947bc8b36 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:59:37 -0600 Subject: [PATCH 24/30] improve tests --- test/features/chat/file_messaging_test.dart | 93 ++++----------------- 1 file changed, 15 insertions(+), 78 deletions(-) diff --git a/test/features/chat/file_messaging_test.dart b/test/features/chat/file_messaging_test.dart index 6aea2a11..88454ed4 100644 --- a/test/features/chat/file_messaging_test.dart +++ b/test/features/chat/file_messaging_test.dart @@ -1,7 +1,6 @@ import 'dart:typed_data'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mockito/mockito.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:image/image.dart' as img; @@ -19,10 +18,8 @@ import 'package:mostro_mobile/services/encrypted_image_upload_service.dart' hide import 'package:mostro_mobile/services/blossom_download_service.dart'; import 'package:mostro_mobile/features/chat/notifiers/chat_room_notifier.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; -import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; import '../../mocks.mocks.dart'; -import '../../mocks.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -54,18 +51,11 @@ void main() { provideDummy(MockChatRoomNotifier()); group('File Messaging System', () { - late ProviderContainer container; late MockBlossomClient mockBlossomClient; - late MockEncryptedFileUploadService mockFileUploadService; - late MockEncryptedImageUploadService mockImageUploadService; - late MockBlossomDownloadService mockDownloadService; late Session testSession; setUp(() { mockBlossomClient = MockBlossomClient(); - mockFileUploadService = MockEncryptedFileUploadService(); - mockImageUploadService = MockEncryptedImageUploadService(); - mockDownloadService = MockBlossomDownloadService(); // Use same valid key derivation pattern as mostro_service_test.dart testSession = Session( @@ -78,25 +68,6 @@ void main() { role: Role.buyer, peer: Peer(publicKey: peerPublicKey), ); - - // Create container with mock session notifier override - container = ProviderContainer(overrides: [ - sessionNotifierProvider.overrideWith((ref) { - final mockSessionNotifier = MockSessionNotifier( - ref, - MockKeyManager(), - MockSessionStorage(), - MockSettings(), - ); - // Configure the mock to return our test session - mockSessionNotifier.setMockSession(testSession); - return mockSessionNotifier; - }), - ]); - }); - - tearDown(() { - container.dispose(); }); group('Encryption & Key Management', () { @@ -228,17 +199,8 @@ void main() { }); group('File Validation', () { - test('accepts files within 25MB limit', () { - final file24MB = Uint8List(24 * 1024 * 1024); - expect(file24MB.length, lessThanOrEqualTo(FileValidationService.maxFileSize)); - - final file25MB = Uint8List(25 * 1024 * 1024); - expect(file25MB.length, lessThanOrEqualTo(FileValidationService.maxFileSize)); - }); - - test('rejects oversized files', () { - final file26MB = Uint8List(26 * 1024 * 1024); - expect(file26MB.length, greaterThan(FileValidationService.maxFileSize)); + test('uses 25MB max file size', () { + expect(FileValidationService.maxFileSize, equals(25 * 1024 * 1024)); }); test('validates supported extensions', () { @@ -267,9 +229,7 @@ void main() { }); test('handles zero-byte files', () { - final emptyFile = Uint8List(0); - expect(emptyFile.length, equals(0)); - expect(emptyFile.length, lessThan(FileValidationService.maxFileSize)); + expect(0, lessThan(FileValidationService.maxFileSize)); }); test('validates real JPEG file with API', () async { @@ -568,11 +528,6 @@ void main() { }); group('File Download & Decryption', () { - test('download service can be mocked', () { - expect(mockDownloadService, isNotNull); - expect(mockDownloadService, isA()); - }); - test('verifies file integrity after decryption', () { final testData = Uint8List.fromList([1, 2, 3, 4, 5]); final key = Uint8List.fromList(List.generate(32, (i) => i)); @@ -595,16 +550,6 @@ void main() { }); group('Complete File Sharing Flows', () { - test('image upload service can be mocked', () { - expect(mockImageUploadService, isNotNull); - expect(mockImageUploadService, isA()); - }); - - test('file upload service can be mocked', () { - expect(mockFileUploadService, isNotNull); - expect(mockFileUploadService, isA()); - }); - test('handles file sharing between different sessions', () { // Create session 1 with different derived keys final session1TradeKey = keyDerivator.derivePrivateKey(extendedPrivKey, 10); @@ -677,19 +622,15 @@ void main() { final corruptedAuthTag = Uint8List.fromList(encrypted.authTag); corruptedAuthTag[0] = (corruptedAuthTag[0] + 1) % 256; - bool threwEncryptionException = false; - try { - EncryptionService.decryptChaCha20Poly1305( + expect( + () => EncryptionService.decryptChaCha20Poly1305( key: key, nonce: encrypted.nonce, encryptedData: encrypted.encryptedData, authTag: corruptedAuthTag, - ); - } catch (e) { - threwEncryptionException = e is EncryptionException; - } - - expect(threwEncryptionException, isTrue); + ), + throwsA(isA()), + ); }); test('validates session isolation', () { @@ -725,17 +666,13 @@ void main() { expect(result.authTag.length, equals(16)); // Invalid key should throw ArgumentError - bool threwArgumentError = false; - try { - EncryptionService.encryptChaCha20Poly1305( + expect( + () => EncryptionService.encryptChaCha20Poly1305( key: invalidKey, plaintext: testData, - ); - } catch (e) { - threwArgumentError = e is ArgumentError; - } - - expect(threwArgumentError, isTrue); + ), + throwsArgumentError, + ); }); }); }); @@ -743,7 +680,7 @@ void main() { // Helper functions for test data creation -/// Creates a valid JPEG image using the image package for header validation tests +/// Creates a minimal JPEG byte sequence with valid header markers for header validation tests Uint8List _createTestJpeg() { // Minimal valid JPEG header for basic header tests return Uint8List.fromList([ @@ -758,7 +695,7 @@ Uint8List _createTestJpeg() { ]); } -/// Creates a valid PNG image using the image package for header validation tests +/// Creates a minimal PNG byte sequence with valid header markers for header validation tests Uint8List _createTestPng() { // Minimal valid PNG header for basic header tests return Uint8List.fromList([ From 7fb86b598598e55ce5e9eeed82f91f587a3b9f2f Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:49:40 -0600 Subject: [PATCH 25/30] centralizing BlossomException to avoid multiple definitions --- lib/services/encrypted_file_upload_service.dart | 7 ------- lib/services/encrypted_image_upload_service.dart | 7 ------- test/features/chat/file_messaging_test.dart | 4 ++-- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/lib/services/encrypted_file_upload_service.dart b/lib/services/encrypted_file_upload_service.dart index 7ced4a42..618788fb 100644 --- a/lib/services/encrypted_file_upload_service.dart +++ b/lib/services/encrypted_file_upload_service.dart @@ -212,10 +212,3 @@ class EncryptedFileUploadService { } } -class BlossomException implements Exception { - final String message; - BlossomException(this.message); - - @override - String toString() => 'BlossomException: $message'; -} \ No newline at end of file diff --git a/lib/services/encrypted_image_upload_service.dart b/lib/services/encrypted_image_upload_service.dart index 6a16fa59..44ac46c8 100644 --- a/lib/services/encrypted_image_upload_service.dart +++ b/lib/services/encrypted_image_upload_service.dart @@ -223,10 +223,3 @@ class EncryptedImageUploadService { } } -class BlossomException implements Exception { - final String message; - BlossomException(this.message); - - @override - String toString() => 'BlossomException: $message'; -} \ No newline at end of file diff --git a/test/features/chat/file_messaging_test.dart b/test/features/chat/file_messaging_test.dart index 88454ed4..4d58d60b 100644 --- a/test/features/chat/file_messaging_test.dart +++ b/test/features/chat/file_messaging_test.dart @@ -13,8 +13,8 @@ import 'package:mostro_mobile/services/encryption_service.dart'; import 'package:mostro_mobile/services/blossom_client.dart'; import 'package:mostro_mobile/services/file_validation_service.dart'; import 'package:mostro_mobile/services/media_validation_service.dart'; -import 'package:mostro_mobile/services/encrypted_file_upload_service.dart' hide BlossomException; -import 'package:mostro_mobile/services/encrypted_image_upload_service.dart' hide BlossomException; +import 'package:mostro_mobile/services/encrypted_file_upload_service.dart'; +import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; import 'package:mostro_mobile/services/blossom_download_service.dart'; import 'package:mostro_mobile/features/chat/notifiers/chat_room_notifier.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; From 916d3d28fc5e53dac92cdc98e0927a7da8033e39 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:39:17 -0600 Subject: [PATCH 26/30] centralizing Blossom server config --- lib/services/encrypted_file_upload_service.dart | 15 ++------------- lib/services/encrypted_image_upload_service.dart | 15 ++------------- lib/services/image_upload_service.dart | 16 ++-------------- 3 files changed, 6 insertions(+), 40 deletions(-) diff --git a/lib/services/encrypted_file_upload_service.dart b/lib/services/encrypted_file_upload_service.dart index 618788fb..65656b77 100644 --- a/lib/services/encrypted_file_upload_service.dart +++ b/lib/services/encrypted_file_upload_service.dart @@ -6,6 +6,7 @@ import 'package:mostro_mobile/services/file_validation_service.dart'; import 'package:mostro_mobile/services/blossom_client.dart'; import 'package:mostro_mobile/services/encryption_service.dart'; import 'package:mostro_mobile/services/blossom_download_service.dart'; +import 'package:mostro_mobile/core/config/blossom_config.dart'; class EncryptedFileUploadResult { final String blossomUrl; @@ -57,18 +58,6 @@ class EncryptedFileUploadResult { class EncryptedFileUploadService { final Logger _logger = Logger(); - // List of Blossom servers (with fallbacks) - static const List _blossomServers = [ - 'https://blossom.primal.net', - 'https://blossom.band', - 'https://nostr.media', - 'https://blossom.sector01.com', - 'https://24242.io', - 'https://otherstuff.shaving.kiwi', - 'https://blossom.f7z.io', - 'https://nosto.re', - 'https://blossom.poster.place', - ]; EncryptedFileUploadService(); @@ -162,7 +151,7 @@ class EncryptedFileUploadService { /// Upload with automatic retry to multiple servers Future _uploadWithRetry(Uint8List encryptedData, String mimeType) async { - final servers = _blossomServers; + final servers = BlossomConfig.defaultServers; for (int i = 0; i < servers.length; i++) { final serverUrl = servers[i]; diff --git a/lib/services/encrypted_image_upload_service.dart b/lib/services/encrypted_image_upload_service.dart index 44ac46c8..b983e5d7 100644 --- a/lib/services/encrypted_image_upload_service.dart +++ b/lib/services/encrypted_image_upload_service.dart @@ -6,6 +6,7 @@ import 'package:mostro_mobile/services/media_validation_service.dart'; import 'package:mostro_mobile/services/blossom_client.dart'; import 'package:mostro_mobile/services/encryption_service.dart'; import 'package:mostro_mobile/services/blossom_download_service.dart'; +import 'package:mostro_mobile/core/config/blossom_config.dart'; class EncryptedImageUploadResult { final String blossomUrl; @@ -61,18 +62,6 @@ class EncryptedImageUploadResult { class EncryptedImageUploadService { final Logger _logger = Logger(); - // List of Blossom servers (with fallbacks) - static const List _blossomServers = [ - 'https://blossom.primal.net', - 'https://blossom.band', - 'https://nostr.media', - 'https://blossom.sector01.com', - 'https://24242.io', - 'https://otherstuff.shaving.kiwi', - 'https://blossom.f7z.io', - 'https://nosto.re', - 'https://blossom.poster.place', - ]; EncryptedImageUploadService(); @@ -180,7 +169,7 @@ class EncryptedImageUploadService { /// Upload with automatic retry to multiple servers Future _uploadWithRetry(Uint8List encryptedData, String mimeType) async { - final servers = _blossomServers; // Always use real Blossom servers + final servers = BlossomConfig.defaultServers; for (int i = 0; i < servers.length; i++) { final serverUrl = servers[i]; diff --git a/lib/services/image_upload_service.dart b/lib/services/image_upload_service.dart index c56ff8dd..a5c094d5 100644 --- a/lib/services/image_upload_service.dart +++ b/lib/services/image_upload_service.dart @@ -3,22 +3,11 @@ import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/services/media_validation_service.dart'; import 'package:mostro_mobile/services/blossom_client.dart'; +import 'package:mostro_mobile/core/config/blossom_config.dart'; class ImageUploadService { final Logger _logger = Logger(); - // List of Blossom servers (with fallbacks) - static const List _blossomServers = [ - 'https://blossom.primal.net', - 'https://blossom.band', - 'https://nostr.media', - 'https://blossom.sector01.com', - 'https://24242.io', - 'https://otherstuff.shaving.kiwi', - 'https://blossom.f7z.io', - 'https://nosto.re', - 'https://blossom.poster.place', - ]; ImageUploadService(); @@ -61,8 +50,7 @@ class ImageUploadService { /// Upload with automatic retry to multiple servers Future _uploadWithRetry(Uint8List imageData, String mimeType) async { - final servers = _blossomServers; // Always use real Blossom servers - + final servers = BlossomConfig.defaultServers; for (int i = 0; i < servers.length; i++) { final serverUrl = servers[i]; _logger.d('Attempting upload to server ${i + 1}/${servers.length}: $serverUrl'); From b0ba869537200fb9aca31dd7190dbbda49059ea6 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:48:03 -0600 Subject: [PATCH 27/30] missing file --- lib/core/config/blossom_config.dart | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 lib/core/config/blossom_config.dart diff --git a/lib/core/config/blossom_config.dart b/lib/core/config/blossom_config.dart new file mode 100644 index 00000000..70e11ad9 --- /dev/null +++ b/lib/core/config/blossom_config.dart @@ -0,0 +1,27 @@ +/// Configuration for Blossom server settings and upload parameters +class BlossomConfig { + /// Default Blossom servers used for media uploads + /// + /// These servers are tried in order when uploading files. + /// If one fails, the next server in the list is attempted. + static const List defaultServers = [ + 'https://blossom.primal.net', + 'https://blossom.band', + 'https://nostr.media', + 'https://blossom.sector01.com', + 'https://24242.io', + 'https://otherstuff.shaving.kiwi', + 'https://blossom.f7z.io', + 'https://nosto.re', + 'https://blossom.poster.place', + ]; + + /// Default upload timeout duration + static const Duration defaultTimeout = Duration(minutes: 5); + + /// Maximum retry attempts per server + static const int maxRetries = 3; + + /// Private constructor to prevent instantiation + BlossomConfig._(); +} \ No newline at end of file From def9ef121e0e9dd668b6146b4c0b3bf4410d3eff Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:29:52 -0600 Subject: [PATCH 28/30] fix: remove duplicate code --- ...NCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md | 1 - .../chat/notifiers/chat_room_notifier.dart | 1 - .../chat/widgets/encrypted_image_message.dart | 1 - lib/services/blossom_upload_helper.dart | 59 ++++++++++++++++++ .../encrypted_file_upload_service.dart | 56 +++++------------ .../encrypted_image_upload_service.dart | 60 +++++-------------- lib/services/image_upload_service.dart | 33 +--------- test/mocks.mocks.dart | 2 - 8 files changed, 92 insertions(+), 121 deletions(-) create mode 100644 lib/services/blossom_upload_helper.dart diff --git a/docs/architecture/ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md b/docs/architecture/ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md index c8deffee..28677ac0 100644 --- a/docs/architecture/ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md +++ b/docs/architecture/ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md @@ -388,7 +388,6 @@ Future _processEncryptedImageMessage( final uploadService = EncryptedImageUploadService(); final decryptedImage = await uploadService.downloadAndDecryptImage( blossomUrl: result.blossomUrl, - nonceHex: result.nonce, sharedKey: sharedKey, ); diff --git a/lib/features/chat/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart index 29b5690c..0b30243e 100644 --- a/lib/features/chat/notifiers/chat_room_notifier.dart +++ b/lib/features/chat/notifiers/chat_room_notifier.dart @@ -470,7 +470,6 @@ class ChatRoomNotifier extends StateNotifier { final uploadService = EncryptedImageUploadService(); final decryptedImage = await uploadService.downloadAndDecryptImage( blossomUrl: result.blossomUrl, - nonceHex: result.nonce, sharedKey: sharedKey, ); diff --git a/lib/features/chat/widgets/encrypted_image_message.dart b/lib/features/chat/widgets/encrypted_image_message.dart index fd43eecb..20c9caee 100644 --- a/lib/features/chat/widgets/encrypted_image_message.dart +++ b/lib/features/chat/widgets/encrypted_image_message.dart @@ -319,7 +319,6 @@ class _EncryptedImageMessageState extends ConsumerState { final uploadService = EncryptedImageUploadService(); final decryptedImage = await uploadService.downloadAndDecryptImage( blossomUrl: imageData.blossomUrl, - nonceHex: imageData.nonce, sharedKey: sharedKey, ); diff --git a/lib/services/blossom_upload_helper.dart b/lib/services/blossom_upload_helper.dart new file mode 100644 index 00000000..48737cb2 --- /dev/null +++ b/lib/services/blossom_upload_helper.dart @@ -0,0 +1,59 @@ +import 'dart:typed_data'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/core/config/blossom_config.dart'; +import 'package:mostro_mobile/services/blossom_client.dart'; + +/// Shared utility for uploading data to Blossom servers with automatic retry +class BlossomUploadHelper { + static final Logger _logger = Logger(); + + /// Upload data to Blossom servers with automatic retry across multiple servers + /// + /// Tries each server in [BlossomConfig.defaultServers] sequentially until one succeeds. + /// If all servers fail, throws [BlossomException] with the last error. + /// + /// Parameters: + /// - [data]: The binary data to upload (can be raw image or encrypted blob) + /// - [mimeType]: The MIME type of the data (e.g. 'image/jpeg', 'application/octet-stream') + /// + /// Returns: + /// - [String]: The Blossom URL where the data was successfully uploaded + /// + /// Throws: + /// - [BlossomException]: When all servers fail to upload the data + static Future uploadWithRetry( + Uint8List data, + String mimeType, + ) async { + final servers = BlossomConfig.defaultServers; + + for (int i = 0; i < servers.length; i++) { + final serverUrl = servers[i]; + _logger.d('Attempting upload to server ${i + 1}/${servers.length}: $serverUrl'); + + try { + final client = BlossomClient(serverUrl: serverUrl); + final blossomUrl = await client.uploadImage( + imageData: data, + mimeType: mimeType, + ); + + _logger.i('βœ… Upload successful to: $serverUrl'); + return blossomUrl; + + } catch (e) { + _logger.w('❌ Upload failed to $serverUrl: $e'); + + // If it's the last server, re-throw the error + if (i == servers.length - 1) { + throw BlossomException('All Blossom servers failed. Last error: $e'); + } + + // Continue with next server + continue; + } + } + + throw BlossomException('No Blossom servers available'); + } +} \ No newline at end of file diff --git a/lib/services/encrypted_file_upload_service.dart b/lib/services/encrypted_file_upload_service.dart index 65656b77..e72ed598 100644 --- a/lib/services/encrypted_file_upload_service.dart +++ b/lib/services/encrypted_file_upload_service.dart @@ -3,10 +3,9 @@ import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/services/file_validation_service.dart'; -import 'package:mostro_mobile/services/blossom_client.dart'; +import 'package:mostro_mobile/services/blossom_upload_helper.dart'; import 'package:mostro_mobile/services/encryption_service.dart'; import 'package:mostro_mobile/services/blossom_download_service.dart'; -import 'package:mostro_mobile/core/config/blossom_config.dart'; class EncryptedFileUploadResult { final String blossomUrl; @@ -43,15 +42,19 @@ class EncryptedFileUploadResult { /// Create from JSON (for receiving messages) factory EncryptedFileUploadResult.fromJson(Map json) { - return EncryptedFileUploadResult( - blossomUrl: json['blossom_url'], - nonce: json['nonce'], - mimeType: json['mime_type'], - fileType: json['file_type'], - originalSize: json['original_size'], - filename: json['filename'], - encryptedSize: json['encrypted_size'], - ); + try { + return EncryptedFileUploadResult( + blossomUrl: json['blossom_url'] as String, + nonce: json['nonce'] as String, + mimeType: json['mime_type'] as String, + fileType: json['file_type'] as String, + originalSize: json['original_size'] as int, + filename: json['filename'] as String, + encryptedSize: json['encrypted_size'] as int, + ); + } catch (e) { + throw FormatException('Invalid EncryptedFileUploadResult JSON: $e'); + } } } @@ -151,36 +154,7 @@ class EncryptedFileUploadService { /// Upload with automatic retry to multiple servers Future _uploadWithRetry(Uint8List encryptedData, String mimeType) async { - final servers = BlossomConfig.defaultServers; - - for (int i = 0; i < servers.length; i++) { - final serverUrl = servers[i]; - _logger.d('Attempting upload to server ${i + 1}/${servers.length}: $serverUrl'); - - try { - final client = BlossomClient(serverUrl: serverUrl); - final blossomUrl = await client.uploadImage( - imageData: encryptedData, - mimeType: mimeType, - ); - - _logger.i('βœ… Upload successful to: $serverUrl'); - return blossomUrl; - - } catch (e) { - _logger.w('❌ Upload failed to $serverUrl: $e'); - - // If it's the last server, re-throw the error - if (i == servers.length - 1) { - throw BlossomException('All Blossom servers failed. Last error: $e'); - } - - // Continue with next server - continue; - } - } - - throw BlossomException('No Blossom servers available'); + return BlossomUploadHelper.uploadWithRetry(encryptedData, mimeType); } /// Download from Blossom with retry diff --git a/lib/services/encrypted_image_upload_service.dart b/lib/services/encrypted_image_upload_service.dart index b983e5d7..77d16aba 100644 --- a/lib/services/encrypted_image_upload_service.dart +++ b/lib/services/encrypted_image_upload_service.dart @@ -3,10 +3,9 @@ import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/services/media_validation_service.dart'; -import 'package:mostro_mobile/services/blossom_client.dart'; +import 'package:mostro_mobile/services/blossom_upload_helper.dart'; import 'package:mostro_mobile/services/encryption_service.dart'; import 'package:mostro_mobile/services/blossom_download_service.dart'; -import 'package:mostro_mobile/core/config/blossom_config.dart'; class EncryptedImageUploadResult { final String blossomUrl; @@ -46,16 +45,20 @@ class EncryptedImageUploadResult { /// Create from JSON (for receiving messages) factory EncryptedImageUploadResult.fromJson(Map json) { - return EncryptedImageUploadResult( - blossomUrl: json['blossom_url'], - nonce: json['nonce'], - mimeType: json['mime_type'], - originalSize: json['original_size'], - width: json['width'], - height: json['height'], - filename: json['filename'], - encryptedSize: json['encrypted_size'], - ); + try { + return EncryptedImageUploadResult( + blossomUrl: json['blossom_url'] as String, + nonce: json['nonce'] as String, + mimeType: json['mime_type'] as String, + originalSize: json['original_size'] as int, + width: json['width'] as int, + height: json['height'] as int, + filename: json['filename'] as String, + encryptedSize: json['encrypted_size'] as int, + ); + } catch (e) { + throw FormatException('Invalid EncryptedImageUploadResult JSON: $e'); + } } } @@ -139,12 +142,10 @@ class EncryptedImageUploadService { /// Download and decrypt image from Blossom Future downloadAndDecryptImage({ required String blossomUrl, - required String nonceHex, required Uint8List sharedKey, }) async { _logger.i('πŸ”“ Starting encrypted image download and decryption...'); _logger.d('URL: $blossomUrl'); - _logger.d('Nonce: $nonceHex'); try { // 1. Download encrypted blob from Blossom @@ -169,36 +170,7 @@ class EncryptedImageUploadService { /// Upload with automatic retry to multiple servers Future _uploadWithRetry(Uint8List encryptedData, String mimeType) async { - final servers = BlossomConfig.defaultServers; - - for (int i = 0; i < servers.length; i++) { - final serverUrl = servers[i]; - _logger.d('Attempting upload to server ${i + 1}/${servers.length}: $serverUrl'); - - try { - final client = BlossomClient(serverUrl: serverUrl); - final blossomUrl = await client.uploadImage( - imageData: encryptedData, - mimeType: mimeType, - ); - - _logger.i('βœ… Upload successful to: $serverUrl'); - return blossomUrl; - - } catch (e) { - _logger.w('❌ Upload failed to $serverUrl: $e'); - - // If it's the last server, re-throw the error - if (i == servers.length - 1) { - throw BlossomException('All Blossom servers failed. Last error: $e'); - } - - // Continue with next server - continue; - } - } - - throw BlossomException('No Blossom servers available'); + return BlossomUploadHelper.uploadWithRetry(encryptedData, mimeType); } /// Download from Blossom with retry diff --git a/lib/services/image_upload_service.dart b/lib/services/image_upload_service.dart index a5c094d5..69a68973 100644 --- a/lib/services/image_upload_service.dart +++ b/lib/services/image_upload_service.dart @@ -2,8 +2,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/services/media_validation_service.dart'; -import 'package:mostro_mobile/services/blossom_client.dart'; -import 'package:mostro_mobile/core/config/blossom_config.dart'; +import 'package:mostro_mobile/services/blossom_upload_helper.dart'; class ImageUploadService { final Logger _logger = Logger(); @@ -50,34 +49,6 @@ class ImageUploadService { /// Upload with automatic retry to multiple servers Future _uploadWithRetry(Uint8List imageData, String mimeType) async { - final servers = BlossomConfig.defaultServers; - for (int i = 0; i < servers.length; i++) { - final serverUrl = servers[i]; - _logger.d('Attempting upload to server ${i + 1}/${servers.length}: $serverUrl'); - - try { - final client = BlossomClient(serverUrl: serverUrl); - final blossomUrl = await client.uploadImage( - imageData: imageData, - mimeType: mimeType, - ); - - _logger.i('βœ… Upload successful to: $serverUrl'); - return blossomUrl; - - } catch (e) { - _logger.w('❌ Upload failed to $serverUrl: $e'); - - // If it's the last server, re-throw the error - if (i == servers.length - 1) { - throw BlossomException('All Blossom servers failed. Last error: $e'); - } - - // Continue with next server - continue; - } - } - - throw BlossomException('No Blossom servers available'); + return BlossomUploadHelper.uploadWithRetry(imageData, mimeType); } } \ No newline at end of file diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index 3f3455a8..ebf0337d 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -3293,7 +3293,6 @@ class MockEncryptedImageUploadService extends _i1.Mock @override _i5.Future<_i33.Uint8List> downloadAndDecryptImage({ required String? blossomUrl, - required String? nonceHex, required _i33.Uint8List? sharedKey, }) => (super.noSuchMethod( @@ -3302,7 +3301,6 @@ class MockEncryptedImageUploadService extends _i1.Mock [], { #blossomUrl: blossomUrl, - #nonceHex: nonceHex, #sharedKey: sharedKey, }, ), From 7176d07cfbff5ce6576c4802cfa7b60677b8cb43 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:12:47 -0600 Subject: [PATCH 29/30] add translations and follow coderabbit sugests --- ...NCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md | 4 ++-- .../chat/widgets/encrypted_image_message.dart | 23 ++++++++++++------- lib/l10n/intl_en.arb | 13 ++++++++++- lib/l10n/intl_es.arb | 13 ++++++++++- lib/l10n/intl_it.arb | 13 ++++++++++- lib/services/blossom_upload_helper.dart | 10 ++++---- .../encrypted_image_upload_service.dart | 3 +-- lib/services/image_upload_service.dart | 3 +-- 8 files changed, 61 insertions(+), 21 deletions(-) diff --git a/docs/architecture/ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md b/docs/architecture/ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md index 28677ac0..8b06fe0e 100644 --- a/docs/architecture/ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md +++ b/docs/architecture/ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md @@ -28,7 +28,7 @@ This document details the complete implementation of encrypted file messaging fo ## Architecture Overview -``` +```text β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ User A β”‚ β”‚ Blossom Servers β”‚ β”‚ User B β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ @@ -142,7 +142,7 @@ static Uint8List decryptChaCha20Poly1305({ - Key derived from ECDH key exchange in Mostro protocol **Blob Structure**: -``` +```text β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Nonce (12B) β”‚ Encrypted Data β”‚ Auth Tag (16B) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ diff --git a/lib/features/chat/widgets/encrypted_image_message.dart b/lib/features/chat/widgets/encrypted_image_message.dart index 20c9caee..0aa06815 100644 --- a/lib/features/chat/widgets/encrypted_image_message.dart +++ b/lib/features/chat/widgets/encrypted_image_message.dart @@ -9,6 +9,7 @@ import 'package:open_file/open_file.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; class EncryptedImageMessage extends ConsumerStatefulWidget { final NostrEvent message; @@ -46,7 +47,7 @@ class _EncryptedImageMessageState extends ConsumerState { if (messageId == null) { if (mounted) { setState(() { - _errorMessage = 'Invalid message: missing ID'; + _errorMessage = S.of(context)!.invalidMessageMissingId; }); } return; @@ -209,7 +210,7 @@ class _EncryptedImageMessageState extends ConsumerState { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( - 'Decrypting image...', + S.of(context)!.decryptingImage, style: TextStyle( fontSize: 12, color: AppTheme.textSecondary.withValues(alpha: 153), @@ -251,7 +252,7 @@ class _EncryptedImageMessageState extends ConsumerState { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( - 'Failed to load image', + S.of(context)!.failedToLoadImage, style: TextStyle( fontSize: 12, color: Colors.red, @@ -285,7 +286,7 @@ class _EncryptedImageMessageState extends ConsumerState { if (messageId == null) { if (mounted) { setState(() { - _errorMessage = 'Invalid message: missing ID'; + _errorMessage = S.of(context)!.invalidMessageMissingId; }); } return; @@ -302,7 +303,7 @@ class _EncryptedImageMessageState extends ConsumerState { // Parse the message content to get image data final content = widget.message.content; if (content == null || !content.startsWith('{')) { - throw Exception('Invalid image message format'); + throw Exception(S.of(context)!.invalidImageMessageFormat); } final imageData = EncryptedImageUploadResult.fromJson( @@ -347,7 +348,13 @@ class _EncryptedImageMessageState extends ConsumerState { } Future _openImage(Uint8List imageData, EncryptedImageUploadResult metadata) async { + // Cache localized strings before async operations + final securityErrorMsg = S.of(context)!.securityErrorInvalidChars; + final couldNotOpenMsg = S.of(context)!.couldNotOpenFile; + final errorOpeningMsg = S.of(context)!.errorOpeningFile; + try { + // Save image to temporary directory final tempDir = await getTemporaryDirectory(); final sanitizedFilename = _sanitizeFilename(metadata.filename); @@ -356,7 +363,7 @@ class _EncryptedImageMessageState extends ConsumerState { // Security check if (sanitizedFilename.contains('/') || sanitizedFilename.contains('\\') || sanitizedFilename.contains('..') || sanitizedFilename.trim().isEmpty) { - throw Exception('Security error: Invalid characters in sanitized filename'); + throw Exception(securityErrorMsg); } await tempFile.writeAsBytes(imageData); @@ -368,7 +375,7 @@ class _EncryptedImageMessageState extends ConsumerState { if (result.type != ResultType.done) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Could not open file: ${result.message}'), + content: Text('$couldNotOpenMsg: ${result.message}'), backgroundColor: Colors.orange, duration: const Duration(seconds: 3), ), @@ -379,7 +386,7 @@ class _EncryptedImageMessageState extends ConsumerState { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error opening file: $e'), + content: Text('$errorOpeningMsg: $e'), backgroundColor: Colors.red, ), ); diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 2f0aa90d..c63a9de9 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -307,6 +307,9 @@ "noneSelected": "None selected", "fiatCurrencies": "Fiat currencies", "paymentMethods": "Payment methods", + "invalidMessageMissingId": "Invalid message: missing ID", + "invalidImageMessageFormat": "Invalid image message format", + "securityErrorInvalidChars": "Security error: Invalid characters in sanitized filename", "ratingLabel": "Rating: {rating}", "buyingBitcoin": "Buying Bitcoin", "sellingBitcoin": "Selling Bitcoin", @@ -1202,5 +1205,13 @@ "@_comment_file_confirmation": "File confirmation dialog strings", "confirmFileUpload": "Confirm Upload", "sendThisFile": "Send this file?", - "send": "Send" + "send": "Send", + + "@_comment_image_decryption": "Image decryption status strings", + "decryptingImage": "Decrypting image...", + "failedToLoadImage": "Failed to load image", + "imageDecryptionError": "Error decrypting image", + "imageNotAvailable": "Image not available", + "couldNotOpenFile": "Could not open file", + "errorOpeningFile": "Error opening file" } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index c4b5848b..903a3ca4 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -225,6 +225,9 @@ "noneSelected": "Ninguno seleccionado", "fiatCurrencies": "Monedas fiat", "paymentMethods": "MΓ©todos de pago", + "invalidMessageMissingId": "Mensaje invΓ‘lido: ID faltante", + "invalidImageMessageFormat": "Formato de mensaje de imagen invΓ‘lido", + "securityErrorInvalidChars": "Error de seguridad: caracteres invΓ‘lidos en nombre de archivo sanitizado", "ratingLabel": "CalificaciΓ³n: {rating}", "buyingBitcoin": "Comprando Bitcoin", "sellingBitcoin": "Vendiendo Bitcoin", @@ -1180,6 +1183,14 @@ "@_comment_file_confirmation": "File confirmation dialog strings", "confirmFileUpload": "Confirmar EnvΓ­o", "sendThisFile": "ΒΏEnviar este archivo?", - "send": "Enviar" + "send": "Enviar", + + "@_comment_image_decryption": "Image decryption status strings", + "decryptingImage": "Desencriptando imagen...", + "failedToLoadImage": "Error al cargar la imagen", + "imageDecryptionError": "Error al desencriptar la imagen", + "imageNotAvailable": "Imagen no disponible", + "couldNotOpenFile": "No se pudo abrir el archivo", + "errorOpeningFile": "Error al abrir archivo" } \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index e0e36d28..eb065c6c 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -258,6 +258,9 @@ "noneSelected": "Nessuna selezione", "fiatCurrencies": "Valute fiat", "paymentMethods": "Metodi di pagamento", + "invalidMessageMissingId": "Messaggio non valido: ID mancante", + "invalidImageMessageFormat": "Formato del messaggio immagine non valido", + "securityErrorInvalidChars": "Errore di sicurezza: caratteri non validi nel nome file sanitizzato", "ratingLabel": "Valutazione: {rating}", "buyingBitcoin": "Comprando Bitcoin", "sellingBitcoin": "Vendendo Bitcoin", @@ -1235,5 +1238,13 @@ "@_comment_file_confirmation": "File confirmation dialog strings", "confirmFileUpload": "Conferma Invio", "sendThisFile": "Inviare questo file?", - "send": "Invia" + "send": "Invia", + + "@_comment_image_decryption": "Image decryption status strings", + "decryptingImage": "Decrittazione immagine...", + "failedToLoadImage": "Impossibile caricare l'immagine", + "imageDecryptionError": "Errore nella decrittazione dell'immagine", + "imageNotAvailable": "Immagine non disponibile", + "couldNotOpenFile": "Impossibile aprire il file", + "errorOpeningFile": "Errore nell'apertura del file" } \ No newline at end of file diff --git a/lib/services/blossom_upload_helper.dart b/lib/services/blossom_upload_helper.dart index 48737cb2..3a63a157 100644 --- a/lib/services/blossom_upload_helper.dart +++ b/lib/services/blossom_upload_helper.dart @@ -27,6 +27,10 @@ class BlossomUploadHelper { ) async { final servers = BlossomConfig.defaultServers; + if (servers.isEmpty) { + throw BlossomException('No Blossom servers configured'); + } + for (int i = 0; i < servers.length; i++) { final serverUrl = servers[i]; _logger.d('Attempting upload to server ${i + 1}/${servers.length}: $serverUrl'); @@ -48,12 +52,10 @@ class BlossomUploadHelper { if (i == servers.length - 1) { throw BlossomException('All Blossom servers failed. Last error: $e'); } - - // Continue with next server - continue; } } - throw BlossomException('No Blossom servers available'); + // This should never be reached due to the throw on last server failure + throw StateError('Unreachable: upload loop exited unexpectedly'); } } \ No newline at end of file diff --git a/lib/services/encrypted_image_upload_service.dart b/lib/services/encrypted_image_upload_service.dart index 77d16aba..0d912770 100644 --- a/lib/services/encrypted_image_upload_service.dart +++ b/lib/services/encrypted_image_upload_service.dart @@ -64,8 +64,7 @@ class EncryptedImageUploadResult { class EncryptedImageUploadService { final Logger _logger = Logger(); - - + EncryptedImageUploadService(); /// Upload encrypted image with complete sanitization and encryption diff --git a/lib/services/image_upload_service.dart b/lib/services/image_upload_service.dart index 69a68973..840e991c 100644 --- a/lib/services/image_upload_service.dart +++ b/lib/services/image_upload_service.dart @@ -6,8 +6,7 @@ import 'package:mostro_mobile/services/blossom_upload_helper.dart'; class ImageUploadService { final Logger _logger = Logger(); - - + ImageUploadService(); /// Upload image with complete sanitization From d48afab4c1b6081ebd83c3c0b836e34fbf5dc31b Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:03:47 -0600 Subject: [PATCH 30/30] add Unicode normalization and robust JSON parsing for file messaging --- .../chat/widgets/encrypted_image_message.dart | 23 ++++++++----------- pubspec.lock | 8 +++++++ pubspec.yaml | 3 +++ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/lib/features/chat/widgets/encrypted_image_message.dart b/lib/features/chat/widgets/encrypted_image_message.dart index 0aa06815..380df869 100644 --- a/lib/features/chat/widgets/encrypted_image_message.dart +++ b/lib/features/chat/widgets/encrypted_image_message.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:path_provider/path_provider.dart'; import 'package:open_file/open_file.dart'; +import 'package:diacritic/diacritic.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; @@ -302,16 +303,15 @@ class _EncryptedImageMessageState extends ConsumerState { // Parse the message content to get image data final content = widget.message.content; - if (content == null || !content.startsWith('{')) { + if (content == null || content.trim().isEmpty) { throw Exception(S.of(context)!.invalidImageMessageFormat); } - final imageData = EncryptedImageUploadResult.fromJson( - Map.from( - // ignore: avoid_dynamic_calls - jsonDecode(content) as Map - ) - ); + final parsedJson = jsonDecode(content); + if (parsedJson is! Map) { + throw Exception(S.of(context)!.invalidImageMessageFormat); + } + final imageData = EncryptedImageUploadResult.fromJson(parsedJson); // Get shared key final sharedKey = await chatNotifier.getSharedKey(); @@ -399,13 +399,8 @@ class _EncryptedImageMessageState extends ConsumerState { // Get basename only (remove any directory components) final basename = filename.split(RegExp(r'[/\\]')).last; - // Normalize accented characters - String normalized = basename - .replaceAll('Γ‘', 'a').replaceAll('Γ©', 'e').replaceAll('Γ­', 'i') - .replaceAll('Γ³', 'o').replaceAll('ΓΊ', 'u').replaceAll('Γ±', 'n') - .replaceAll('ΓΌ', 'u').replaceAll('Á', 'A').replaceAll('Γ‰', 'E') - .replaceAll('Í', 'I').replaceAll('Γ“', 'O').replaceAll('Ú', 'U') - .replaceAll('Γ‘', 'N').replaceAll('Ü', 'U'); + // Normalize using diacritic package for comprehensive Unicode support + String normalized = removeDiacritics(basename); // Replace spaces and remove dangerous characters final cleaned = normalized diff --git a/pubspec.lock b/pubspec.lock index 573d63e8..31690a4d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -369,6 +369,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + diacritic: + dependency: "direct main" + description: + name: diacritic + sha256: "12981945ec38931748836cd76f2b38773118d0baef3c68404bdfde9566147876" + url: "https://pub.dev" + source: hosted + version: "0.1.6" dots_indicator: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b5a31b65..4fd0632b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -96,6 +96,9 @@ dependencies: # File picker for documents, videos, etc. file_picker: ^10.3.7 open_file: ^3.5.10 + + # Unicode normalization for filename sanitization + diacritic: ^0.1.6 dev_dependencies: