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; /// Local storage service for caching metadata. final LocalStorageService _localStorage; /// 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, _localStorage = localStorage, _dio = dio ?? Dio() { _dio.options.baseUrl = baseUrl; } /// Sets the Nostr keypair for authentication. void setNostrKeyPair(NostrKeyPair keypair) { _nostrKeyPair = keypair; } /// Creates an authorization event (kind 24242) for Blossom authentication. NostrEvent _createAuthorizationEvent() { if (_nostrKeyPair == null) { throw BlossomException('Nostr keypair not set. Call setNostrKeyPair() first.'); } // Create authorization event (kind 24242) per BUD-01 return NostrEvent.create( content: '', kind: 24242, privateKey: _nostrKeyPair!.privateKey, tags: [], ); } /// 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) { throw BlossomException('Nostr keypair not set. Call setNostrKeyPair() first.'); } 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(); // 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) { 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) { throw BlossomException( 'Upload failed: ${e.message ?? 'Unknown error'}', e.response?.statusCode, ); } catch (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, }; } /// 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 { 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. String _getCacheFilePath(String blobHash, bool isThumbnail) { if (_imageCacheDirectory == null) return ''; final cacheKey = '${blobHash}_${isThumbnail ? 'thumb' : 'full'}'; return path.join(_imageCacheDirectory!.path, cacheKey); } }