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:
parent
0150a7ce02
commit
5bc1715637
5 changed files with 233 additions and 0 deletions
|
|
@ -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';
|
||||
|
|
|
|||
22
packages/commons_core/lib/src/social/media_transport.dart
Normal file
22
packages/commons_core/lib/src/social/media_transport.dart
Normal 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();
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue