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
b56ce68167
commit
7fce1ce6f0
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';
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue