You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

575 lines
22 KiB

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<BlossomUploadResponse> 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<String, dynamic>;
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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<String, dynamic>;
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<Uint8List> 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 /<sha256> per BUD-01
imageUrl = '$_baseUrl/$blobHash';
}
final response = await _dio.get<List<int>>(
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 /<sha256> per BUD-01
return '$_baseUrl/$blobHash';
}
/// Gets the base URL for Blossom API.
String get baseUrl => _baseUrl;
/// Initializes the image cache directory.
Future<void> _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<void> 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
}
}
}

Powered by TurnKey Linux.