From 5bc1715637409a7c4e49ed59cef333a3882a6ad7 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 21:04:47 +0200 Subject: [PATCH] feat(commons_core): Blossom MediaTransport for offer photos Add a MediaTransport interface (sibling of Offer/Message/Trust transports) and a Blossom (BUD-01/02) backend: sign a kind-24242 upload auth with the same secp256k1 identity, PUT the bytes over HTTP, return the content- addressed URL. Idempotent by SHA-256; http.Client injected for tests. --- packages/commons_core/lib/commons_core.dart | 2 + .../lib/src/social/media_transport.dart | 22 ++++ .../social/nostr/blossom_media_transport.dart | 114 ++++++++++++++++++ packages/commons_core/pubspec.yaml | 5 + .../social/blossom_media_transport_test.dart | 90 ++++++++++++++ 5 files changed, 233 insertions(+) create mode 100644 packages/commons_core/lib/src/social/media_transport.dart create mode 100644 packages/commons_core/lib/src/social/nostr/blossom_media_transport.dart create mode 100644 packages/commons_core/test/social/blossom_media_transport_test.dart diff --git a/packages/commons_core/lib/commons_core.dart b/packages/commons_core/lib/commons_core.dart index 515a513..e3e713b 100644 --- a/packages/commons_core/lib/commons_core.dart +++ b/packages/commons_core/lib/commons_core.dart @@ -13,7 +13,9 @@ export 'src/identity/nostr_key_derivation.dart'; export 'src/ids/id_gen.dart'; export 'src/social/certification.dart'; export 'src/social/geohash.dart'; +export 'src/social/media_transport.dart'; export 'src/social/message_transport.dart'; +export 'src/social/nostr/blossom_media_transport.dart'; export 'src/social/nostr/nostr_channel.dart'; export 'src/social/nostr/nostr_connection.dart'; export 'src/social/nostr/nostr_message_transport.dart'; diff --git a/packages/commons_core/lib/src/social/media_transport.dart b/packages/commons_core/lib/src/social/media_transport.dart new file mode 100644 index 0000000..4e7c044 --- /dev/null +++ b/packages/commons_core/lib/src/social/media_transport.dart @@ -0,0 +1,22 @@ +import 'dart:typed_data'; + +/// Uploads a public image and returns a URL peers can fetch — the hosting seam +/// for offer photos. An [Offer] carries only a URL on the wire (NIP-99), never +/// bytes; this transport turns local bytes into that URL. +/// +/// A sibling of [OfferTransport]/`MessageTransport`/`TrustTransport`, but it does +/// NOT ride the relay websocket: media goes over plain HTTP to a media server +/// (Blossom), so it takes its own endpoint rather than a `NostrChannel`. +/// +/// Uploading a photo here publishes it: an offer is a public shop window the +/// person elected to reveal, so its image is public too. This is distinct from +/// the encrypted-at-rest inventory — nothing local is exposed by this seam. +abstract interface class MediaTransport { + /// Uploads [bytes] and returns the public URL. Expected to be idempotent for + /// content-addressed backends (re-uploading the same bytes yields the same + /// URL). Throws when the server rejects the upload or is unreachable — callers + /// degrade by publishing the offer without an image. + Future upload(Uint8List bytes, {required String mimeType}); + + Future close(); +} diff --git a/packages/commons_core/lib/src/social/nostr/blossom_media_transport.dart b/packages/commons_core/lib/src/social/nostr/blossom_media_transport.dart new file mode 100644 index 0000000..e80aabc --- /dev/null +++ b/packages/commons_core/lib/src/social/nostr/blossom_media_transport.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart' as crypto; +import 'package:http/http.dart' as http; +import 'package:nostr/nostr.dart'; + +import '../media_transport.dart'; + +/// [MediaTransport] backed by a Blossom media server (BUD-01/02). +/// +/// Blossom is content-addressed: a blob's URL is derived from its SHA-256, so +/// uploads are idempotent (the server dedups) and the URL is deterministic. We +/// authorize each upload with a signed kind-24242 event — the same secp256k1 +/// identity that signs offers — and PUT the bytes; no relay is involved. +class BlossomMediaTransport implements MediaTransport { + BlossomMediaTransport( + Uri server, + this._secretKeyHex, { + http.Client? client, + Duration authTtl = const Duration(hours: 1), + }) : _server = _normalize(server), + _client = client ?? http.Client(), + _ownsClient = client == null, + _authTtl = authTtl; + + /// Blossom kind for upload/list/delete authorization events (BUD-01). + static const authKind = 24242; + + final Uri _server; + final String _secretKeyHex; + final http.Client _client; + final bool _ownsClient; + final Duration _authTtl; + + @override + Future upload(Uint8List bytes, {required String mimeType}) async { + final sha = crypto.sha256.convert(bytes).toString(); + final auth = _authEvent(sha); + final response = await _client.put( + _server.resolve('upload'), + headers: { + 'Authorization': 'Nostr ${base64.encode(utf8.encode(auth.toJson()))}', + 'Content-Type': mimeType, + }, + body: bytes, + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw MediaUploadException( + 'Blossom upload failed (${response.statusCode})', + response.statusCode, + ); + } + return _urlFrom(response.body, sha); + } + + /// Signed kind-24242 authorization: declares an `upload`, the blob's hash, and + /// an expiry so a captured header can't be replayed forever. + Event _authEvent(String sha256hex) { + final expiry = + DateTime.now().add(_authTtl).millisecondsSinceEpoch ~/ 1000; + return Event.from( + kind: authKind, + content: 'Upload offer photo', + tags: [ + ['t', 'upload'], + ['x', sha256hex], + ['expiration', '$expiry'], + ], + secretKey: _secretKeyHex, + ); + } + + /// Prefers the `url` from the blob descriptor the server returns; falls back to + /// the deterministic `/` when the body is missing or unparsable. + Uri _urlFrom(String body, String sha256hex) { + if (body.isNotEmpty) { + try { + final decoded = json.decode(body); + if (decoded is Map && decoded['url'] is String) { + final url = (decoded['url'] as String).trim(); + if (url.isNotEmpty) return Uri.parse(url); + } + } on FormatException { + // Non-JSON body — fall through to the content-addressed URL. + } + } + return _server.resolve(sha256hex); + } + + static Uri _normalize(Uri server) { + // Ensure a trailing slash so `resolve('upload')` appends rather than + // replacing the last path segment. + if (server.path.endsWith('/')) return server; + return server.replace(path: '${server.path}/'); + } + + @override + Future close() async { + if (_ownsClient) _client.close(); + } +} + +/// Thrown when the media server rejects an upload. Callers treat it as "publish +/// the offer without a photo", never as a hard failure of sharing. +class MediaUploadException implements Exception { + const MediaUploadException(this.message, this.statusCode); + + final String message; + final int statusCode; + + @override + String toString() => 'MediaUploadException($statusCode): $message'; +} diff --git a/packages/commons_core/pubspec.yaml b/packages/commons_core/pubspec.yaml index ef9d76a..7a522b4 100644 --- a/packages/commons_core/pubspec.yaml +++ b/packages/commons_core/pubspec.yaml @@ -21,6 +21,11 @@ dependencies: # signing, NIP-19, NIP-44, gift wrap — the vetted crypto for the social layer # transport. We keep the relay/websocket layer and our derivation on top. nostr: ^2.0.0 + # Plain HTTP (BSD-3, AGPL-compatible) for the Blossom media server — offer + # photos are uploaded over HTTP, not the relay websocket. Client is injectable. + http: ^1.2.0 + # Sync SHA-256 (BSD-3) for content-addressed Blossom blob ids. + crypto: ^3.0.3 dev_dependencies: lints: ^6.0.0 diff --git a/packages/commons_core/test/social/blossom_media_transport_test.dart b/packages/commons_core/test/social/blossom_media_transport_test.dart new file mode 100644 index 0000000..15ea2d1 --- /dev/null +++ b/packages/commons_core/test/social/blossom_media_transport_test.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:commons_core/commons_core.dart'; +import 'package:crypto/crypto.dart' as crypto; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart'; +import 'package:test/test.dart'; + +void main() { + Future secretKey() async { + final id = await NostrKeyDerivation.deriveFromSeed( + Uint8List(32)..fillRange(0, 32, 7), + ); + return id.privateKeyHex; + } + + final bytes = Uint8List.fromList(List.generate(64, (i) => i)); + final sha = crypto.sha256.convert(bytes).toString(); + + test('PUTs to /upload with a signed kind-24242 auth and returns the url', + () async { + late http.Request captured; + final client = MockClient((req) async { + captured = req; + return http.Response( + json.encode({'url': 'https://media.test/$sha.jpg', 'sha256': sha}), + 200, + ); + }); + final transport = BlossomMediaTransport( + Uri.parse('https://media.test'), + await secretKey(), + client: client, + ); + + final url = await transport.upload(bytes, mimeType: 'image/jpeg'); + + expect(url, Uri.parse('https://media.test/$sha.jpg')); + expect(captured.method, 'PUT'); + expect(captured.url, Uri.parse('https://media.test/upload')); + expect(captured.bodyBytes, bytes); + expect(captured.headers['content-type'], startsWith('image/jpeg')); + + // The Authorization header is a base64 Nostr event; decode and inspect it. + final header = captured.headers['authorization']!; + expect(header, startsWith('Nostr ')); + final event = Event.fromMap( + json.decode(utf8.decode(base64.decode(header.substring(6)))) + as Map, + verify: true, // a real Schnorr signature over the canonical id + ); + expect(event.kind, BlossomMediaTransport.authKind); + String? tag(String name) => event.tags + .firstWhere((t) => t.isNotEmpty && t[0] == name, orElse: () => const []) + .elementAtOrNull(1); + expect(tag('t'), 'upload'); + expect(tag('x'), sha); + expect(tag('expiration'), isNotNull); + }); + + test('falls back to / when the body carries no url', () async { + final client = MockClient((req) async => http.Response('', 200)); + final transport = BlossomMediaTransport( + Uri.parse('https://media.test/'), + await secretKey(), + client: client, + ); + + final url = await transport.upload(bytes, mimeType: 'image/jpeg'); + + expect(url, Uri.parse('https://media.test/$sha')); + }); + + test('throws MediaUploadException on a non-2xx response', () async { + final client = MockClient((req) async => http.Response('nope', 413)); + final transport = BlossomMediaTransport( + Uri.parse('https://media.test'), + await secretKey(), + client: client, + ); + + expect( + () => transport.upload(bytes, mimeType: 'image/jpeg'), + throwsA(isA() + .having((e) => e.statusCode, 'statusCode', 413)), + ); + }); +}