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.
This commit is contained in:
vjrj 2026-07-10 21:04:47 +02:00
parent 0150a7ce02
commit 5bc1715637
5 changed files with 233 additions and 0 deletions

View file

@ -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';

View file

@ -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<Uri> upload(Uint8List bytes, {required String mimeType});
Future<void> close();
}

View file

@ -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<Uri> 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 `<server>/<sha256>` 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<void> 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';
}

View file

@ -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

View file

@ -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<String> secretKey() async {
final id = await NostrKeyDerivation.deriveFromSeed(
Uint8List(32)..fillRange(0, 32, 7),
);
return id.privateKeyHex;
}
final bytes = Uint8List.fromList(List<int>.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<String, dynamic>,
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 <server>/<sha256> 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<MediaUploadException>()
.having((e) => e.statusCode, 'statusCode', 413)),
);
});
}