feat(market): embed offer photos inline instead of a media server

Replace the Blossom media-server approach with a fully decentralized one:
generate a small (~320px) JPEG thumbnail from the lot's cover photo and
embed it in the offer event as a data: URI, so photos ride the relays with
no server and no IP leak. The full photo stays in the encrypted inventory.

- offer_thumbnail.dart: downscale-until-it-fits data-URI builder + decoder
- market_widgets: render data: URIs from memory, http(s) URLs from network
- offers_cubit: publish a thumbnail (best-effort) in place of an upload
- drop MediaTransport/Blossom from commons_core (http/crypto deps) and the
  media-server setting; parked on branch parked/offer-photos-blossom

Relay event-size limits cap the image to a thumbnail; higher-res sharing
would need the parked Blossom path.
This commit is contained in:
vjrj 2026-07-10 21:27:59 +02:00
parent 5017ea51e0
commit 2b419e6516
24 changed files with 230 additions and 411 deletions

View file

@ -13,9 +13,7 @@ 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

@ -1,22 +0,0 @@
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

@ -1,114 +0,0 @@
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';
}