diff --git a/.gitignore b/.gitignore index 4a51c218..a8d4f017 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,7 @@ migrate_working_dir/ build/ /coverage/* /lcov_badge_generator -/.flutter-plugins -/.flutter-plugins-dependencies +.flutter-plugins +.flutter-plugins-dependencies /packages/ndk/coverage/* /packages/ndk/coverage.lcov diff --git a/packages/amber/pubspec.lock b/packages/amber/pubspec.lock index 949f5a38..d580538d 100644 --- a/packages/amber/pubspec.lock +++ b/packages/amber/pubspec.lock @@ -469,7 +469,7 @@ packages: path: "../ndk" relative: true source: path - version: "0.2.0" + version: "0.2.4" package_config: dependency: transitive description: diff --git a/packages/isar/pubspec.lock b/packages/isar/pubspec.lock index f3ec2d1c..8fe8f3a1 100644 --- a/packages/isar/pubspec.lock +++ b/packages/isar/pubspec.lock @@ -375,7 +375,7 @@ packages: path: "../ndk" relative: true source: path - version: "0.2.0" + version: "0.2.4" node_preamble: dependency: transitive description: diff --git a/packages/ndk/example/files/blossom_example_test.dart b/packages/ndk/example/files/blossom_example_test.dart new file mode 100644 index 00000000..d2180b88 --- /dev/null +++ b/packages/ndk/example/files/blossom_example_test.dart @@ -0,0 +1,22 @@ +// ignore_for_file: avoid_print + +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +void main() async { + test('download test', () async { + final ndk = Ndk.defaultConfig(); + + final downloadResult = await ndk.blossom.getBlob( + sha256: + "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553", + serverUrls: ["https://cdn.hzrd149.com"], + ); + + print( + "file of type: ${downloadResult.mimeType}, size: ${downloadResult.data.length}", + ); + + expect(downloadResult.data.length, greaterThan(0)); + }); +} diff --git a/packages/ndk/example/files/files_example_test.dart b/packages/ndk/example/files/files_example_test.dart new file mode 100644 index 00000000..8f73d974 --- /dev/null +++ b/packages/ndk/example/files/files_example_test.dart @@ -0,0 +1,31 @@ +// ignore_for_file: avoid_print + +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +void main() async { + test('download test - blossom', () async { + final ndk = Ndk.defaultConfig(); + + final downloadResult = await ndk.files.download( + url: + "https://cdn.hzrd149.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf"); + + print( + "file of type: ${downloadResult.mimeType}, size: ${downloadResult.data.length}"); + + expect(downloadResult.data.length, greaterThan(0)); + }); + + test('download test - non blossom', () async { + final ndk = Ndk.defaultConfig(); + + final downloadResult = await ndk.files + .download(url: "https://camelus.app/.well-known/nostr.json"); + + print( + "file of type: ${downloadResult.mimeType}, size: ${downloadResult.data.length}"); + + expect(downloadResult.data.length, greaterThan(0)); + }); +} diff --git a/packages/ndk/lib/config/blossom_config.dart b/packages/ndk/lib/config/blossom_config.dart new file mode 100644 index 00000000..0dc42283 --- /dev/null +++ b/packages/ndk/lib/config/blossom_config.dart @@ -0,0 +1,4 @@ +// ignore_for_file: constant_identifier_names + +/// how long nostr auth messages are valid for +const Duration BLOSSOM_AUTH_EXPIRATION = Duration(minutes: 5); diff --git a/packages/ndk/lib/data_layer/data_sources/http_request.dart b/packages/ndk/lib/data_layer/data_sources/http_request.dart index a21f382f..c535ab7a 100644 --- a/packages/ndk/lib/data_layer/data_sources/http_request.dart +++ b/packages/ndk/lib/data_layer/data_sources/http_request.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:http/http.dart' as http; @@ -21,4 +22,93 @@ class HttpRequestDS { } return jsonDecode(response.body); } + + Future put({ + required Uri url, + required Uint8List body, + required headers, + }) async { + http.Response response = await _client.put( + url, + body: body, + headers: headers, + ); + + if (response.statusCode != 200) { + throw Exception( + "error fetching STATUS: ${response.statusCode}, Link: $url"); + } + + return response; + } + + Future post({ + required Uri url, + required Uint8List body, + required headers, + }) async { + http.Response response = await _client.post( + url, + body: body, + headers: headers, + ); + + if (response.statusCode != 200) { + throw Exception( + "error fetching STATUS: ${response.statusCode}, Link: $url"); + } + + return response; + } + + Future head({ + required Uri url, + headers, + }) async { + http.Response response = await _client.head( + url, + headers: headers, + ); + + if (response.statusCode != 200) { + throw Exception( + "error fetching STATUS: ${response.statusCode}, Link: $url"); + } + + return response; + } + + Future get({ + required Uri url, + headers, + }) async { + http.Response response = await _client.get( + url, + headers: headers, + ); + + if (response.statusCode != 200) { + throw Exception( + "error fetching STATUS: ${response.statusCode}, Link: $url"); + } + + return response; + } + + Future delete({ + required Uri url, + required headers, + }) async { + http.Response response = await _client.delete( + url, + headers: headers, + ); + + if (response.statusCode != 200) { + throw Exception( + "error fetching STATUS: ${response.statusCode}, Link: $url"); + } + + return response; + } } diff --git a/packages/ndk/lib/data_layer/repositories/blossom/blossom_impl.dart b/packages/ndk/lib/data_layer/repositories/blossom/blossom_impl.dart new file mode 100644 index 00000000..5126d992 --- /dev/null +++ b/packages/ndk/lib/data_layer/repositories/blossom/blossom_impl.dart @@ -0,0 +1,441 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import '../../../domain_layer/entities/blossom_blobs.dart'; +import '../../../domain_layer/entities/nip_01_event.dart'; +import '../../../domain_layer/entities/tuple.dart'; +import '../../../domain_layer/repositories/blossom.dart'; +import '../../data_sources/http_request.dart'; + +class BlossomRepositoryImpl implements BlossomRepository { + final HttpRequestDS client; + + BlossomRepositoryImpl({ + required this.client, + }); + + @override + Future> uploadBlob({ + required Uint8List data, + required Nip01Event authorization, + String? contentType, + required List serverUrls, + UploadStrategy strategy = UploadStrategy.mirrorAfterSuccess, + }) async { + switch (strategy) { + case UploadStrategy.mirrorAfterSuccess: + return _uploadWithMirroring( + data: data, + serverUrls: serverUrls, + contentType: contentType, + authorization: authorization, + ); + case UploadStrategy.allSimultaneous: + return _uploadToAllServers( + data: data, + serverUrls: serverUrls, + contentType: contentType, + authorization: authorization, + ); + case UploadStrategy.firstSuccess: + return _uploadToFirstSuccess( + data: data, + serverUrls: serverUrls, + contentType: contentType, + authorization: authorization, + ); + } + } + + Future> _uploadWithMirroring({ + required Uint8List data, + required Nip01Event authorization, + required List serverUrls, + String? contentType, + }) async { + final results = []; + BlobUploadResult? successfulUpload; + + // Try servers until we get a successful upload + for (final serverUrl in serverUrls) { + final result = await _uploadToServer( + serverUrl: serverUrl, + data: data, + contentType: contentType, + authorization: authorization, + ); + results.add(result); + + if (result.success) { + successfulUpload = result; + break; + } + } + + // If we found a working server, mirror to all other servers that haven't been tried yet + if (successfulUpload != null) { + // Get the index where we succeeded + final successIndex = serverUrls.indexOf(successfulUpload.serverUrl); + + // Mirror to remaining servers (ones we haven't tried yet) + final remainingServers = serverUrls.sublist(successIndex + 1); + if (remainingServers.isNotEmpty) { + final mirrorResults = await Future.wait( + remainingServers.map((url) => _mirrorToServer( + fileUrl: successfulUpload!.descriptor!.url, + serverUrl: url, + sha256: successfulUpload.descriptor!.sha256, + authorization: authorization, + )), + ); + results.addAll(mirrorResults); + } + } + + return results; + } + + Future> _uploadToAllServers({ + required Uint8List data, + required List serverUrls, + required Nip01Event authorization, + String? contentType, + }) async { + final results = await Future.wait(serverUrls.map((url) => _uploadToServer( + serverUrl: url, + data: data, + contentType: contentType, + authorization: authorization, + ))); + return results; + } + + Future> _uploadToFirstSuccess({ + required Uint8List data, + required List serverUrls, + required Nip01Event authorization, + String? contentType, + }) async { + for (final url in serverUrls) { + final result = await _uploadToServer( + serverUrl: url, + data: data, + contentType: contentType, + authorization: authorization, + ); + if (result.success) { + return [result]; + } + } + + // If all servers failed, return all errors + final results = await _uploadToAllServers( + data: data, + serverUrls: serverUrls, + contentType: contentType, + authorization: authorization, + ); + return results; + } + + /// Upload a file to a server + Future _uploadToServer({ + required String serverUrl, + required Uint8List data, + Nip01Event? authorization, + String? contentType, + }) async { + try { + final response = await client.put( + url: Uri.parse('$serverUrl/upload'), + body: data, + headers: { + if (contentType != null) 'Content-Type': contentType, + if (authorization != null) + 'Authorization': "Nostr ${authorization.toBase64()}", + 'Content-Length': '${data.length}', + }, + ); + + if (response.statusCode != 200) { + return BlobUploadResult( + serverUrl: serverUrl, + success: false, + error: 'HTTP ${response.statusCode}', + ); + } + + return BlobUploadResult( + serverUrl: serverUrl, + success: true, + descriptor: BlobDescriptor.fromJson(jsonDecode(response.body)), + ); + } catch (e) { + return BlobUploadResult( + serverUrl: serverUrl, + success: false, + error: e.toString(), + ); + } + } + + /// Mirror a file from one server to another, based on the file URL + Future _mirrorToServer({ + required String fileUrl, + required String serverUrl, + required String sha256, + required Nip01Event authorization, + }) async { + final jsonMsg = {"url": fileUrl}; + + final Uint8List myBody = + Uint8List.fromList(utf8.encode(jsonEncode(jsonMsg))); + try { + // Mirror endpoint is POST /mirror/ + final response = await client.post( + url: Uri.parse('$serverUrl/mirror/$sha256'), + body: myBody, + headers: { + 'Authorization': "Nostr ${authorization.toBase64()}", + }, + ); + + if (response.statusCode != 200) { + return BlobUploadResult( + serverUrl: serverUrl, + success: false, + error: 'HTTP ${response.statusCode}', + ); + } + + return BlobUploadResult( + serverUrl: serverUrl, + success: true, + descriptor: BlobDescriptor.fromJson(jsonDecode(response.body)), + ); + } catch (e) { + return BlobUploadResult( + serverUrl: serverUrl, + success: false, + error: e.toString(), + ); + } + } + + @override + Future getBlob({ + required String sha256, + required List serverUrls, + Nip01Event? authorization, + int? start, + int? end, + }) async { + Exception? lastError; + + for (final url in serverUrls) { + try { + final headers = {}; + if (start != null) { + // Create range header in format "bytes=start-end" + // If end is null, it means "until the end of the file" + headers['range'] = 'bytes=$start-${end ?? ''}'; + } + + final response = await client.get( + url: Uri.parse('$url/$sha256'), + headers: headers, + ); + + // Check for both 200 (full content) and 206 (partial content) status codes + if (response.statusCode == 200 || response.statusCode == 206) { + return BlobResponse( + data: response.bodyBytes, + mimeType: response.headers['content-type'], + contentLength: + int.tryParse(response.headers['content-length'] ?? ''), + contentRange: response.headers['content-range'] ?? '', + ); + } + lastError = Exception('HTTP ${response.statusCode}'); + } catch (e) { + lastError = e is Exception ? e : Exception(e.toString()); + } + } + + throw Exception( + 'Failed to get blob from any of the servers. Last error: $lastError'); + } + + /// first value is whether the server supports range requests \ + /// second value is the content length of the blob in bytes + @override + Future> supportsRangeRequests({ + required String sha256, + required String serverUrl, + }) async { + try { + final response = await client.head( + url: Uri.parse('$serverUrl/$sha256'), + ); + + final acceptRanges = response.headers['accept-ranges']; + final contentLength = + int.tryParse(response.headers['content-length'] ?? ''); + return Tuple(acceptRanges?.toLowerCase() == 'bytes', contentLength); + } catch (e) { + return Tuple(false, null); + } + } + + @override + Future> getBlobStream({ + required String sha256, + required List serverUrls, + Nip01Event? authorization, + int chunkSize = 1024 * 1024, // 1MB chunks + }) async { + // Find a server that supports range requests + String? supportedServer; + int? contentLength; + + for (final url in serverUrls) { + try { + final rangeResponse = await supportsRangeRequests( + sha256: sha256, + serverUrl: url, + ); + if (rangeResponse.first) { + supportedServer = url; + contentLength = rangeResponse.second; + break; + } + } catch (_) { + continue; + } + } + + if (supportedServer == null || contentLength == null) { + // Fallback to regular download if no server supports range requests + final bytes = await getBlob(sha256: sha256, serverUrls: serverUrls); + return Stream.value(bytes); + } + + // Create a stream controller to manage the chunks + final controller = StreamController(); + + // Start downloading chunks + int offset = 0; + while (offset < contentLength) { + final end = (offset + chunkSize - 1).clamp(0, contentLength - 1); + + try { + final chunk = await getBlob( + sha256: sha256, + serverUrls: [supportedServer], + start: offset, + end: end, + ); + controller.add(chunk); + offset = end + 1; + } catch (e) { + await controller.close(); + rethrow; + } + } + + await controller.close(); + return controller.stream; + } + + @override + Future> listBlobs({ + required pubkey, + required List serverUrls, + DateTime? since, + DateTime? until, + Nip01Event? authorization, + }) async { + Exception? lastError; + + for (final url in serverUrls) { + try { + final queryParams = { + if (since != null) 'since': '${since.millisecondsSinceEpoch ~/ 1000}', + if (until != null) 'until': '${until.millisecondsSinceEpoch ~/ 1000}', + }; + + final response = await client.get( + url: Uri.parse('$url/list/$pubkey') + .replace(queryParameters: queryParams), + ); + + if (response.statusCode == 200) { + final List json = jsonDecode(response.body); + return json.map((j) => BlobDescriptor.fromJson(j)).toList(); + } + lastError = Exception('HTTP ${response.statusCode}'); + } catch (e) { + lastError = e is Exception ? e : Exception(e.toString()); + } + } + + throw Exception( + 'Failed to list blobs from all servers. Last error: $lastError'); + } + + @override + Future> deleteBlob({ + required String sha256, + required List serverUrls, + required Nip01Event authorization, + }) async { + final results = await Future.wait(serverUrls.map((url) => _deleteFromServer( + serverUrl: url, + sha256: sha256, + authorization: authorization, + ))); + return results; + } + + Future _deleteFromServer({ + required String serverUrl, + required String sha256, + required Nip01Event authorization, + }) async { + try { + final response = await client.delete( + url: Uri.parse('$serverUrl/$sha256'), + headers: { + 'Authorization': "Nostr ${authorization.toBase64()}", + }, + ); + + return BlobDeleteResult( + serverUrl: serverUrl, + success: response.statusCode == 200, + error: + response.statusCode != 200 ? 'HTTP ${response.statusCode}' : null, + ); + } catch (e) { + return BlobDeleteResult( + serverUrl: serverUrl, + success: false, + error: e.toString(), + ); + } + } + + @override + Future directDownload({ + required Uri url, + }) async { + final response = await client.get(url: url); + return BlobResponse( + data: response.bodyBytes, + mimeType: response.headers['content-type'], + contentLength: int.tryParse(response.headers['content-length'] ?? ''), + contentRange: response.headers['content-range'] ?? '', + ); + } +} diff --git a/packages/ndk/lib/domain_layer/entities/blossom_blobs.dart b/packages/ndk/lib/domain_layer/entities/blossom_blobs.dart new file mode 100644 index 00000000..5f21c7ee --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/blossom_blobs.dart @@ -0,0 +1,66 @@ +import 'dart:typed_data'; + +class BlobDescriptor { + final String url; + final String sha256; + final int size; + final String? type; + final DateTime uploaded; + + BlobDescriptor({ + required this.url, + required this.sha256, + required this.size, + this.type, + required this.uploaded, + }); + + factory BlobDescriptor.fromJson(Map json) { + return BlobDescriptor( + url: json['url'], + sha256: json['sha256'], + size: json['size'], + type: json['type'], + uploaded: DateTime.fromMillisecondsSinceEpoch(json['uploaded'] * 1000)); + } +} + +class BlobUploadResult { + final String serverUrl; + final bool success; + final BlobDescriptor? descriptor; + final String? error; + + BlobUploadResult({ + required this.serverUrl, + required this.success, + this.descriptor, + this.error, + }); +} + +class BlobDeleteResult { + final String serverUrl; + final bool success; + final String? error; + + BlobDeleteResult({ + required this.serverUrl, + required this.success, + this.error, + }); +} + +class BlobResponse { + final Uint8List data; + final String? mimeType; + final int? contentLength; + final String? contentRange; + + BlobResponse({ + required this.data, + this.mimeType, + this.contentLength, + this.contentRange, + }); +} diff --git a/packages/ndk/lib/domain_layer/entities/ndk_file.dart b/packages/ndk/lib/domain_layer/entities/ndk_file.dart new file mode 100644 index 00000000..6f8e3683 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/ndk_file.dart @@ -0,0 +1,15 @@ +import 'dart:typed_data'; + +class NdkFile { + final Uint8List data; + final String? sha256; + final String? mimeType; + final int? size; + + NdkFile({ + required this.data, + this.mimeType, + this.size, + this.sha256, + }); +} diff --git a/packages/ndk/lib/domain_layer/entities/nip_01_event.dart b/packages/ndk/lib/domain_layer/entities/nip_01_event.dart index 82c9a06e..82ea3d01 100644 --- a/packages/ndk/lib/domain_layer/entities/nip_01_event.dart +++ b/packages/ndk/lib/domain_layer/entities/nip_01_event.dart @@ -93,6 +93,11 @@ class Nip01Event { }; } + /// Returns the Event object as a base64-encoded JSON string + String toBase64() { + return base64Encode(utf8.encode(json.encode(toJson()))); + } + /// sign the event with given privateKey /// [WARN] only for testing! Use [EventSigner] to sign events in production void sign(String privateKey) { diff --git a/packages/ndk/lib/domain_layer/repositories/blossom.dart b/packages/ndk/lib/domain_layer/repositories/blossom.dart new file mode 100644 index 00000000..a2900f8e --- /dev/null +++ b/packages/ndk/lib/domain_layer/repositories/blossom.dart @@ -0,0 +1,77 @@ +import 'dart:typed_data'; + +import '../entities/blossom_blobs.dart'; +import '../entities/nip_01_event.dart'; +import '../entities/tuple.dart'; + +enum UploadStrategy { + /// Upload to first server, then mirror to others + mirrorAfterSuccess, + + /// Upload to all servers simultaneously + allSimultaneous, + + /// Upload to first successful server only + firstSuccess +} + +abstract class BlossomRepository { + /// Uploads a blob using the specified strategy + Future> uploadBlob({ + required Uint8List data, + required Nip01Event authorization, + String? contentType, + required List serverUrls, + UploadStrategy strategy = UploadStrategy.mirrorAfterSuccess, + }); + + /// Gets a blob by trying servers sequentially until success + /// If [authorization] is null, the server must be public + /// If [start] and [end] are null, the entire blob is returned + /// [start] and [end] are used to download a range of bytes, @see MDN HTTP range requests + Future getBlob({ + required String sha256, + required List serverUrls, + Nip01Event? authorization, + int? start, + int? end, + }); + + /// Directly downloads a blob from the url, without blossom + Future directDownload({ + required Uri url, + }); + + /// checks if the server supports range requests, if no server supports range requests, the entire blob is returned + /// otherwise, the blob is returned in chunks. @see MDN HTTP range requests + Future> getBlobStream({ + required String sha256, + required List serverUrls, + Nip01Event? authorization, + int chunkSize = 1024 * 1024, // 1MB chunks + }); + + /// Checks if the server supports range requests and gets the content length \ + /// first value is whether the server supports range requests \ + /// second value is the content length of the blob in bytes + Future> supportsRangeRequests({ + required String sha256, + required String serverUrl, + }); + + /// Lists blobs from the first successful server + Future> listBlobs({ + required String pubkey, + required List serverUrls, + DateTime? since, + DateTime? until, + Nip01Event? authorization, + }); + + /// Attempts to delete blob from all servers + Future> deleteBlob({ + required String sha256, + required List serverUrls, + required Nip01Event authorization, + }); +} diff --git a/packages/ndk/lib/domain_layer/usecases/files/blossom.dart b/packages/ndk/lib/domain_layer/usecases/files/blossom.dart new file mode 100644 index 00000000..a874dfac --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/files/blossom.dart @@ -0,0 +1,217 @@ +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; + +import '../../../config/blossom_config.dart'; +import '../../entities/blossom_blobs.dart'; +import '../../entities/nip_01_event.dart'; +import '../../repositories/blossom.dart'; +import '../../repositories/event_signer.dart'; +import 'blossom_user_server_list.dart'; + +/// direct access usecase to blossom \ +/// use files usecase for a more convinent way to manage files +class Blossom { + /// kind for all most of blossom + static const kBlossom = 24242; + + /// kind for blossom user server list + static const kBlossomUserServerList = 10063; + + final BlossomUserServerList userServerList; + final BlossomRepository blossomImpl; + final EventSigner? signer; + + Blossom({ + required this.userServerList, + required this.blossomImpl, + required this.signer, + }); + + _checkSigner() { + if (signer == null) { + throw "Signer is null"; + } + } + + /// upload a blob to the server + /// if [serverUrls] is null, the userServerList is fetched from nostr. \ + /// if the pukey has no UserServerList (kind: 10063), throws an error + Future> uploadBlob({ + required Uint8List data, + List? serverUrls, + String? contentType, + UploadStrategy strategy = UploadStrategy.mirrorAfterSuccess, + }) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + /// sha256 of the data + final dataSha256 = sha256.convert(data); + + _checkSigner(); + + final Nip01Event myAuthorization = Nip01Event( + content: "upload", + pubKey: signer!.getPublicKey(), + kind: kBlossom, + createdAt: now, + tags: [ + ["t", "upload"], + ["x", dataSha256.toString()], + ["expiration", "${now + BLOSSOM_AUTH_EXPIRATION.inMilliseconds}"], + ], + ); + + await signer!.sign(myAuthorization); + + serverUrls ??= await userServerList + .getUserServerList(pubkeys: [signer!.getPublicKey()]); + + if (serverUrls == null) { + throw "User has no server list"; + } + + return blossomImpl.uploadBlob( + data: data, + serverUrls: serverUrls, + authorization: myAuthorization, + contentType: contentType, + strategy: strategy, + ); + } + + /// downloads a blob + /// if [serverUrls] is null, the userServerList is fetched from nostr. \ + /// if the pukey has no UserServerList (kind: 10063), throws an error + Future getBlob({ + required String sha256, + bool useAuth = false, + List? serverUrls, + String? pubkeyToFetchUserServerList, + }) async { + Nip01Event? myAuthorization; + + if (useAuth) { + _checkSigner(); + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + myAuthorization = Nip01Event( + content: "get", + pubKey: signer!.getPublicKey(), + kind: kBlossom, + createdAt: now, + tags: [ + ["t", "get"], + ["x", sha256], + ["expiration", "${now + BLOSSOM_AUTH_EXPIRATION.inMilliseconds}"], + ], + ); + + await signer!.sign(myAuthorization); + } + + if (serverUrls == null) { + if (pubkeyToFetchUserServerList == null) { + throw "pubkeyToFetchUserServerList is null and serverUrls is null"; + } + + serverUrls ??= await userServerList + .getUserServerList(pubkeys: [pubkeyToFetchUserServerList]); + } + + if (serverUrls == null) { + throw "User has no server list"; + } + + return blossomImpl.getBlob( + sha256: sha256, + authorization: myAuthorization, + serverUrls: serverUrls, + ); + } + + Future> listBlobs({ + required String pubkey, + List? serverUrls, + bool useAuth = true, + DateTime? since, + DateTime? until, + }) async { + Nip01Event? myAuthorization; + + if (useAuth) { + _checkSigner(); + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + myAuthorization = Nip01Event( + content: "List Blobs", + pubKey: signer!.getPublicKey(), + kind: kBlossom, + createdAt: now, + tags: [ + ["t", "list"], + ["expiration", "${now + BLOSSOM_AUTH_EXPIRATION.inMilliseconds}"], + ], + ); + + await signer!.sign(myAuthorization); + } + + /// fetch user server list from nostr + serverUrls ??= await userServerList.getUserServerList(pubkeys: [pubkey]); + + if (serverUrls == null) { + throw "User has no server list: $pubkey"; + } + + return blossomImpl.listBlobs( + pubkey: pubkey, + since: since, + until: until, + serverUrls: serverUrls, + authorization: myAuthorization, + ); + } + + Future> deleteBlob({ + required String sha256, + List? serverUrls, + }) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + _checkSigner(); + + final Nip01Event myAuthorization = Nip01Event( + content: "delete", + pubKey: signer!.getPublicKey(), + kind: kBlossom, + createdAt: now, + tags: [ + ["t", "delete"], + ["x", sha256], + ["expiration", "${now + BLOSSOM_AUTH_EXPIRATION.inMilliseconds}"], + ], + ); + + await signer!.sign(myAuthorization); + + /// fetch user server list from nostr + serverUrls ??= await userServerList + .getUserServerList(pubkeys: [signer!.getPublicKey()]); + + if (serverUrls == null) { + throw "User has no server list"; + } + return blossomImpl.deleteBlob( + sha256: sha256, + authorization: myAuthorization, + serverUrls: serverUrls, + ); + } + + /// Directly downloads a blob from the url, without blossom + Future directDownload({ + required Uri url, + }) { + return blossomImpl.directDownload(url: url); + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/files/blossom_user_server_list.dart b/packages/ndk/lib/domain_layer/usecases/files/blossom_user_server_list.dart new file mode 100644 index 00000000..80b0f0ca --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/files/blossom_user_server_list.dart @@ -0,0 +1,44 @@ +import '../../entities/filter.dart'; +import '../requests/requests.dart'; +import 'blossom.dart'; + +class BlossomUserServerList { + final Requests requests; + + BlossomUserServerList( + this.requests, + ); + + /// Get user server list + /// returns list of server urls + /// returns null if the user has no server list + Future?> getUserServerList({ + required List pubkeys, + }) async { + final rsp = requests.query( + filters: [ + Filter( + authors: pubkeys, + kinds: [Blossom.kBlossomUserServerList], + ) + ], + ); + + final data = await rsp.future; + data.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + if (data.isEmpty) { + return null; + } + + final List foundServers = []; + + for (final tag in data.first.tags) { + if (tag.length > 1 && tag[0] == 'server') { + foundServers.add(tag[1]); + } + } + + return foundServers; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/files/files.dart b/packages/ndk/lib/domain_layer/usecases/files/files.dart new file mode 100644 index 00000000..9cc0ed9f --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/files/files.dart @@ -0,0 +1,68 @@ +import '../../entities/blossom_blobs.dart'; +import '../../entities/ndk_file.dart'; +import 'blossom.dart'; + +/// high level usecase to manage files on nostr +class Files { + final Blossom blossom; + + /// Regular expression to match SHA256 in URLs + static final sha256Regex = RegExp(r'/([a-fA-F0-9]{64})(?:/|$)'); + + Files(this.blossom); + + /// upload a file to the server(s) \ + /// if [serverUrls] is null, the userServerList is fetched from nostr. \ + /// if no serverUrls (param or nostr) are found, throws an error + Future> upload({ + required NdkFile file, + List? serverUrls, + }) { + return blossom.uploadBlob( + data: file.data, + serverUrls: serverUrls, + contentType: file.mimeType, + ); + } + + /// deletes a file from the server(s) \ + /// if [serverUrls] is null, the userServerList is fetched from nostr. \ + Future> delete({ + required String sha256, + List? serverUrls, + }) { + return blossom.deleteBlob( + sha256: sha256, + serverUrls: serverUrls, + ); + } + + /// download a file from the server(s) \ + /// if its a blossom url (sha256 in url), blossom is used to download \ + /// if its a public url, the file is downloaded directly \ + /// \ + /// [serverUrls] and [pubkey] are used to download from blossom \ + /// if [serverUrls] is null, the userServerList is fetched from nostr (using the pubkey). \ + /// if both [serverUrls] and [pubkey] are null, throws an error. + Future download({ + required String url, + List? serverUrls, + String? pubkey, + }) async { + // Regular expression to match SHA256 in URLs + final sha256Match = sha256Regex.firstMatch(url); + + if (sha256Match != null) { + // This is a blossom URL, handle it using blossom protocol + final sha256 = sha256Match.group(1)!; + + // Try to download using blossom + return await blossom.getBlob( + sha256: sha256, + serverUrls: serverUrls, + pubkeyToFetchUserServerList: pubkey); + } else { + return await blossom.directDownload(url: Uri.parse(url)); + } + } +} diff --git a/packages/ndk/lib/entities.dart b/packages/ndk/lib/entities.dart index 2b3a75ae..e91bc554 100644 --- a/packages/ndk/lib/entities.dart +++ b/packages/ndk/lib/entities.dart @@ -28,3 +28,4 @@ export 'domain_layer/entities/request_response.dart'; export 'domain_layer/entities/request_state.dart'; export 'domain_layer/entities/tuple.dart'; export 'domain_layer/entities/user_relay_list.dart'; +export 'domain_layer/entities/blossom_blobs.dart'; diff --git a/packages/ndk/lib/ndk.dart b/packages/ndk/lib/ndk.dart index 0d1e69f5..17025569 100644 --- a/packages/ndk/lib/ndk.dart +++ b/packages/ndk/lib/ndk.dart @@ -35,6 +35,7 @@ export 'domain_layer/usecases/nwc/responses/pay_invoice_response.dart'; export 'domain_layer/usecases/nwc/responses/list_transactions_response.dart'; export 'domain_layer/usecases/nwc/responses/lookup_invoice_response.dart'; export 'domain_layer/usecases/nwc/nwc_connection.dart'; +export 'domain_layer/entities/blossom_blobs.dart'; /** * export classes that need to be injected @@ -70,6 +71,8 @@ export 'domain_layer/usecases/zaps/zaps.dart'; export 'domain_layer/usecases/zaps/zap_request.dart'; export 'domain_layer/usecases/zaps/zap_receipt.dart'; export 'domain_layer/usecases/zaps/invoice_response.dart'; +export 'domain_layer/usecases/files/files.dart'; +export 'domain_layer/usecases/files/blossom.dart'; /** * other stuff diff --git a/packages/ndk/lib/presentation_layer/init.dart b/packages/ndk/lib/presentation_layer/init.dart index 6c5b85b0..d30fbd60 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -1,17 +1,22 @@ import 'package:http/http.dart' as http; -import 'package:ndk/data_layer/repositories/lnurl_http_impl.dart'; -import 'package:ndk/domain_layer/repositories/lnurl_transport.dart'; import '../data_layer/data_sources/http_request.dart'; +import '../data_layer/repositories/blossom/blossom_impl.dart'; +import '../data_layer/repositories/lnurl_http_impl.dart'; import '../data_layer/repositories/nip_05_http_impl.dart'; import '../data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart'; import '../domain_layer/entities/global_state.dart'; import '../domain_layer/entities/jit_engine_relay_connectivity_data.dart'; +import '../domain_layer/repositories/blossom.dart'; +import '../domain_layer/repositories/lnurl_transport.dart'; import '../domain_layer/repositories/nip_05_repo.dart'; import '../domain_layer/usecases/broadcast/broadcast.dart'; import '../domain_layer/usecases/cache_read/cache_read.dart'; import '../domain_layer/usecases/cache_write/cache_write.dart'; import '../domain_layer/usecases/engines/network_engine.dart'; +import '../domain_layer/usecases/files/blossom.dart'; +import '../domain_layer/usecases/files/blossom_user_server_list.dart'; +import '../domain_layer/usecases/files/files.dart'; import '../domain_layer/usecases/follows/follows.dart'; import '../domain_layer/usecases/jit_engine/jit_engine.dart'; import '../domain_layer/usecases/lists/lists.dart'; @@ -60,6 +65,9 @@ class Initialization { late Nwc nwc; late Zaps zaps; late Lnurl lnurl; + late Files files; + late Blossom blossom; + late BlossomUserServerList blossomUserServerList; late VerifyNip05 verifyNip05; @@ -110,6 +118,10 @@ class Initialization { final Nip05Repository nip05repository = Nip05HttpRepositoryImpl(httpDS: _httpRequestDS); + final BlossomRepository blossomRepository = BlossomRepositoryImpl( + client: _httpRequestDS, + ); + /// use cases cacheWrite = CacheWrite(_ndkConfig.cache); cacheRead = CacheRead(_ndkConfig.cache); @@ -184,6 +196,16 @@ class Initialization { lnurl: lnurl, ); + blossomUserServerList = BlossomUserServerList(requests); + + blossom = Blossom( + blossomImpl: blossomRepository, + signer: _ndkConfig.eventSigner, + userServerList: blossomUserServerList, + ); + + files = Files(blossom); + /// set the user configured log level Logger.setLogLevel(_ndkConfig.logLevel); } diff --git a/packages/ndk/lib/presentation_layer/ndk.dart b/packages/ndk/lib/presentation_layer/ndk.dart index 6900da53..12cd2bde 100644 --- a/packages/ndk/lib/presentation_layer/ndk.dart +++ b/packages/ndk/lib/presentation_layer/ndk.dart @@ -4,6 +4,8 @@ import '../data_layer/repositories/cache_manager/mem_cache_manager.dart'; import '../data_layer/repositories/verifiers/bip340_event_verifier.dart'; import '../domain_layer/entities/global_state.dart'; import '../domain_layer/usecases/broadcast/broadcast.dart'; +import '../domain_layer/usecases/files/blossom.dart'; +import '../domain_layer/usecases/files/files.dart'; import '../domain_layer/usecases/follows/follows.dart'; import '../domain_layer/usecases/lists/lists.dart'; import '../domain_layer/usecases/metadatas/metadatas.dart'; @@ -92,6 +94,16 @@ class Ndk { /// Verifies NIP-05 events VerifyNip05 get nip05 => _initialization.verifyNip05; + /// manage files on nostr \ + /// upload, download, delete files \ + /// high level usecase, recommended for most users + Files get files => _initialization.files; + + /// Blossom usecase \ + /// upload, download, delete, list files \ + /// low level usecase, recommended for advanced users + Blossom get blossom => _initialization.blossom; + /// Nostr Wallet connect @experimental // needs more docs & tests Nwc get nwc => _initialization.nwc; diff --git a/packages/ndk/pubspec.lock b/packages/ndk/pubspec.lock index 62339a40..73ea5f66 100644 --- a/packages/ndk/pubspec.lock +++ b/packages/ndk/pubspec.lock @@ -141,10 +141,10 @@ packages: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" convert: dependency: "direct main" description: @@ -273,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" + url: "https://pub.dev" + source: hosted + version: "1.1.1" http_multi_server: dependency: transitive description: @@ -285,10 +293,10 @@ packages: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" io: dependency: transitive description: @@ -434,13 +442,13 @@ packages: source: hosted version: "0.28.0" shelf: - dependency: transitive + dependency: "direct dev" description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_packages_handler: dependency: transitive description: @@ -449,6 +457,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + shelf_router: + dependency: "direct dev" + description: + name: shelf_router + sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 + url: "https://pub.dev" + source: hosted + version: "1.1.4" shelf_static: dependency: transitive description: diff --git a/packages/ndk/pubspec.yaml b/packages/ndk/pubspec.yaml index ed4d6b89..554697bd 100644 --- a/packages/ndk/pubspec.yaml +++ b/packages/ndk/pubspec.yaml @@ -37,3 +37,7 @@ dev_dependencies: flutter_lints: ^5.0.0 mockito: ^5.0.17 test: any + # shelf used for blossom mock server + shelf: ^1.4.2 + shelf_router: ^1.1.4 + diff --git a/packages/ndk/test/mocks/mock_blossom_server.dart b/packages/ndk/test/mocks/mock_blossom_server.dart new file mode 100644 index 00000000..c5e89276 --- /dev/null +++ b/packages/ndk/test/mocks/mock_blossom_server.dart @@ -0,0 +1,279 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:shelf_router/shelf_router.dart'; + +class MockBlossomServer { + // In-memory storage for blobs + final Map _blobs = {}; + final int port; + HttpServer? _server; + + MockBlossomServer({this.port = 3000}); + + Router _createRouter() { + final router = Router(); + + // GET / - Get Blob + router.get('/', (Request request, String sha256) { + if (!_blobs.containsKey(sha256)) { + return Response.notFound('Blob not found'); + } + return Response.ok(_blobs[sha256]!.data, + headers: {'Content-Type': _blobs[sha256]!.contentType}); + }); + + // HEAD / - Has Blob + router.head('/', (Request request, String sha256) { + if (!_blobs.containsKey(sha256)) { + return Response.notFound('Blob not found'); + } + return Response(200, headers: { + 'Content-Length': _blobs[sha256]!.data.length.toString(), + 'Content-Type': _blobs[sha256]!.contentType, + }); + }); + + // PUT /upload - Upload Blob + router.put('/upload', (Request request) async { + // Check for authorization header + final authHeader = request.headers['authorization']; + + if (authHeader == null) { + return Response.forbidden('Missing authorization'); + } + + try { + final authEvent = + json.decode(utf8.decode(base64Decode(authHeader.split(' ')[1]))); + if (!_verifyAuthEvent(authEvent, 'upload')) { + return Response.forbidden('Invalid authorization event'); + } + } catch (e) { + return Response.forbidden('Invalid authorization format'); + } + + // Read the request body + final bytes = await request.read().expand((chunk) => chunk).toList(); + final data = Uint8List.fromList(bytes); + + final sha256 = _computeSha256(data); + final contentType = + request.headers['content-type'] ?? 'application/octet-stream'; + + _blobs[sha256] = _BlobEntry( + data: data, + contentType: contentType, + uploader: 'test_pubkey', + uploadedAt: DateTime.now(), + ); + + return Response.ok( + json.encode({ + 'url': 'http://localhost:$port/$sha256', + 'sha256': sha256, + 'size': data.length, + 'type': contentType, + 'uploaded': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }), + headers: {'Content-Type': 'application/json'}, + ); + }); + + // GET /list/ - List Blobs + router.get('/list/', (Request request, String pubkey) { + final since = int.tryParse(request.url.queryParameters['since'] ?? ''); + final until = int.tryParse(request.url.queryParameters['until'] ?? ''); + + final blobs = _blobs.entries + .where((entry) => entry.value.uploader == pubkey) + .where((entry) { + final timestamp = + entry.value.uploadedAt.millisecondsSinceEpoch ~/ 1000; + if (since != null && timestamp < since) return false; + if (until != null && timestamp > until) return false; + return true; + }) + .map((entry) => { + 'url': 'http://localhost:$port/${entry.key}', + 'sha256': entry.key, + 'size': entry.value.data.length, + 'type': entry.value.contentType, + 'uploaded': + entry.value.uploadedAt.millisecondsSinceEpoch ~/ 1000, + }) + .toList(); + + return Response.ok( + json.encode(blobs), + headers: {'Content-Type': 'application/json'}, + ); + }); + + // DELETE / - Delete Blob + router.delete('/', (Request request, String sha256) { + final authHeader = request.headers['authorization']; + if (authHeader == null) { + return Response.forbidden('Missing authorization'); + } + + try { + final authEvent = + json.decode(utf8.decode(base64Decode(authHeader.split(' ')[1]))); + + if (!_verifyAuthEvent(authEvent, 'delete')) { + return Response.forbidden('Invalid authorization event'); + } + } catch (e) { + return Response.forbidden('Invalid authorization format'); + } + + if (!_blobs.containsKey(sha256)) { + return Response.notFound('Blob not found'); + } + + _blobs.remove(sha256); + return Response(200); + }); + + router.post('/mirror/', (Request request, String sha256) async { + // Check for authorization header + final authHeader = request.headers['authorization']; + + if (authHeader == null) { + return Response.forbidden('Missing authorization'); + } + + try { + final authEvent = + json.decode(utf8.decode(base64Decode(authHeader.split(' ')[1]))); + if (!_verifyAuthEvent(authEvent, 'upload')) { + return Response.forbidden('Invalid authorization event'); + } + } catch (e) { + return Response.forbidden('Invalid authorization format'); + } + + // Parse the request body to get the URL + final String body = await request.readAsString(); + Map requestData; + try { + requestData = json.decode(body); + if (!requestData.containsKey('url')) { + return Response.badRequest( + body: 'Request body must contain a "url" field'); + } + } catch (e) { + return Response.badRequest(body: 'Invalid JSON body'); + } + + // Download the blob from the provided URL + try { + final sourceUrl = requestData['url']; + final httpClient = HttpClient(); + final request = await httpClient.getUrl(Uri.parse(sourceUrl)); + final response = await request.close(); + + if (response.statusCode != 200) { + return Response.internalServerError( + body: + 'Failed to download from source URL: ${response.statusCode}'); + } + + // Read the response data + final bytes = await response.expand((chunk) => chunk).toList(); + final data = Uint8List.fromList(bytes); + + // Verify the SHA256 matches + final computedSha256 = _computeSha256(data); + if (computedSha256 != sha256) { + return Response.badRequest( + body: 'SHA256 mismatch: expected $sha256, got $computedSha256'); + } + + // Store the blob + _blobs[sha256] = _BlobEntry( + data: data, + contentType: response.headers.contentType?.toString() ?? + 'application/octet-stream', + uploader: 'test_pubkey', + uploadedAt: DateTime.now(), + ); + + // Return the same descriptor format as upload + return Response.ok( + json.encode({ + 'url': 'http://localhost:$port/$sha256', + 'sha256': sha256, + 'size': data.length, + 'type': _blobs[sha256]!.contentType, + 'uploaded': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }), + headers: {'Content-Type': 'application/json'}, + ); + } catch (e) { + return Response.internalServerError( + body: 'Failed to mirror blob: ${e.toString()}'); + } + }); + + return router; + } + + Future start() async { + final handler = Pipeline() + .addMiddleware(logRequests()) + .addHandler(_createRouter().call); + + _server = await serve(handler, 'localhost', port); + print('Mock Blossom Server running on port $port'); + } + + Future stop() async { + await _server?.close(); + _server = null; + } + + // Helper methods + String _computeSha256(List data) { + return sha256.convert(data).toString(); + } + + bool _verifyAuthEvent(Map event, String type) { + // Simple verification for testing purposes + if (event['kind'] != 24242) return false; + + final tags = List>.from(event['tags']); + final hasTypeTag = + tags.any((tag) => tag.length >= 2 && tag[0] == 't' && tag[1] == type); + + return hasTypeTag; + } +} + +class _BlobEntry { + final Uint8List data; + final String contentType; + final String uploader; + final DateTime uploadedAt; + + _BlobEntry({ + required this.data, + required this.contentType, + required this.uploader, + required this.uploadedAt, + }); +} + +// Example usage in tests +void main() async { + final server = MockBlossomServer(port: 3000); + await server.start(); + + // Run your tests here + + await server.stop(); +} diff --git a/packages/ndk/test/timeouts/network_speed_test.dart b/packages/ndk/test/timeouts/network_speed_test.dart index d34347fd..9e04963a 100644 --- a/packages/ndk/test/timeouts/network_speed_test.dart +++ b/packages/ndk/test/timeouts/network_speed_test.dart @@ -21,8 +21,8 @@ void main() { group('low level - network faster then timeout', () { KeyPair key1 = Bip340.generatePrivateKey(); - MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 8201); - MockRelay relay2 = MockRelay(name: "relay 2", explicitPort: 8202); + MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 8221); + MockRelay relay2 = MockRelay(name: "relay 2", explicitPort: 8222); Map key1TextNotes = {key1: textNote(key1)}; diff --git a/packages/ndk/test/usecases/files/blossom_test.dart b/packages/ndk/test/usecases/files/blossom_test.dart new file mode 100644 index 00000000..6c349d92 --- /dev/null +++ b/packages/ndk/test/usecases/files/blossom_test.dart @@ -0,0 +1,380 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:http/http.dart' as http; + +import 'package:ndk/data_layer/data_sources/http_request.dart'; +import 'package:ndk/data_layer/repositories/blossom/blossom_impl.dart'; +import 'package:ndk/domain_layer/repositories/blossom.dart'; + +import 'package:ndk/ndk.dart'; + +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:ndk/shared/nips/nip01/key_pair.dart'; +import 'package:test/test.dart'; + +import '../../mocks/mock_blossom_server.dart'; +import '../../mocks/mock_event_verifier.dart'; + +void main() { + late MockBlossomServer server; + late MockBlossomServer server2; + late Blossom client; + + setUp(() async { + server = MockBlossomServer(port: 3000); + server2 = MockBlossomServer(port: 3001); + await server.start(); + await server2.start(); + + KeyPair key1 = Bip340.generatePrivateKey(); + final signer = Bip340EventSigner( + privateKey: key1.privateKey, publicKey: key1.publicKey); + + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.JIT, + eventSigner: signer, + ), + ); + + client = ndk.blossom; + }); + + tearDown(() async { + await server.stop(); + await server2.stop(); + }); + + group('Blossom Client Integration Tests', () { + test('Upload and retrieve blob', () async { + final testData = Uint8List.fromList(utf8.encode('Hello, Blossom!')); + + // Upload blob + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: ['http://localhost:3000'], + ); + expect(uploadResponse.first.success, true); + + final sha256 = uploadResponse.first.descriptor!.sha256; + + // Retrieve blob + final getResponse = await client.getBlob( + sha256: sha256, + serverUrls: ['http://localhost:3000'], + ); + expect(utf8.decode(getResponse.data), equals('Hello, Blossom!')); + }); + + test('Upload and retrieve blob - one out of three', () async { + final testData = Uint8List.fromList(utf8.encode('Hello World!')); + + // Upload blob + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: ['http://localhost:3000'], + ); + expect(uploadResponse.first.success, true); + + final sha256 = uploadResponse.first.descriptor!.sha256; + + // Retrieve blob + final getResponse = await client.getBlob( + sha256: sha256, + serverUrls: [ + 'http://dead.example.com', + 'http://localhost:3001', + 'http://localhost:3000', + ], + ); + expect(utf8.decode(getResponse.data), equals('Hello World!')); + }); + + test('List blobs for user', () async { + // Upload some test blobs first + final testData1 = Uint8List.fromList(utf8.encode('Test 1')); + final testData2 = Uint8List.fromList(utf8.encode('Test 2')); + + await client.uploadBlob( + data: testData1, + serverUrls: ['http://localhost:3000'], + ); + await client.uploadBlob( + data: testData2, + serverUrls: ['http://localhost:3000'], + ); + + final listResponse = await client.listBlobs( + pubkey: 'test_pubkey', + serverUrls: ['http://localhost:3000'], + ); + + expect(listResponse.length, equals(2)); + }); + + test('Delete blob', () async { + final testData = Uint8List.fromList(utf8.encode('Hello, Blossom!')); + + // Upload blob + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: ['http://localhost:3000'], + ); + expect(uploadResponse.first.success, true); + + final sha256 = uploadResponse.first.descriptor!.sha256; + + // Delete blob + final deleteResponse = await client.deleteBlob( + sha256: sha256, + serverUrls: ['http://localhost:3000'], + ); + expect(deleteResponse.first.success, true); + + // Retrieve blob + final getResponse = client.getBlob( + sha256: sha256, + serverUrls: ['http://localhost:3000'], + ); + //check that something throws an error + expect(getResponse, throwsException); + }); + }); + + group("blossom upload strategy tests", () { + test('Upload to first successful server only - firstSuccess', () async { + final testData = Uint8List.fromList(utf8.encode('strategy test')); + + // Upload blob + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: [ + 'http://dead.example.com', + 'http://localhost:3001', + 'http://localhost:3000', + ], + strategy: UploadStrategy.firstSuccess, + ); + expect(uploadResponse.first.success, true); + + final sha256 = uploadResponse.first.descriptor!.sha256; + + final deadServer = client.getBlob(sha256: sha256, serverUrls: [ + 'http://dead.example.com', + ]); + expect(deadServer, throwsException); + + final server1 = await client.getBlob(sha256: sha256, serverUrls: [ + 'http://localhost:3001', + ]); + + expect(utf8.decode(server1.data), equals('strategy test')); + + final server2 = client.getBlob(sha256: sha256, serverUrls: [ + 'http://localhost:3000', + ]); + + expect(server2, throwsException); + }); + + test('Upload to first successful server only - mirrorAfterSuccess', + () async { + final myData = "strategy test mirrorAfterSuccess"; + final testData = Uint8List.fromList(utf8.encode(myData)); + + // Upload blob + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: [ + 'http://dead.example.com', + 'http://localhost:3001', + 'http://localhost:3000', + ], + strategy: UploadStrategy.mirrorAfterSuccess, + ); + expect(uploadResponse[0].success, false); + expect(uploadResponse[1].success, true); + expect(uploadResponse[2].success, true); + + final sha256 = uploadResponse[1].descriptor!.sha256; + + final deadServer = client.getBlob(sha256: sha256, serverUrls: [ + 'http://dead.example.com', + ]); + expect(deadServer, throwsException); + + final server1 = await client.getBlob(sha256: sha256, serverUrls: [ + 'http://localhost:3001', + ]); + + expect(utf8.decode(server1.data), equals(myData)); + + final server2 = await client.getBlob(sha256: sha256, serverUrls: [ + 'http://localhost:3000', + ]); + + expect(utf8.decode(server2.data), equals(myData)); + }); + + test('Upload to first successful server only - allSimultaneous', () async { + final myData = "strategy test allSimultaneous"; + final testData = Uint8List.fromList(utf8.encode(myData)); + + // Upload blob + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: [ + 'http://dead.example.com', + 'http://localhost:3001', + 'http://localhost:3000', + ], + strategy: UploadStrategy.allSimultaneous, + ); + expect(uploadResponse[0].success, false); + expect(uploadResponse[1].success, true); + expect(uploadResponse[2].success, true); + + final sha256 = uploadResponse[1].descriptor!.sha256; + + final deadServer = client.getBlob(sha256: sha256, serverUrls: [ + 'http://dead.example.com', + ]); + expect(deadServer, throwsException); + + final server1 = await client.getBlob(sha256: sha256, serverUrls: [ + 'http://localhost:3001', + ]); + + expect(utf8.decode(server1.data), equals(myData)); + + final server2 = await client.getBlob(sha256: sha256, serverUrls: [ + 'http://localhost:3000', + ]); + + expect(utf8.decode(server2.data), equals(myData)); + }); + }); + + group("stream blobs", () { + final BlossomRepository blossomRepo = BlossomRepositoryImpl( + client: HttpRequestDS(http.Client()), + ); + test('getBlobStream should properly stream large files with range requests', + () async { + // First upload a test file to the mock server + final testData = Uint8List.fromList( + List.generate(5 * 1024 * 1024, (i) => i % 256)); // 5MB test file + + // Upload the test file + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: ['http://localhost:3000'], + ); + + expect(uploadResponse.first.success, true); + final sha256 = uploadResponse.first.descriptor!.sha256; + + // Now test the streaming download + final stream = await blossomRepo.getBlobStream( + sha256: sha256, + serverUrls: ['http://localhost:3000'], + chunkSize: 1024 * 1024, // 1MB chunks + ); + + // Collect all chunks and verify the data + final chunks = []; + int totalSize = 0; + + await for (final response in stream) { + chunks.add(response.data); + totalSize += response.data.length; + + // Verify chunk metadata + expect(response.mimeType, isNotNull); + expect(response.contentLength, equals(testData.length)); + //expect(response.contentRange, matches(RegExp(r'bytes \d+-\d+/\d+'))); + } + + // Combine chunks and verify the complete file + final resultData = Uint8List(totalSize); + int offset = 0; + for (final chunk in chunks) { + resultData.setRange(offset, offset + chunk.length, chunk); + offset += chunk.length; + } + + expect(resultData, equals(testData)); + expect(totalSize, equals(testData.length)); + }); + + test( + 'getBlobStream should fallback to regular download if range requests not supported', + () async { + // Upload a smaller test file + final testData = Uint8List.fromList( + List.generate(1024, (i) => i % 256)); // 1KB test file + + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: ['http://localhost:3000'], + ); + + expect(uploadResponse.first.success, true); + final sha256 = uploadResponse.first.descriptor!.sha256; + + // Test the streaming download + final stream = await blossomRepo.getBlobStream( + sha256: sha256, + serverUrls: ['http://localhost:3000'], + ); + + // Should receive exactly one chunk with the complete file + final chunks = await stream.toList(); + expect(chunks.length, equals(1)); + expect(chunks.first.data, equals(testData)); + }); + + test('getBlobStream should handle server errors gracefully', () async { + expect( + () => blossomRepo.getBlobStream( + sha256: 'nonexistent_sha256', + serverUrls: ['http://localhost:3000'], + ), + throwsException, + ); + }); + + test( + 'getBlobStream should try multiple servers until finding one that works', + () async { + final testData = Uint8List.fromList( + List.generate(2 * 1024 * 1024, (i) => i % 256)); // 2MB test file + + final uploadResponse = await client.uploadBlob( + data: testData, + serverUrls: ['http://localhost:3000'], + ); + + final sha256 = uploadResponse.first.descriptor!.sha256; + + // Test with multiple servers, including non-existent ones + final stream = await blossomRepo.getBlobStream( + sha256: sha256, + serverUrls: [ + 'http://nonexistent-server:3000', + 'http://localhost:3000', + 'http://another-nonexistent:3000', + ], + ); + + final receivedData = await stream + .map((response) => response.data) + .expand((chunk) => chunk) + .toList(); + + expect(Uint8List.fromList(receivedData), equals(testData)); + }); + }); +} diff --git a/packages/ndk/test/usecases/files/files_test.dart b/packages/ndk/test/usecases/files/files_test.dart new file mode 100644 index 00000000..b66c781d --- /dev/null +++ b/packages/ndk/test/usecases/files/files_test.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:ndk/domain_layer/entities/ndk_file.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:ndk/shared/nips/nip01/key_pair.dart'; +import 'package:test/test.dart'; + +import '../../mocks/mock_blossom_server.dart'; +import '../../mocks/mock_event_verifier.dart'; + +void main() { + late MockBlossomServer server; + late MockBlossomServer server2; + late Files client; + + setUp(() async { + server = MockBlossomServer(port: 3010); + server2 = MockBlossomServer(port: 3011); + await server.start(); + await server2.start(); + + KeyPair key1 = Bip340.generatePrivateKey(); + final signer = Bip340EventSigner( + privateKey: key1.privateKey, publicKey: key1.publicKey); + + final ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.JIT, + eventSigner: signer, + ), + ); + + client = ndk.files; + }); + + tearDown(() async { + await server.stop(); + await server2.stop(); + }); + + group('File Integration Tests', () { + test('no file', () async { + // download + final getResponse = client.download( + url: 'http://localhost:3000/no_file', + serverUrls: ['http://localhost:3010'], + ); + expect(getResponse, throwsA(isA())); + }); + + test('Upload and retrieve file', () async { + final testData = Uint8List.fromList(utf8.encode('Hello, File2')); + + // Upload + final uploadResponse = await client.upload( + file: NdkFile(data: testData, mimeType: 'text/plain'), + serverUrls: ['http://localhost:3010'], + ); + expect(uploadResponse.first.success, true); + + final sha256 = uploadResponse.first.descriptor!.sha256; + + // download + final getResponse = await client.download( + url: 'http://localhost:3010/$sha256', + serverUrls: ['http://localhost:3010'], + ); + + expect(utf8.decode(getResponse.data), equals('Hello, File2')); + }); + + test('Upload and delete file', () async { + final testData = Uint8List.fromList(utf8.encode('Hello, File2')); + + // Upload + final uploadResponse = await client.upload( + file: NdkFile(data: testData, mimeType: 'text/plain'), + serverUrls: ['http://localhost:3010'], + ); + expect(uploadResponse.first.success, true); + + final sha256 = uploadResponse.first.descriptor!.sha256; + + final deleteResponse = await client.delete( + sha256: sha256, + serverUrls: ['http://localhost:3010', 'http://localhost:3011'], + ); + + expect(deleteResponse.first.success, true); + + // download + final getResponse = client.download( + url: 'http://localhost:3010/$sha256', + serverUrls: [ + 'https://localhost:3011', + 'http://localhost:3010', + ], + ); + expect(getResponse, throwsA(isA())); + }); + }); +} diff --git a/packages/objectbox/pubspec.lock b/packages/objectbox/pubspec.lock index efe69acf..ba98e49a 100644 --- a/packages/objectbox/pubspec.lock +++ b/packages/objectbox/pubspec.lock @@ -401,7 +401,7 @@ packages: path: "../ndk" relative: true source: path - version: "0.2.0" + version: "0.2.4" node_preamble: dependency: transitive description: diff --git a/packages/rust_verifier/.flutter-plugins b/packages/rust_verifier/.flutter-plugins deleted file mode 100644 index 522dd75a..00000000 --- a/packages/rust_verifier/.flutter-plugins +++ /dev/null @@ -1,8 +0,0 @@ -# This is a generated file; do not edit or check into version control. -integration_test=/usr/bin/flutter/packages/integration_test/ -path_provider=/home/fmar/.pub-cache/hosted/pub.dev/path_provider-2.1.5/ -path_provider_android=/home/fmar/.pub-cache/hosted/pub.dev/path_provider_android-2.2.12/ -path_provider_foundation=/home/fmar/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.0/ -path_provider_linux=/home/fmar/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/ -path_provider_windows=/home/fmar/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/ -rust_lib_ndk=/home/fmar/workspace/ndk/packages/rust_verifier/rust_builder/ diff --git a/packages/rust_verifier/.flutter-plugins-dependencies b/packages/rust_verifier/.flutter-plugins-dependencies deleted file mode 100644 index b6416a02..00000000 --- a/packages/rust_verifier/.flutter-plugins-dependencies +++ /dev/null @@ -1 +0,0 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"integration_test","path":"/usr/bin/flutter/packages/integration_test/","native_build":true,"dependencies":[]},{"name":"path_provider_foundation","path":"/home/fmar/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.0/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"rust_lib_ndk","path":"/home/fmar/workspace/ndk/packages/rust_verifier/rust_builder/","native_build":true,"dependencies":[]}],"android":[{"name":"integration_test","path":"/usr/bin/flutter/packages/integration_test/","native_build":true,"dependencies":[]},{"name":"path_provider_android","path":"/home/fmar/.pub-cache/hosted/pub.dev/path_provider_android-2.2.12/","native_build":true,"dependencies":[]},{"name":"rust_lib_ndk","path":"/home/fmar/workspace/ndk/packages/rust_verifier/rust_builder/","native_build":true,"dependencies":[]}],"macos":[{"name":"path_provider_foundation","path":"/home/fmar/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.0/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"rust_lib_ndk","path":"/home/fmar/workspace/ndk/packages/rust_verifier/rust_builder/","native_build":true,"dependencies":[]}],"linux":[{"name":"path_provider_linux","path":"/home/fmar/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[]},{"name":"rust_lib_ndk","path":"/home/fmar/workspace/ndk/packages/rust_verifier/rust_builder/","native_build":true,"dependencies":[]}],"windows":[{"name":"path_provider_windows","path":"/home/fmar/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[]},{"name":"rust_lib_ndk","path":"/home/fmar/workspace/ndk/packages/rust_verifier/rust_builder/","native_build":true,"dependencies":[]}],"web":[]},"dependencyGraph":[{"name":"integration_test","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"rust_lib_ndk","dependencies":[]}],"date_created":"2024-12-18 00:22:24.640813","version":"3.27.0","swift_package_manager_enabled":false} \ No newline at end of file diff --git a/packages/rust_verifier/pubspec.lock b/packages/rust_verifier/pubspec.lock index cc8561c6..7de95040 100644 --- a/packages/rust_verifier/pubspec.lock +++ b/packages/rust_verifier/pubspec.lock @@ -477,7 +477,7 @@ packages: path: "../ndk" relative: true source: path - version: "0.2.0" + version: "0.2.4" package_config: dependency: transitive description: