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