import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import '../../core/logger.dart'; import '../../core/exceptions/blossom_exception.dart'; import '../local/local_storage_service.dart'; import '../media/media_service_interface.dart'; import '../nostr/models/nostr_event.dart'; import '../nostr/models/nostr_keypair.dart'; import 'models/blossom_upload_response.dart'; /// Service for interacting with Blossom Media Server API. /// /// Blossom uses Nostr authentication (kind 24242 authorization events). /// See: https://github.com/hzrd149/blossom class BlossomService implements MediaServiceInterface { /// HTTP client for API requests. final Dio _dio; /// Blossom API base URL. final String _baseUrl; /// Nostr keypair for authentication. NostrKeyPair? _nostrKeyPair; /// Cache directory for storing fetched images. Directory? _imageCacheDirectory; /// Creates a [BlossomService] instance. /// /// [baseUrl] - Blossom server base URL (e.g., 'https://blossom.example.com'). /// [localStorage] - Local storage service for caching metadata. /// [dio] - Optional Dio instance for dependency injection (useful for testing). BlossomService({ required String baseUrl, required LocalStorageService localStorage, Dio? dio, }) : _baseUrl = baseUrl, _dio = dio ?? Dio() { _dio.options.baseUrl = baseUrl; } /// Sets the Nostr keypair for authentication. void setNostrKeyPair(NostrKeyPair keypair) { _nostrKeyPair = keypair; Logger.info('BlossomService: Nostr keypair set (pubkey: ${keypair.publicKey.substring(0, 16)}...)'); } /// Creates an authorization event (kind 24242) for Blossom authentication. /// /// [blobHash] - The SHA256 hash of the blob being uploaded. /// [filename] - The filename of the file being uploaded (optional). /// [expirationSeconds] - Expiration time in seconds from now (default: 300 = 5 minutes). NostrEvent _createAuthorizationEvent(String blobHash, {String? filename, int expirationSeconds = 300}) { if (_nostrKeyPair == null) { Logger.error('Cannot create Blossom authorization event: Nostr keypair not set'); throw BlossomException('Nostr keypair not set. Call setNostrKeyPair() first.'); } Logger.debug('Creating Blossom authorization event with pubkey: ${_nostrKeyPair!.publicKey.substring(0, 16)}...'); Logger.debug('Blossom authorization blob hash: $blobHash'); final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; final expiration = createdAt + expirationSeconds; // Create authorization event (kind 24242) with required tags per Blossom spec // Based on iris.to implementation, Blossom expects: // - content: filename (or empty string) // - tags: ["t", "upload"], ["x", blobHash], ["expiration", timestamp] final event = NostrEvent.create( content: filename ?? '', // Filename or empty string kind: 24242, privateKey: _nostrKeyPair!.privateKey, tags: [ ['t', 'upload'], // Type tag ['x', blobHash], // Blob hash tag ['expiration', expiration.toString()], // Expiration timestamp ], ); // Verify the signature before using it final isValid = event.verifySignature(); if (!isValid) { Logger.error('Blossom authorization event signature verification FAILED!'); throw BlossomException('Failed to create valid authorization event: signature verification failed'); } Logger.debug('Blossom authorization event created: id=${event.id.substring(0, 16)}..., kind=${event.kind}, sig=${event.sig.substring(0, 16)}...'); Logger.debug('Blossom authorization event signature verified: $isValid'); Logger.debug('Blossom authorization event tags: ${event.tags}'); return event; } /// Gets the Content-Type header value based on file extension. String _getContentType(String filename) { final extension = filename.split('.').last.toLowerCase(); switch (extension) { case 'jpg': case 'jpeg': return 'image/jpeg'; case 'png': return 'image/png'; case 'webp': return 'image/webp'; case 'gif': return 'image/gif'; default: return 'image/jpeg'; // Default fallback } } /// Uploads an image file to Blossom. /// /// [imageFile] - The image file to upload. /// /// Returns [BlossomUploadResponse] containing the blob hash and URL. /// /// Throws [BlossomException] if upload fails. Future uploadImageToBlossom(File imageFile) async { if (_nostrKeyPair == null) { Logger.error('Blossom upload failed: Nostr keypair not set'); throw BlossomException('Nostr keypair not set. Call setNostrKeyPair() first.'); } Logger.debug('Blossom upload: Nostr keypair is set (pubkey: ${_nostrKeyPair!.publicKey.substring(0, 16)}...)'); try { if (!await imageFile.exists()) { throw BlossomException('Image file does not exist: ${imageFile.path}'); } // Read file bytes and calculate SHA256 hash final fileBytes = await imageFile.readAsBytes(); final hash = sha256.convert(fileBytes); final blobHash = hash.toString(); // Get filename for the authorization event final filename = imageFile.path.split('/').last; // Determine content type based on file extension final contentType = _getContentType(filename); // Create authorization event with blob hash and filename final authEvent = _createAuthorizationEvent(blobHash, filename: filename); // Log authorization event for debugging Logger.debug('Blossom authorization event: ${authEvent.toJson()}'); Logger.debug('Blossom authorization event ID: ${authEvent.id}'); Logger.debug('Blossom authorization event pubkey: ${authEvent.pubkey}'); Logger.debug('Blossom authorization event kind: ${authEvent.kind}'); Logger.debug('Blossom authorization event sig: ${authEvent.sig.substring(0, 16)}...'); // Encode authorization event as JSON object (not array) with snake_case field names // Based on iris.to implementation, Blossom expects: // - JSON object format: {"created_at": ..., "content": ..., "tags": ..., "kind": ..., "pubkey": ..., "id": ..., "sig": ...} // - Authorization header: "Nostr " + base64-encoded JSON object final authEventJson = { 'created_at': authEvent.createdAt, 'content': authEvent.content, 'tags': authEvent.tags, 'kind': authEvent.kind, 'pubkey': authEvent.pubkey, 'id': authEvent.id, 'sig': authEvent.sig, }; final authJsonString = jsonEncode(authEventJson); final authBase64 = base64Encode(utf8.encode(authJsonString)); Logger.debug('Blossom Authorization header (JSON object): $authJsonString'); Logger.debug('Blossom Authorization header (Base64): $authBase64'); // Try different authorization header formats in order // Based on iris.to, Blossom expects "Nostr " + base64-encoded JSON object final formats = [ ('Nostr Base64', 'Nostr $authBase64'), // Try this first (matches iris.to) ('Raw JSON', authJsonString), ('Base64', authBase64), ]; DioException? lastException; for (final (formatName, authHeader) in formats) { Logger.debug('Blossom trying authorization format: $formatName'); try { // Send raw file bytes (not multipart form data) as per iris.to implementation // iris.to sends: PUT /upload with Content-Type: image/jpeg and raw binary data final response = await _dio.put( '/upload', data: fileBytes, // Raw binary data options: Options( headers: { 'Authorization': authHeader, 'Content-Type': contentType, // e.g., 'image/jpeg', 'image/png', 'image/webp' }, ), ); // Success! Logger.info('Blossom upload succeeded with format: $formatName'); if (response.statusCode != 200 && response.statusCode != 201) { throw BlossomException( 'Upload failed: ${response.statusMessage}', response.statusCode, ); } // Parse response (blob descriptor per BUD-02) final responseData = response.data as Map; final uploadResponse = BlossomUploadResponse.fromJson(responseData); Logger.info('Image uploaded to Blossom: ${uploadResponse.hash}'); return uploadResponse; } on DioException catch (e) { lastException = e; if (e.response?.statusCode == 401) { Logger.debug('Blossom format "$formatName" failed with 401, trying next format...'); continue; // Try next format } else { // Non-401 error - log it but continue trying other formats // This could be a network error, parsing error, etc. Logger.warning('Blossom format "$formatName" failed with non-401 error: ${e.message} (status: ${e.response?.statusCode}), trying next format...'); continue; // Try next format anyway } } catch (e) { // Non-DioException - this is unexpected, log and continue Logger.warning('Blossom format "$formatName" failed with unexpected error: $e, trying next format...'); continue; } } // All formats failed with 401 if (lastException != null) { Logger.error('Blossom upload DioException: ${lastException.message}'); Logger.error('Blossom upload status code: ${lastException.response?.statusCode}'); Logger.error('Blossom upload response data: ${lastException.response?.data}'); Logger.error('Blossom 401 Unauthorized - All authorization formats failed'); Logger.error('Blossom keypair status: ${_nostrKeyPair != null ? "SET" : "NOT SET"}'); if (_nostrKeyPair != null) { Logger.error('Blossom keypair pubkey: ${_nostrKeyPair!.publicKey}'); } Logger.error('Tried formats: Raw JSON, Base64, Nostr Base64'); Logger.error('Possible causes:'); Logger.error('1. Server-side authentication configuration issue'); Logger.error('2. Blob hash mismatch or incorrect format'); Logger.error('3. Expiration timestamp issue'); Logger.error('4. Server expects different event structure'); throw BlossomException( 'Upload failed: ${lastException.message ?? 'Unknown error'}', lastException.response?.statusCode, ); } throw BlossomException('Upload failed: All authorization formats failed'); } on DioException catch (e) { Logger.error('Blossom upload DioException: ${e.message}'); Logger.error('Blossom upload status code: ${e.response?.statusCode}'); Logger.error('Blossom upload response data: ${e.response?.data}'); throw BlossomException( 'Upload failed: ${e.message ?? 'Unknown error'}', e.response?.statusCode, ); } catch (e) { Logger.error('Blossom upload exception: $e'); throw BlossomException('Upload failed: $e'); } } /// Uploads an image file (implements MediaServiceInterface). @override Future> uploadImage(File imageFile) async { final response = await uploadImageToBlossom(imageFile); return { 'hash': response.hash, 'id': response.hash, // For compatibility 'url': response.url, }; } /// Uploads a video file (implements MediaServiceInterface). @override Future> uploadVideo(File videoFile) async { if (_nostrKeyPair == null) { Logger.error('Blossom upload failed: Nostr keypair not set'); throw BlossomException('Nostr keypair not set. Call setNostrKeyPair() first.'); } Logger.debug('Blossom video upload: Nostr keypair is set (pubkey: ${_nostrKeyPair!.publicKey.substring(0, 16)}...)'); try { if (!await videoFile.exists()) { throw BlossomException('Video file does not exist: ${videoFile.path}'); } // Read file bytes and calculate SHA256 hash final fileBytes = await videoFile.readAsBytes(); final hash = sha256.convert(fileBytes); final blobHash = hash.toString(); // Get filename for the authorization event final filename = videoFile.path.split('/').last; // Determine content type for video (MP4) final contentType = 'video/mp4'; // Create authorization event with blob hash and filename final authEvent = _createAuthorizationEvent(blobHash, filename: filename); // Encode authorization event as JSON object final authEventJson = { 'created_at': authEvent.createdAt, 'content': authEvent.content, 'tags': authEvent.tags, 'kind': authEvent.kind, 'pubkey': authEvent.pubkey, 'id': authEvent.id, 'sig': authEvent.sig, }; final authJsonString = jsonEncode(authEventJson); final authBase64 = base64Encode(utf8.encode(authJsonString)); // Try different authorization header formats final formats = [ ('Nostr Base64', 'Nostr $authBase64'), ('Raw JSON', authJsonString), ('Base64', authBase64), ]; DioException? lastException; for (final (formatName, authHeader) in formats) { try { final response = await _dio.put( '/upload', data: fileBytes, options: Options( headers: { 'Authorization': authHeader, 'Content-Type': contentType, }, ), ); if (response.statusCode != 200 && response.statusCode != 201) { throw BlossomException( 'Upload failed: ${response.statusMessage}', response.statusCode, ); } final responseData = response.data as Map; final uploadResponse = BlossomUploadResponse.fromJson(responseData); Logger.info('Video uploaded to Blossom: ${uploadResponse.hash}'); return { 'hash': uploadResponse.hash, 'id': uploadResponse.hash, 'url': uploadResponse.url, }; } on DioException catch (e) { lastException = e; if (e.response?.statusCode == 401) { Logger.debug('Blossom format "$formatName" failed with 401, trying next format...'); continue; } // Non-401 error, rethrow rethrow; } } if (lastException != null) { throw BlossomException( 'Video upload failed: ${lastException.message ?? 'Unknown error'}', lastException.response?.statusCode, ); } throw BlossomException('Video upload failed: All authorization formats failed'); } on DioException catch (e) { Logger.error('Blossom video upload DioException: ${e.message}'); throw BlossomException( 'Video upload failed: ${e.message ?? 'Unknown error'}', e.response?.statusCode, ); } catch (e) { Logger.error('Blossom video upload error: $e'); throw BlossomException('Video upload failed: $e'); } } /// Fetches image bytes for a blob by hash with local caching. /// /// [blobHash] - The SHA256 hash of the blob (or full URL). /// [isThumbnail] - Whether to fetch thumbnail (true) or original image (false). Default: true. /// /// Returns the image bytes as Uint8List. /// /// Throws [BlossomException] if fetch fails. Future fetchImageBytes(String blobHash, {bool isThumbnail = true}) async { await _ensureImageCacheDirectory(); // Check cache first final cacheFilePath = _getCacheFilePath(blobHash, isThumbnail); if (cacheFilePath.isNotEmpty) { final cacheFile = File(cacheFilePath); if (await cacheFile.exists()) { try { final cachedBytes = await cacheFile.readAsBytes(); Logger.debug('Loaded image from cache: $blobHash'); return Uint8List.fromList(cachedBytes); } catch (e) { Logger.warning('Failed to read cached image, fetching fresh: $e'); } } } // Cache miss - fetch from Blossom try { // If blobHash is a full URL, use it directly String imageUrl; if (blobHash.startsWith('http://') || blobHash.startsWith('https://')) { imageUrl = blobHash; } else { // Construct URL: GET / per BUD-01 imageUrl = '$_baseUrl/$blobHash'; } final response = await _dio.get>( imageUrl, options: Options( responseType: ResponseType.bytes, ), ); if (response.statusCode != 200) { throw BlossomException( 'Failed to fetch image: ${response.statusMessage}', response.statusCode, ); } final imageBytes = Uint8List.fromList(response.data ?? []); // Cache the fetched image if (cacheFilePath.isNotEmpty && imageBytes.isNotEmpty) { try { // Ensure cache directory exists before writing await _ensureImageCacheDirectory(); final cacheFile = File(cacheFilePath); await cacheFile.writeAsBytes(imageBytes); Logger.debug('Cached image: $blobHash'); } catch (e) { Logger.warning('Failed to cache image (non-fatal): $e'); } } return imageBytes; } on DioException catch (e) { throw BlossomException( 'Failed to fetch image: ${e.message ?? 'Unknown error'}', e.response?.statusCode, ); } } /// Gets the full image URL for a blob hash. /// /// [blobHash] - The SHA256 hash of the blob (or full URL). /// /// Returns the full URL to the blob. String getImageUrl(String blobHash) { // If it's already a full URL, return it if (blobHash.startsWith('http://') || blobHash.startsWith('https://')) { return blobHash; } // GET / per BUD-01 return '$_baseUrl/$blobHash'; } /// Gets the base URL for Blossom API. String get baseUrl => _baseUrl; /// Initializes the image cache directory. Future _ensureImageCacheDirectory() async { if (_imageCacheDirectory != null) return; try { final appDir = await getApplicationDocumentsDirectory(); _imageCacheDirectory = Directory(path.join(appDir.path, 'blossom_image_cache')); if (!await _imageCacheDirectory!.exists()) { await _imageCacheDirectory!.create(recursive: true); } } catch (e) { Logger.warning('Failed to initialize image cache directory: $e'); } } /// Gets the cache file path for an image. /// /// Extracts the blob hash from URLs if needed and sanitizes it for use as a filename. String _getCacheFilePath(String blobHashOrUrl, bool isThumbnail) { if (_imageCacheDirectory == null) return ''; // Extract blob hash from URL if it's a full URL String blobHash = blobHashOrUrl; if (blobHashOrUrl.startsWith('http://') || blobHashOrUrl.startsWith('https://')) { // Extract hash from URL like: https://media.based21.com/08e301596aef7a232dcec85c4725f49ff88864f26a3c572f1fa6d1aedaecaf7e.webp final uri = Uri.parse(blobHashOrUrl); final pathSegments = uri.pathSegments; if (pathSegments.isNotEmpty) { // Get the last segment (the hash with extension) final lastSegment = pathSegments.last; // Remove extension if present (e.g., .webp) blobHash = lastSegment.split('.').first; } else { // Fallback: use a sanitized version of the URL blobHash = blobHashOrUrl .replaceAll(RegExp(r'[^a-zA-Z0-9]'), '_') .substring(0, blobHashOrUrl.length > 64 ? 64 : blobHashOrUrl.length); } } // Sanitize blob hash to ensure it's safe for use as a filename // Blob hashes are hex strings (64 chars), so they should be safe, but let's be defensive final sanitizedHash = blobHash.replaceAll(RegExp(r'[^a-zA-Z0-9]'), '_'); final cacheKey = '${sanitizedHash}_${isThumbnail ? 'thumb' : 'full'}'; return path.join(_imageCacheDirectory!.path, cacheKey); } /// Clears the cache for a specific image URL or blob hash. @override Future clearImageCache(String imageUrlOrBlobHash) async { // Extract blob hash from URL if it's a full URL String blobHash = imageUrlOrBlobHash; if (imageUrlOrBlobHash.startsWith('http://') || imageUrlOrBlobHash.startsWith('https://')) { // Extract hash from URL like: https://media.based21.com/08e301596aef7a232dcec85c4725f49ff88864f26a3c572f1fa6d1aedaecaf7e.webp final uri = Uri.parse(imageUrlOrBlobHash); final pathSegments = uri.pathSegments; if (pathSegments.isNotEmpty) { // Get the last segment (the hash with extension) final lastSegment = pathSegments.last; // Remove extension if present blobHash = lastSegment.split('.').first; } } // Clear both thumbnail and full image cache final thumbCachePath = _getCacheFilePath(blobHash, true); final fullCachePath = _getCacheFilePath(blobHash, false); try { if (thumbCachePath.isNotEmpty) { final thumbFile = File(thumbCachePath); if (await thumbFile.exists()) { await thumbFile.delete(); Logger.debug('Cleared thumbnail cache: $blobHash'); } } if (fullCachePath.isNotEmpty) { final fullFile = File(fullCachePath); if (await fullFile.exists()) { await fullFile.delete(); Logger.debug('Cleared full image cache: $blobHash'); } } } catch (e) { Logger.warning('Failed to clear image cache for $blobHash: $e'); // Don't throw - cache clearing is best effort } } }