|
|
|
|
@ -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,9 +127,12 @@ class BlossomService implements MediaServiceInterface {
|
|
|
|
|
/// 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}');
|
|
|
|
|
@ -90,28 +143,69 @@ class BlossomService implements MediaServiceInterface {
|
|
|
|
|
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)
|
|
|
|
|
// 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: formData,
|
|
|
|
|
data: fileBytes, // Raw binary data
|
|
|
|
|
options: Options(
|
|
|
|
|
headers: {
|
|
|
|
|
'Authorization': jsonEncode(authEvent.toJson()), // Signed Nostr event
|
|
|
|
|
'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}',
|
|
|
|
|
@ -126,11 +220,55 @@ class BlossomService implements MediaServiceInterface {
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|