From 5b6fe6af320c843c11d6a35c35e99addce26330d Mon Sep 17 00:00:00 2001 From: gitea Date: Wed, 12 Nov 2025 20:29:45 +0100 Subject: [PATCH] blossom media server support added working --- lib/data/blossom/blossom_service.dart | 243 +++++++++++++++++++++----- lib/data/session/session_service.dart | 14 +- 2 files changed, 216 insertions(+), 41 deletions(-) diff --git a/lib/data/blossom/blossom_service.dart b/lib/data/blossom/blossom_service.dart index dd4a47a..e1c64e7 100644 --- a/lib/data/blossom/blossom_service.dart +++ b/lib/data/blossom/blossom_service.dart @@ -51,21 +51,71 @@ class BlossomService implements MediaServiceInterface { /// 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. - NostrEvent _createAuthorizationEvent() { + /// + /// [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.'); } - // Create authorization event (kind 24242) per BUD-01 - return NostrEvent.create( - content: '', + 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: [], + 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. @@ -77,8 +127,11 @@ class BlossomService implements MediaServiceInterface { /// 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()) { @@ -89,48 +142,133 @@ class BlossomService implements MediaServiceInterface { 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 - final authEvent = _createAuthorizationEvent(); - - // Prepare multipart form data - final formData = FormData.fromMap({ - 'file': await MultipartFile.fromFile( - imageFile.path, - filename: imageFile.path.split('/').last, - ), - }); - - // Upload to Blossom (PUT /upload per BUD-02) - final response = await _dio.put( - '/upload', - data: formData, - options: Options( - headers: { - 'Authorization': jsonEncode(authEvent.toJson()), // Signed Nostr event - }, - ), - ); - - if (response.statusCode != 200 && response.statusCode != 201) { + // 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: ${response.statusMessage}', - response.statusCode, + 'Upload failed: ${lastException.message ?? 'Unknown error'}', + lastException.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; + 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'); } } @@ -202,6 +340,8 @@ class BlossomService implements MediaServiceInterface { // 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'); @@ -253,9 +393,34 @@ class BlossomService implements MediaServiceInterface { } /// Gets the cache file path for an image. - String _getCacheFilePath(String blobHash, bool isThumbnail) { + /// + /// 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 ''; - final cacheKey = '${blobHash}_${isThumbnail ? 'thumb' : 'full'}'; + + // 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); } } diff --git a/lib/data/session/session_service.dart b/lib/data/session/session_service.dart index be4cecb..4cd55df 100644 --- a/lib/data/session/session_service.dart +++ b/lib/data/session/session_service.dart @@ -11,6 +11,7 @@ import '../firebase/firebase_service.dart'; import '../nostr/nostr_service.dart'; import '../nostr/models/nostr_keypair.dart'; import '../nostr/models/nostr_profile.dart'; +import '../blossom/blossom_service.dart'; import 'models/user.dart'; /// Service for managing user sessions, login, logout, and session isolation. @@ -196,9 +197,18 @@ class SessionService { if (mediaService != null && storedPrivateKey != null) { try { // Check if it's a BlossomService by checking if it has setNostrKeyPair method - if (mediaService.runtimeType.toString().contains('BlossomService')) { - (mediaService as dynamic).setNostrKeyPair(keyPair); + // Use a more reliable check by trying to call the method + if (mediaService is BlossomService) { + mediaService.setNostrKeyPair(keyPair); Logger.info('Nostr keypair set on BlossomService for image uploads'); + } else { + // Fallback: try dynamic call if type check doesn't work + try { + (mediaService as dynamic).setNostrKeyPair(keyPair); + Logger.info('Nostr keypair set on media service (dynamic) for image uploads'); + } catch (_) { + Logger.debug('Media service does not support Nostr keypair: ${mediaService.runtimeType}'); + } } } catch (e) { Logger.warning('Failed to set Nostr keypair on BlossomService: $e');