feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,443 @@
import 'dart:convert';
import 'dart:typed_data';
const _pngSignature = <int>[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const _allowedPngAncillaryChunks = {
'cHRM',
'gAMA',
'sBIT',
'sRGB',
'bKGD',
'hIST',
'tRNS',
'sPLT',
'acTL',
'fcTL',
'fdAT',
};
const _allowedWebpChunks = {'VP8 ', 'VP8L', 'VP8X', 'ALPH', 'ANIM', 'ANMF'};
const _webpMetadataFlags = 0x20 | 0x08 | 0x04;
/// Remove metadata from an animated image without decoding its frames.
///
/// Decoding through UIKit or Android Bitmap would flatten animations. These
/// structural scrubbers retain only the chunks/extensions accepted by the
/// relay and preserve animation timing, disposal, looping, and frame data.
Uint8List sanitizeAnimatedImageForUpload(Uint8List bytes, String mimeType) {
return switch (mimeType) {
'image/gif' => _scrubGif(bytes),
'image/png' => _scrubPng(bytes),
'image/webp' => _scrubWebp(bytes),
_ => throw FormatException('Unsupported animated image type: $mimeType'),
};
}
Uint8List _scrubPng(Uint8List bytes) {
if (!_startsWith(bytes, _pngSignature)) {
throw const FormatException('Invalid PNG signature');
}
final output = BytesBuilder(copy: false)..add(_pngSignature);
var offset = _pngSignature.length;
while (offset < bytes.length) {
if (bytes.length - offset < 12) {
throw const FormatException('Truncated PNG chunk');
}
final payloadLength = _readUint32BigEndian(bytes, offset);
final chunkLength = payloadLength + 12;
if (payloadLength > bytes.length - offset - 12) {
throw const FormatException('Invalid PNG chunk length');
}
final typeStart = offset + 4;
final type = ascii.decode(bytes.sublist(typeStart, typeStart + 4));
if (type == 'iCCP') {
throw const FormatException(
'Animated PNG ICC profile cannot be removed safely',
);
}
if (type == 'eXIf') {
final orientation = _readExifOrientation(
bytes,
offset + 8,
payloadLength,
);
if (orientation != null && orientation >= 2 && orientation <= 8) {
throw const FormatException(
'Animated PNG EXIF orientation cannot be removed safely',
);
}
}
final isAncillary = bytes[typeStart] & 0x20 != 0;
if (!isAncillary || _allowedPngAncillaryChunks.contains(type)) {
output.add(Uint8List.sublistView(bytes, offset, offset + chunkLength));
}
offset += chunkLength;
if (type == 'IEND') {
return output.takeBytes();
}
}
throw const FormatException('PNG is missing IEND');
}
Uint8List _scrubWebp(Uint8List bytes) {
if (bytes.length < 12 ||
!_matchesAscii(bytes, 0, 'RIFF') ||
!_matchesAscii(bytes, 8, 'WEBP')) {
throw const FormatException('Invalid WebP signature');
}
final declaredLength = _readUint32LittleEndian(bytes, 4);
final inputEnd = declaredLength + 8;
if (inputEnd < 12 || inputEnd > bytes.length) {
throw const FormatException('Invalid WebP container length');
}
final chunks = BytesBuilder(copy: false);
var offset = 12;
while (offset < inputEnd) {
if (inputEnd - offset < 8) {
throw const FormatException('Truncated WebP chunk');
}
final type = ascii.decode(bytes.sublist(offset, offset + 4));
final payloadLength = _readUint32LittleEndian(bytes, offset + 4);
final payloadStart = offset + 8;
final paddedLength = payloadLength + (payloadLength.isOdd ? 1 : 0);
final chunkEnd = payloadStart + paddedLength;
if (chunkEnd > inputEnd) {
throw const FormatException('Invalid WebP chunk length');
}
if (type == 'EXIF') {
final orientation = _readExifOrientation(
bytes,
payloadStart,
payloadLength,
);
if (orientation != null && orientation >= 2 && orientation <= 8) {
throw const FormatException(
'Animated WebP EXIF orientation cannot be removed safely',
);
}
}
if (type == 'ICCP') {
throw const FormatException(
'Animated WebP ICC profile cannot be removed safely',
);
}
if (_allowedWebpChunks.contains(type)) {
if (type == 'VP8X') {
if (payloadLength == 0) {
throw const FormatException('Invalid VP8X chunk');
}
final payload = BytesBuilder(copy: false)
..addByte(bytes[payloadStart] & ~_webpMetadataFlags)
..add(
Uint8List.sublistView(
bytes,
payloadStart + 1,
payloadStart + payloadLength,
),
);
_addWebpChunk(chunks, type, payload.takeBytes());
} else if (type == 'ANMF') {
_addWebpChunk(
chunks,
type,
_scrubAnmfPayload(
Uint8List.sublistView(
bytes,
payloadStart,
payloadStart + payloadLength,
),
),
);
} else {
_addWebpChunk(
chunks,
type,
Uint8List.sublistView(
bytes,
payloadStart,
payloadStart + payloadLength,
),
);
}
}
offset = chunkEnd;
}
final chunkBytes = chunks.takeBytes();
final output = BytesBuilder(copy: false)
..add(ascii.encode('RIFF'))
..add(_uint32LittleEndian(chunkBytes.length + 4))
..add(ascii.encode('WEBP'))
..add(chunkBytes);
return output.takeBytes();
}
void _addWebpChunk(BytesBuilder output, String type, Uint8List payload) {
output
..add(ascii.encode(type))
..add(_uint32LittleEndian(payload.length))
..add(payload);
if (payload.length.isOdd) output.addByte(0);
}
Uint8List _scrubAnmfPayload(Uint8List payload) {
const frameHeaderLength = 16;
if (payload.length < frameHeaderLength) {
throw const FormatException('Invalid WebP animation frame');
}
final output = BytesBuilder(copy: false)
..add(Uint8List.sublistView(payload, 0, frameHeaderLength));
var offset = frameHeaderLength;
var sawAlpha = false;
var sawImage = false;
while (offset < payload.length) {
if (payload.length - offset < 8) {
throw const FormatException('Truncated WebP animation frame chunk');
}
final chunkLength = _readUint32LittleEndian(payload, offset + 4);
final chunkStart = offset + 8;
final paddedLength = chunkLength + (chunkLength.isOdd ? 1 : 0);
final chunkEnd = chunkStart + paddedLength;
if (chunkEnd > payload.length) {
throw const FormatException('Invalid WebP animation frame chunk length');
}
final chunkPayload = Uint8List.sublistView(
payload,
chunkStart,
chunkStart + chunkLength,
);
if (_matchesAscii(payload, offset, 'ALPH')) {
if (sawAlpha || sawImage) {
throw const FormatException('Invalid WebP animation frame layout');
}
_addWebpChunk(output, 'ALPH', chunkPayload);
sawAlpha = true;
} else if (_matchesAscii(payload, offset, 'VP8 ')) {
if (sawImage) {
throw const FormatException('Invalid WebP animation frame layout');
}
_addWebpChunk(output, 'VP8 ', chunkPayload);
sawImage = true;
} else if (_matchesAscii(payload, offset, 'VP8L')) {
if (sawAlpha || sawImage) {
throw const FormatException('Invalid WebP animation frame layout');
}
_addWebpChunk(output, 'VP8L', chunkPayload);
sawImage = true;
}
offset = chunkEnd;
}
if (!sawImage) {
throw const FormatException('WebP animation frame is missing image data');
}
return output.takeBytes();
}
int? _readExifOrientation(
Uint8List bytes,
int payloadStart,
int payloadLength,
) {
final payloadEnd = payloadStart + payloadLength;
var tiffStart = payloadStart;
if (payloadLength >= 6 &&
_matchesAscii(bytes, payloadStart, 'Exif') &&
bytes[payloadStart + 4] == 0 &&
bytes[payloadStart + 5] == 0) {
tiffStart += 6;
}
if (payloadEnd - tiffStart < 8) return null;
final endian = switch ((bytes[tiffStart], bytes[tiffStart + 1])) {
(0x49, 0x49) => Endian.little,
(0x4d, 0x4d) => Endian.big,
_ => null,
};
if (endian == null) return null;
int? readUint16(int offset) {
if (offset < tiffStart || offset + 2 > payloadEnd) return null;
return ByteData.sublistView(bytes, offset, offset + 2).getUint16(0, endian);
}
int? readUint32(int offset) {
if (offset < tiffStart || offset + 4 > payloadEnd) return null;
return ByteData.sublistView(bytes, offset, offset + 4).getUint32(0, endian);
}
if (readUint16(tiffStart + 2) != 42) return null;
final ifdOffset = readUint32(tiffStart + 4);
if (ifdOffset == null) return null;
final ifdStart = tiffStart + ifdOffset;
final entryCount = readUint16(ifdStart);
if (entryCount == null) return null;
final entriesStart = ifdStart + 2;
for (var index = 0; index < entryCount; index += 1) {
final entryStart = entriesStart + index * 12;
if (readUint16(entryStart) == 0x0112 &&
readUint16(entryStart + 2) == 3 &&
readUint32(entryStart + 4) == 1) {
return readUint16(entryStart + 8);
}
}
return null;
}
Uint8List _scrubGif(Uint8List bytes) {
if (bytes.length < 13 ||
(!_matchesAscii(bytes, 0, 'GIF87a') &&
!_matchesAscii(bytes, 0, 'GIF89a'))) {
throw const FormatException('Invalid GIF signature');
}
var offset = 13;
final packed = bytes[10];
if (packed & 0x80 != 0) {
final tableLength = 3 << ((packed & 0x07) + 1);
offset += tableLength;
if (offset > bytes.length) {
throw const FormatException('Truncated GIF color table');
}
}
final segments = <Uint8List?>[Uint8List.sublistView(bytes, 0, offset)];
final pendingGraphicControls = <int>[];
while (offset < bytes.length) {
switch (bytes[offset]) {
case 0x2c:
final start = offset;
if (bytes.length - offset < 10) {
throw const FormatException('Truncated GIF image descriptor');
}
final imagePacked = bytes[offset + 9];
offset += 10;
if (imagePacked & 0x80 != 0) {
offset += 3 << ((imagePacked & 0x07) + 1);
if (offset > bytes.length) {
throw const FormatException('Truncated GIF local color table');
}
}
if (offset >= bytes.length) {
throw const FormatException('Missing GIF LZW code size');
}
offset = _gifSubBlocksEnd(bytes, offset + 1);
segments.add(Uint8List.sublistView(bytes, start, offset));
pendingGraphicControls.clear();
case 0x21:
final start = offset;
if (bytes.length - offset < 2) {
throw const FormatException('Truncated GIF extension');
}
final label = bytes[offset + 1];
offset += 2;
switch (label) {
case 0xf9:
if (bytes.length - offset < 6 ||
bytes[offset] != 4 ||
bytes[offset + 5] != 0) {
throw const FormatException('Invalid GIF graphic control');
}
offset += 6;
segments.add(Uint8List.sublistView(bytes, start, offset));
pendingGraphicControls.add(segments.length - 1);
case 0xff:
if (bytes.length - offset < 12 || bytes[offset] != 11) {
throw const FormatException('Invalid GIF application extension');
}
final isLoopExtension =
_matchesAscii(bytes, offset + 1, 'NETSCAPE2.0') ||
_matchesAscii(bytes, offset + 1, 'ANIMEXTS1.0');
final dataStart = offset + 12;
offset = _gifSubBlocksEnd(bytes, dataStart);
if (isLoopExtension) {
if (bytes.length - dataStart < 5 ||
bytes[dataStart] != 3 ||
bytes[dataStart + 1] != 1) {
throw const FormatException('Invalid GIF loop extension');
}
segments
..add(Uint8List.sublistView(bytes, start, dataStart + 4))
..add(Uint8List(1));
}
case 0x01:
offset = _gifSubBlocksEnd(bytes, offset);
for (final segmentIndex in pendingGraphicControls) {
segments[segmentIndex] = null;
}
pendingGraphicControls.clear();
default:
offset = _gifSubBlocksEnd(bytes, offset);
}
case 0x3b:
segments.add(Uint8List.sublistView(bytes, offset, offset + 1));
final output = BytesBuilder(copy: false);
for (final segment in segments) {
if (segment != null) output.add(segment);
}
return output.takeBytes();
default:
throw const FormatException('Invalid GIF block');
}
}
throw const FormatException('GIF is missing trailer');
}
int _gifSubBlocksEnd(Uint8List bytes, int offset) {
while (offset < bytes.length) {
final blockLength = bytes[offset];
offset += 1;
if (blockLength == 0) return offset;
offset += blockLength;
if (offset > bytes.length) {
throw const FormatException('Truncated GIF data block');
}
}
throw const FormatException('GIF data blocks are missing a terminator');
}
bool _startsWith(Uint8List bytes, List<int> prefix) {
if (bytes.length < prefix.length) return false;
for (var index = 0; index < prefix.length; index += 1) {
if (bytes[index] != prefix[index]) return false;
}
return true;
}
bool _matchesAscii(Uint8List bytes, int offset, String value) {
final expected = ascii.encode(value);
if (bytes.length - offset < expected.length) return false;
for (var index = 0; index < expected.length; index += 1) {
if (bytes[offset + index] != expected[index]) return false;
}
return true;
}
int _readUint32BigEndian(Uint8List bytes, int offset) {
return ByteData.sublistView(bytes, offset, offset + 4).getUint32(0);
}
int _readUint32LittleEndian(Uint8List bytes, int offset) {
return ByteData.sublistView(
bytes,
offset,
offset + 4,
).getUint32(0, Endian.little);
}
Uint8List _uint32LittleEndian(int value) {
return Uint8List(4)..buffer.asByteData().setUint32(0, value, Endian.little);
}
@@ -0,0 +1,55 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'relay_session.dart';
/// Tracks the app lifecycle state and drives websocket connect/disconnect
/// behavior for mobile battery efficiency.
class AppLifecycleNotifier extends Notifier<AppLifecycleState> {
AppLifecycleListener? _listener;
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
@override
AppLifecycleState build() {
_listener = AppLifecycleListener(onStateChange: _onStateChange);
// Watch connectivity changes to trigger reconnect on network restore.
_connectivitySub = Connectivity().onConnectivityChanged.listen((results) {
final hasNetwork = results.any((r) => r != ConnectivityResult.none);
if (hasNetwork && state == AppLifecycleState.resumed) {
ref.read(relaySessionProvider.notifier).onAppResumed();
}
});
ref.onDispose(() {
_listener?.dispose();
_connectivitySub?.cancel();
});
return AppLifecycleState.resumed;
}
void _onStateChange(AppLifecycleState newState) {
state = newState;
final session = ref.read(relaySessionProvider.notifier);
switch (newState) {
case AppLifecycleState.resumed:
session.onAppResumed();
case AppLifecycleState.paused:
case AppLifecycleState.detached:
session.onAppPaused();
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
break; // Brief transition states — no action.
}
}
}
final appLifecycleProvider =
NotifierProvider<AppLifecycleNotifier, AppLifecycleState>(
AppLifecycleNotifier.new,
);
@@ -0,0 +1,50 @@
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
/// Reads an identity-scoped preference, migrating values left under a
/// pre-canonicalization relay origin.
///
/// Identity-scoped keys embed the relay origin, and that origin used to be
/// whatever scheme the onboarding flow happened to persist — `wss://` for an
/// invite join, `https://` for device pairing. Now that [RelayConfig.baseUrl]
/// canonicalizes the scheme, an install that joined by invite would compute a
/// different key than the one its data was written under, leaving that data on
/// disk but unreachable.
///
/// The value under [canonicalKey] wins. Otherwise a value under [legacyKey] is
/// returned straight away and promoted onto the canonical key in the
/// background. The legacy entry is removed only once the copy reports success,
/// so a migration interrupted mid-flight leaves the original readable and the
/// next read simply retries.
///
/// Pass matching accessors for the stored type — `getString`/`setString`, or
/// `getStringList`/`setStringList`.
T? readMigratedPref<T>(
SharedPreferences prefs, {
required String canonicalKey,
required String legacyKey,
required T? Function(String key) read,
required Future<bool> Function(String key, T value) write,
}) {
final canonical = read(canonicalKey);
if (canonical != null) return canonical;
// Pairing-created communities already store an HTTP origin, so the two keys
// coincide and there is nothing to migrate.
if (canonicalKey == legacyKey) return null;
final legacy = read(legacyKey);
if (legacy == null) return null;
unawaited(_promote(prefs, legacyKey, write(canonicalKey, legacy)));
return legacy;
}
Future<void> _promote(
SharedPreferences prefs,
String legacyKey,
Future<bool> copy,
) async {
if (await copy) await prefs.remove(legacyKey);
}
+164
View File
@@ -0,0 +1,164 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'relay_provider.dart';
const _mediaGetAuthKind = 24242;
const _mediaGetAuthLifetimeSeconds = 600;
/// Re-sign this long before the cached auth event expires, so an in-flight
/// request signed just before the boundary still lands well within validity.
const _mediaGetAuthRefreshMarginSeconds = 60;
/// Builds BUD-01 Blossom `t=get` auth headers for relay-host media URLs.
///
/// Returns an empty map for non-relay URLs or when no signing key is available,
/// so callers can safely use this on arbitrary profile/custom-emoji URLs without
/// leaking Buzz credentials to third-party hosts.
///
/// The signed header is memoized until [_mediaGetAuthRefreshMarginSeconds]
/// before expiry: repeated calls return the byte-identical map instead of
/// producing a fresh Schnorr signature per widget build. The service itself is
/// rebuilt (dropping the memo) whenever the relay config — base URL or signing
/// identity — changes, via [mediaGetAuthServiceProvider].
class MediaGetAuthService {
final String _baseUrl;
final String? _nsec;
final DateTime Function() _now;
Map<String, String>? _cachedHeaders;
DateTime? _refreshAt;
MediaGetAuthService({
required String baseUrl,
required String? nsec,
DateTime Function()? now,
}) : _baseUrl = baseUrl,
_nsec = nsec,
_now = now ?? DateTime.now;
bool isRelayMediaUrl(String url) {
final uri = Uri.tryParse(url);
final relayUri = Uri.tryParse(_baseUrl);
if (uri == null || relayUri == null) return false;
return _isRelayMediaUrl(uri, relayUri);
}
Map<String, String> headersFor(String url) {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) return const {};
if (!isRelayMediaUrl(url)) return const {};
final cached = _cachedHeaders;
final refreshAt = _refreshAt;
if (cached != null && refreshAt != null && _now().isBefore(refreshAt)) {
return cached;
}
try {
final signedAt = _now();
final authEvent = _buildGetAuthEvent(nsec);
final encoded = base64Url
.encode(utf8.encode(authEvent.toJson()))
.replaceAll('=', '');
final headers = Map<String, String>.unmodifiable({
'Authorization': 'Nostr $encoded',
});
_cachedHeaders = headers;
_refreshAt = signedAt.add(
const Duration(
seconds:
_mediaGetAuthLifetimeSeconds - _mediaGetAuthRefreshMarginSeconds,
),
);
return headers;
} catch (_) {
// Read auth is best-effort: while the relay rollout flag is off, an
// unsigned fetch still works. Once the flag is on, this request will 403
// instead of crashing the widget tree because local key material is bad.
return const {};
}
}
bool _isRelayMediaUrl(Uri uri, Uri relayUri) {
if (uri.scheme != 'http' && uri.scheme != 'https') return false;
if (uri.host.isEmpty || relayUri.host.isEmpty) return false;
// Extract the URL's origin and path. Query strings are ignored for media
// host/path detection, matching the fetch target shape used by descriptors.
final base = '${uri.scheme}://${uri.authority}';
final mediaAuthority = extractServerAuthority(base);
final relayAuthority = extractServerAuthority(_baseUrl);
if (mediaAuthority == null || relayAuthority == null) return false;
if (mediaAuthority.toLowerCase() != relayAuthority.toLowerCase()) {
return false;
}
return uri.path.startsWith('/media/');
}
nostr.Event _buildGetAuthEvent(String nsec) {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
final expiration =
(_now().millisecondsSinceEpoch ~/ 1000) + _mediaGetAuthLifetimeSeconds;
final tags = <List<String>>[
['t', 'get'],
['expiration', '$expiration'],
if (extractServerAuthority(_baseUrl) case final authority?)
['server', authority],
];
return nostr.Event.from(
kind: _mediaGetAuthKind,
content: 'Get buzz-media',
tags: tags,
secretKey: privkeyHex,
verify: false,
);
}
}
final mediaGetAuthServiceProvider = Provider<MediaGetAuthService>((ref) {
final config = ref.watch(relayConfigProvider);
return MediaGetAuthService(baseUrl: config.baseUrl, nsec: config.nsec);
});
Map<String, String> mediaGetHeadersFor(WidgetRef ref, String url) {
return ref.read(mediaGetAuthServiceProvider).headersFor(url);
}
Map<String, String> mediaGetHeadersForContext(
BuildContext context,
String url,
) {
final container = ProviderScope.containerOf(context, listen: false);
return container.read(mediaGetAuthServiceProvider).headersFor(url);
}
String? extractServerAuthority(String baseUrl) {
final uri = Uri.parse(baseUrl);
if (uri.host.isEmpty) return null;
final host = uri.host.contains(':') ? '[${uri.host}]' : uri.host;
final port = uri.hasPort ? uri.port : null;
final authority = port == null ? host : '$host:$port';
return _normalizeAuthority(authority);
}
String _normalizeAuthority(String authority) {
var normalized = authority.trim().toLowerCase();
if (normalized.endsWith('.')) {
normalized = normalized.substring(0, normalized.length - 1);
}
if (normalized.endsWith(':443')) {
return normalized.substring(0, normalized.length - ':443'.length);
}
if (normalized.endsWith(':80')) {
return normalized.substring(0, normalized.length - ':80'.length);
}
return normalized;
}
+238
View File
@@ -0,0 +1,238 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'media_auth.dart';
/// Shared, keep-alive HTTP client for media fetches. One client per container
/// so TLS connections are reused across images instead of re-handshaking per
/// fetch. Override in tests to stub the network.
final mediaHttpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});
/// An [ImageProvider] for relay-hosted (and arbitrary remote) media that fixes
/// the fetch-stampede failure modes of `Image.network` + per-build auth
/// headers:
///
/// 1. **Stable cache identity.** Flutter's [NetworkImage] includes the full
/// headers map in its `==`/`hashCode`, so a freshly signed Authorization
/// header on every widget build made the *same URL* a new cache key and
/// bypassed the in-memory [ImageCache] entirely. This provider keys on
/// (url, scale, auth scope) and injects auth headers at *fetch* time, so
/// header refreshes never invalidate cached images.
/// 2. **Failure cooldown.** A URL that fails to load is not retried until a
/// cooldown elapses (honoring `Retry-After` on 429), so error retries on
/// rebuild cannot amplify into a request storm against the rate limiter.
///
/// The auth scope (relay base URL + signing identity) is part of key equality:
/// switching relay or account can never reuse another identity's cached bytes.
class MediaImageProvider extends ImageProvider<MediaImageProvider> {
final String url;
final double scale;
final MediaGetAuthService auth;
/// Excluded from equality: transport, not identity.
final http.Client client;
const MediaImageProvider({
required this.url,
required this.auth,
required this.client,
this.scale = 1.0,
});
static const _defaultCooldown = Duration(seconds: 30);
static const _maxRetryAfter = Duration(minutes: 5);
static final Map<String, DateTime> _cooldownUntil = {};
/// Injectable clock for cooldown tests.
@visibleForTesting
static DateTime Function() debugNow = DateTime.now;
@visibleForTesting
static void debugResetCooldowns() => _cooldownUntil.clear();
@override
Future<MediaImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<MediaImageProvider>(this);
}
@override
ImageStreamCompleter loadImage(
MediaImageProvider key,
ImageDecoderCallback decode,
) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: key.scale,
debugLabel: url,
);
}
Future<ui.Codec> _loadAsync(
MediaImageProvider key,
ImageDecoderCallback decode,
) async {
try {
final until = _cooldownUntil[url];
if (until != null) {
if (debugNow().isBefore(until)) {
throw MediaImageCooldownException(url: url, until: until);
}
_cooldownUntil.remove(url);
}
final uri = Uri.parse(url);
final http.Response response;
try {
response = await client.get(uri, headers: auth.headersFor(url));
} catch (_) {
_cooldownUntil[url] = debugNow().add(_defaultCooldown);
rethrow;
}
if (response.statusCode != 200) {
_cooldownUntil[url] = debugNow().add(_cooldownFor(response));
throw NetworkImageLoadException(
statusCode: response.statusCode,
uri: uri,
);
}
final bytes = response.bodyBytes;
if (bytes.isEmpty) {
_cooldownUntil[url] = debugNow().add(_defaultCooldown);
throw NetworkImageLoadException(statusCode: 200, uri: uri);
}
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
return decode(buffer);
} catch (_) {
// Match NetworkImage: make sure an errored key is not retained in the
// cache. The cooldown map (not the cache) throttles retries.
scheduleMicrotask(() {
PaintingBinding.instance.imageCache.evict(key);
});
rethrow;
}
}
Duration _cooldownFor(http.Response response) {
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '');
if (retryAfter != null && retryAfter > 0) {
final requested = Duration(seconds: retryAfter);
return requested > _maxRetryAfter ? _maxRetryAfter : requested;
}
return _defaultCooldown;
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is MediaImageProvider &&
other.url == url &&
other.scale == scale &&
other.auth == auth;
}
@override
int get hashCode => Object.hash(url, scale, auth);
@override
String toString() =>
'MediaImageProvider("$url", scale: ${scale.toStringAsFixed(1)})';
}
/// Thrown when a fetch is suppressed because the URL recently failed.
class MediaImageCooldownException implements Exception {
final String url;
final DateTime until;
const MediaImageCooldownException({required this.url, required this.until});
@override
String toString() =>
'MediaImageCooldownException: $url is cooling down until $until';
}
/// Drop-in replacement for the media `Image.network` call sites.
///
/// Renders [url] through [MediaImageProvider] (stable cache key, fetch-time
/// auth, failure cooldown) and bounds the decode size so a handful of large
/// originals cannot thrash the global [ImageCache]:
///
/// - [decodeWidth] set: decode at that logical width (x device pixel ratio).
/// - otherwise, when [boundDecodeToLayout] is true (default): decode at the
/// layout width reported by [LayoutBuilder], when finite.
/// - [boundDecodeToLayout] false and no [decodeWidth]: full-resolution decode
/// (the zoomable full-screen viewer).
class MediaImage extends ConsumerWidget {
final String url;
final BoxFit? fit;
final double? width;
final double? height;
final String? semanticLabel;
final ImageErrorWidgetBuilder? errorBuilder;
final FilterQuality filterQuality;
final double? decodeWidth;
final bool boundDecodeToLayout;
const MediaImage({
super.key,
required this.url,
this.fit,
this.width,
this.height,
this.semanticLabel,
this.errorBuilder,
this.filterQuality = FilterQuality.medium,
this.decodeWidth,
this.boundDecodeToLayout = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = MediaImageProvider(
url: url,
auth: ref.watch(mediaGetAuthServiceProvider),
client: ref.watch(mediaHttpClientProvider),
);
if (decodeWidth != null) {
return _image(provider, _physicalWidth(context, decodeWidth!));
}
if (!boundDecodeToLayout) {
return _image(provider, null);
}
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final cacheWidth = maxWidth.isFinite && maxWidth > 0
? _physicalWidth(context, maxWidth)
: null;
return _image(provider, cacheWidth);
},
);
}
int _physicalWidth(BuildContext context, double logicalWidth) {
return (logicalWidth * MediaQuery.devicePixelRatioOf(context)).ceil();
}
Widget _image(MediaImageProvider provider, int? cacheWidth) {
return Image(
image: ResizeImage.resizeIfNeeded(cacheWidth, null, provider),
fit: fit,
width: width,
height: height,
semanticLabel: semanticLabel,
errorBuilder: errorBuilder,
filterQuality: filterQuality,
gaplessPlayback: true,
);
}
}
+999
View File
@@ -0,0 +1,999 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:file_selector/file_selector.dart' as file_selector;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
import 'animated_image_sanitizer.dart';
import 'media_auth.dart';
import 'mp4_fast_start.dart';
import 'relay_provider.dart';
const _mediaUploadPath = '/upload';
const _legacyMediaUploadPath = '/media/upload';
const _mediaUploadPlatformChannelName = 'buzz/media_upload';
const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload';
const _transcodeVideoToMp4Method = 'transcodeVideoToMp4';
const _generateVideoPosterMethod = 'generateVideoPoster';
const _transcodeImageToJpegMethod = 'transcodeImageToJpeg';
const _requiresLegacyMediaStoragePermissionMethod =
'requiresLegacyMediaStoragePermission';
const _readClipboardImageMethod = 'readClipboardImage';
const _clipboardHasImageMethod = 'clipboardHasImage';
const _uploadAuthKind = 24242;
const _uploadAuthLifetimeSeconds = 300;
const _heicBrands = {
'heic',
'heix',
'hevc',
'hevx',
'heim',
'heis',
'mif1',
'msf1',
};
final _mediaUploadPlatformChannel = MethodChannel(
_mediaUploadPlatformChannelName,
);
/// Whether saving media needs Android's pre-scoped-storage runtime permission.
Future<bool> requiresLegacyMediaStoragePermission() async {
if (defaultTargetPlatform != TargetPlatform.android) {
return false;
}
return await _mediaUploadPlatformChannel.invokeMethod<bool>(
_requiresLegacyMediaStoragePermissionMethod,
) ??
false;
}
const _allowedImageMimeTypes = {
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
};
const _allowedVideoMimeTypes = {'video/mp4'};
const _maxVideoSizeBytes = 100 * 1024 * 1024; // 100MB
const _maxFileSizeBytes = 100 * 1024 * 1024; // 100MB
const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload.";
typedef PickGalleryImage = Future<XFile?> Function();
/// Selects multiple gallery images for upload in picker order.
typedef PickGalleryImages = Future<List<XFile>> Function();
typedef PickGalleryVideo = Future<XFile?> Function();
typedef PickAttachmentFile = Future<XFile?> Function();
typedef SanitizeImageBytes =
Future<Uint8List> Function(Uint8List bytes, String mimeType);
typedef TranscodeImageToJpeg = Future<Uint8List> Function(Uint8List bytes);
typedef TranscodeVideoToMp4 = Future<String> Function(String filePath);
/// Generates poster-frame bytes for the video at [filePath], when available.
typedef GenerateVideoPoster = Future<Uint8List?> Function(String filePath);
typedef ReadClipboardImage = Future<Uint8List?> Function();
class MediaPolicyUploadException implements Exception {
const MediaPolicyUploadException();
@override
String toString() => _mediaPolicyUploadMessage;
}
/// Cancels a single user-initiated media upload without closing the shared
/// HTTP client used by later uploads.
class UploadCancellationToken {
final Completer<void> _cancelled = Completer<void>();
/// Whether cancellation has been requested.
bool get isCancelled => _cancelled.isCompleted;
/// Completes when cancellation is first requested.
Future<void> get whenCancelled => _cancelled.future;
/// Requests cancellation. Calling this more than once has no effect.
void cancel() {
if (!_cancelled.isCompleted) _cancelled.complete();
}
}
/// Indicates that a user cancelled a media upload before it completed.
class UploadCancelledException implements Exception {
const UploadCancelledException();
}
@immutable
class _PreparedUploadImage {
final Uint8List bytes;
final String mimeType;
const _PreparedUploadImage({required this.bytes, required this.mimeType});
}
@immutable
class BlobDescriptor {
final String url;
final String sha256;
final int size;
final String type;
final int uploaded;
final String? dim;
final String? blurhash;
final String? thumb;
final double? duration;
final String? image;
final String? filename;
const BlobDescriptor({
required this.url,
required this.sha256,
required this.size,
required this.type,
required this.uploaded,
this.dim,
this.blurhash,
this.thumb,
this.duration,
this.image,
this.filename,
});
factory BlobDescriptor.fromJson(Map<String, dynamic> json) => BlobDescriptor(
url: json['url'] as String,
sha256: json['sha256'] as String,
size: (json['size'] as num).toInt(),
type: json['type'] as String,
uploaded: (json['uploaded'] as num).toInt(),
dim: json['dim'] as String?,
blurhash: json['blurhash'] as String?,
thumb: json['thumb'] as String?,
duration: (json['duration'] as num?)?.toDouble(),
image: json['image'] as String?,
filename: json['filename'] as String?,
);
BlobDescriptor withFilename(String value) => BlobDescriptor(
url: url,
sha256: sha256,
size: size,
type: type,
uploaded: uploaded,
dim: dim,
blurhash: blurhash,
thumb: thumb,
duration: duration,
image: image,
filename: value,
);
/// Returns a descriptor with [value] as its NIP-71 video poster URL.
BlobDescriptor withImage(String value) => BlobDescriptor(
url: url,
sha256: sha256,
size: size,
type: type,
uploaded: uploaded,
dim: dim,
blurhash: blurhash,
thumb: thumb,
duration: duration,
image: value,
filename: filename,
);
List<String> toImetaTag() => [
'imeta',
'url $url',
'm $type',
'x $sha256',
'size $size',
if (dim != null) 'dim $dim',
if (blurhash != null) 'blurhash $blurhash',
if (thumb != null) 'thumb $thumb',
if (duration != null) 'duration $duration',
if (image != null) 'image $image',
if (filename != null) 'filename $filename',
];
String toMarkdownImage() {
if (type.startsWith('video/')) return '![video]($url)';
if (type.startsWith('image/')) return '![image]($url)';
final label = (filename ?? 'file').replaceAllMapped(
RegExp(r'[\\\[\]]'),
(match) => '\\${match[0]}',
);
return '[$label]($url)';
}
}
class MediaUploadService {
final String _baseUrl;
final String? _nsec;
final PickGalleryImage _pickGalleryImage;
final PickGalleryImages _pickGalleryImages;
final PickGalleryVideo _pickGalleryVideo;
final PickAttachmentFile? _pickAttachmentFile;
final SanitizeImageBytes _sanitizeImageBytes;
final TranscodeImageToJpeg _transcodeImageToJpeg;
final TranscodeVideoToMp4 _transcodeVideoToMp4;
final GenerateVideoPoster _generateVideoPoster;
final ReadClipboardImage _readClipboardImage;
final DateTime Function() _now;
final http.Client _http;
final bool _ownsHttpClient;
MediaUploadService({
required String baseUrl,
required String? nsec,
required PickGalleryImage pickGalleryImage,
PickGalleryImages? pickGalleryImages,
required PickGalleryVideo pickGalleryVideo,
PickAttachmentFile? pickAttachmentFile,
SanitizeImageBytes? sanitizeImageBytes,
TranscodeImageToJpeg? transcodeImageToJpeg,
TranscodeVideoToMp4? transcodeVideoToMp4,
GenerateVideoPoster? generateVideoPoster,
ReadClipboardImage? readClipboardImage,
DateTime Function()? now,
http.Client? httpClient,
}) : _baseUrl = baseUrl,
_nsec = nsec,
_pickGalleryImage = pickGalleryImage,
_pickGalleryImages =
pickGalleryImages ??
(() async {
final image = await pickGalleryImage();
return image == null ? const <XFile>[] : [image];
}),
_pickGalleryVideo = pickGalleryVideo,
_pickAttachmentFile = pickAttachmentFile,
_sanitizeImageBytes = sanitizeImageBytes ?? _sanitizePickedImageBytes,
_transcodeImageToJpeg =
transcodeImageToJpeg ?? _transcodePickedImageToJpeg,
_transcodeVideoToMp4 = transcodeVideoToMp4 ?? _transcodePickedVideoToMp4,
_generateVideoPoster = generateVideoPoster ?? _generatePickedVideoPoster,
_readClipboardImage = readClipboardImage ?? _readPlatformClipboardImage,
_now = now ?? DateTime.now,
_http = httpClient ?? http.Client(),
_ownsHttpClient = httpClient == null;
void dispose() {
if (_ownsHttpClient) {
_http.close();
}
}
Future<BlobDescriptor?> pickAndUploadImage() async {
final pickedImage = await _pickGalleryImage();
if (pickedImage == null) return null;
return uploadImage(pickedImage);
}
/// Opens the system picker with multi-selection enabled.
Future<List<XFile>> pickGalleryImages() => _pickGalleryImages();
Future<BlobDescriptor> uploadImage(
XFile image, {
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
final preparedImage = await _prepareUploadImage(image);
_throwIfCancelled(cancellationToken);
return _uploadPreparedBytes(
preparedImage.bytes,
mimeType: preparedImage.mimeType,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
}
Future<bool> clipboardHasImage() async {
return await _mediaUploadPlatformChannel.invokeMethod<bool>(
_clipboardHasImageMethod,
) ??
false;
}
Future<BlobDescriptor> readAndUploadClipboardImage() async {
final image = await readClipboardImage();
if (image == null) throw Exception('Unable to read pasted image');
return uploadImage(image);
}
/// Reads a clipboard image for composer preview before the user sends it.
Future<XFile?> readClipboardImage({String filename = 'Pasted image'}) async {
final bytes = await _readClipboardImage();
if (bytes == null || bytes.isEmpty) return null;
return XFile.fromData(bytes, name: filename);
}
/// Opens the system gallery video picker.
Future<XFile?> pickGalleryVideo() => _pickGalleryVideo();
/// Sanitizes and uploads [pickedVideo] as an MP4 attachment.
Future<BlobDescriptor> uploadVideo(
XFile pickedVideo, {
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
final length = await pickedVideo.length();
if (length > _maxVideoSizeBytes) {
throw Exception(
'Video is too large (${(length / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.',
);
}
// Always rebuild the container. Passing an existing MP4 through would retain
// QuickTime GPS, global metadata, chapters, or non-A/V tracks.
String? transcodedPath;
try {
transcodedPath = await _transcodeVideoToMp4(pickedVideo.path);
_throwIfCancelled(cancellationToken);
final transcodedFile = File(transcodedPath);
final transcodedLength = await transcodedFile.length();
if (transcodedLength > _maxVideoSizeBytes) {
throw Exception(
'Transcoded video is too large (${(transcodedLength / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.',
);
}
final bytes = await transcodedFile.readAsBytes();
_throwIfCancelled(cancellationToken);
final video = await uploadBytes(
bytes,
mimeType: 'video/mp4',
onProgress: onProgress == null
? null
: (progress) => onProgress(progress * 0.9),
cancellationToken: cancellationToken,
);
// Extract from the canonical output first so the poster matches the
// uploaded orientation. Some AVFoundation exports need a moment before
// their first frame is seekable; fall back to the picked source instead
// of silently sending a permanently gray video card.
Uint8List? posterBytes;
Object? posterExtractionError;
for (final sourcePath in {transcodedPath, pickedVideo.path}) {
try {
final candidate = await _generateVideoPoster(sourcePath);
if (candidate != null && candidate.isNotEmpty) {
posterBytes = candidate;
break;
}
} catch (error) {
posterExtractionError = error;
}
}
if (posterBytes == null) {
if (posterExtractionError != null) {
debugPrint('Unable to generate video poster: $posterExtractionError');
}
onProgress?.call(1);
return video;
}
// Posters are best-effort: a video that passed the media policy should
// still send if the separate preview upload fails.
try {
_throwIfCancelled(cancellationToken);
final poster = await uploadImage(
XFile.fromData(
posterBytes,
mimeType: 'image/jpeg',
name: 'video-poster.jpg',
),
onProgress: onProgress == null
? null
: (progress) => onProgress(0.9 + (progress * 0.1)),
cancellationToken: cancellationToken,
);
return video.withImage(poster.url);
} catch (error) {
debugPrint('Unable to upload video poster: $error');
onProgress?.call(1);
return video;
}
} finally {
if (transcodedPath != null) {
try {
await File(transcodedPath).delete();
} catch (_) {
// Best-effort temp file cleanup.
}
}
}
}
Future<BlobDescriptor?> pickAndUploadVideo() async {
final pickedVideo = await pickGalleryVideo();
if (pickedVideo == null) return null;
return uploadVideo(pickedVideo);
}
/// Opens the system document picker for a generic file attachment.
Future<XFile?> pickAttachmentFile() async {
final pickAttachmentFile = _pickAttachmentFile;
if (pickAttachmentFile == null) {
throw Exception("File attachments aren't available on this device.");
}
return pickAttachmentFile();
}
/// Uploads [pickedFile] as a size-limited generic attachment.
Future<BlobDescriptor> uploadFile(
XFile pickedFile, {
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
final length = await pickedFile.length();
if (length == 0) {
throw Exception('File is empty.');
}
if (length > _maxFileSizeBytes) {
throw Exception(
'File is too large (${(length / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.',
);
}
final bytes = await pickedFile.readAsBytes();
_throwIfCancelled(cancellationToken);
final descriptor = await _uploadPreparedBytes(
bytes,
mimeType: 'application/octet-stream',
allowGenericFile: true,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
return descriptor.withFilename(_safeAttachmentFilename(pickedFile.name));
}
Future<BlobDescriptor?> pickAndUploadFile() async {
final pickedFile = await pickAttachmentFile();
if (pickedFile == null) return null;
return uploadFile(pickedFile);
}
Future<BlobDescriptor> uploadBytes(
Uint8List bytes, {
required String mimeType,
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
if (mimeType == 'image/gif' ||
(mimeType == 'image/png' && _isAnimatedPng(bytes)) ||
(mimeType == 'image/webp' && _isAnimatedWebp(bytes))) {
try {
bytes = sanitizeAnimatedImageForUpload(bytes, mimeType);
} on FormatException {
throw Exception('failed to sanitize image for upload');
}
}
return _uploadPreparedBytes(
bytes,
mimeType: mimeType,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
}
Future<BlobDescriptor> _uploadPreparedBytes(
Uint8List bytes, {
required String mimeType,
bool allowGenericFile = false,
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
if (!allowGenericFile &&
!_allowedImageMimeTypes.contains(mimeType) &&
!_allowedVideoMimeTypes.contains(mimeType)) {
throw Exception('unsupported file type: $mimeType');
}
final sha256 = _sha256Hex(bytes);
var response = await _sendUploadRequest(
bytes: bytes,
mimeType: mimeType,
sha256: sha256,
path: _mediaUploadPath,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
if (response.statusCode == HttpStatus.notFound ||
response.statusCode == HttpStatus.methodNotAllowed) {
response = await _sendUploadRequest(
bytes: bytes,
mimeType: mimeType,
sha256: sha256,
path: _legacyMediaUploadPath,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
}
if (response.statusCode < 200 || response.statusCode >= 300) {
if (_allowedImageMimeTypes.contains(mimeType) &&
(response.statusCode == HttpStatus.unsupportedMediaType ||
response.statusCode == HttpStatus.unprocessableEntity)) {
throw const MediaPolicyUploadException();
}
throw Exception(
'upload failed (${response.statusCode}): ${response.body}',
);
}
return BlobDescriptor.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
}
Future<http.Response> _sendUploadRequest({
required Uint8List bytes,
required String mimeType,
required String sha256,
required String path,
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
final request = http.AbortableStreamedRequest(
'PUT',
Uri.parse(_baseUrl).resolve(path),
abortTrigger: cancellationToken?.whenCancelled,
);
request.contentLength = bytes.length;
request.headers.addAll(
_buildUploadHeaders(mimeType: mimeType, sha256: sha256),
);
final writeRequest = request.sink
.addStream(_uploadByteStream(bytes, onProgress))
.whenComplete(request.sink.close);
final response = await _http.send(request);
await writeRequest;
_throwIfCancelled(cancellationToken);
return http.Response.fromStream(response);
}
void _throwIfCancelled(UploadCancellationToken? cancellationToken) {
if (cancellationToken?.isCancelled ?? false) {
throw const UploadCancelledException();
}
}
Map<String, String> _buildUploadHeaders({
required String mimeType,
required String sha256,
}) {
final headers = <String, String>{
'Authorization': _buildUploadAuthHeader(sha256),
'Content-Type': mimeType,
'X-SHA-256': sha256,
};
return headers;
}
String _buildUploadAuthHeader(String sha256) {
final authEvent = _buildUploadAuthEvent(sha256);
final authJson = authEvent.toJson();
final encoded = base64Url.encode(utf8.encode(authJson)).replaceAll('=', '');
return 'Nostr $encoded';
}
nostr.Event _buildUploadAuthEvent(String sha256) {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) {
throw Exception('Cannot upload media: no signing key available');
}
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
final expiration =
(_now().millisecondsSinceEpoch ~/ 1000) + _uploadAuthLifetimeSeconds;
final tags = <List<String>>[
['t', 'upload'],
['x', sha256],
['expiration', '$expiration'],
if (extractServerAuthority(_baseUrl) case final authority?)
['server', authority],
];
return nostr.Event.from(
kind: _uploadAuthKind,
content: 'Upload buzz-media',
tags: tags,
secretKey: privkeyHex,
verify: false,
);
}
Future<_PreparedUploadImage> _prepareUploadImage(XFile pickedImage) async {
final bytes = await pickedImage.readAsBytes();
final detectedMimeType = _tryDetectImageMimeType(bytes);
if (detectedMimeType case final mimeType?) {
return _prepareDetectedUploadImage(bytes, mimeType);
}
if (_shouldTranscodePickedImage(pickedImage, bytes)) {
return _prepareTranscodedUploadImage(bytes);
}
throw Exception('unsupported file type');
}
Future<_PreparedUploadImage> _prepareDetectedUploadImage(
Uint8List bytes,
String mimeType,
) async {
final preparedBytes = await _sanitizeImageBytesIfNeeded(bytes, mimeType);
return _buildPreparedUploadImage(preparedBytes);
}
Future<_PreparedUploadImage> _prepareTranscodedUploadImage(
Uint8List bytes,
) async {
final transcodedBytes = await _transcodeImageToJpeg(bytes);
return _buildPreparedUploadImage(transcodedBytes);
}
_PreparedUploadImage _buildPreparedUploadImage(Uint8List bytes) {
return _PreparedUploadImage(
bytes: bytes,
mimeType: _detectImageMimeType(bytes),
);
}
Future<Uint8List> _sanitizeImageBytesIfNeeded(
Uint8List bytes,
String mimeType,
) async {
if (mimeType == 'image/gif' ||
(mimeType == 'image/png' && _isAnimatedPng(bytes)) ||
(mimeType == 'image/webp' && _isAnimatedWebp(bytes))) {
try {
return sanitizeAnimatedImageForUpload(bytes, mimeType);
} on FormatException {
throw Exception('failed to sanitize image for upload');
}
}
if (!_shouldSanitizePickedImage(mimeType)) {
return bytes;
}
final sanitizedBytes = await _sanitizeImageBytes(bytes, mimeType);
if (sanitizedBytes.isEmpty) {
throw Exception('failed to sanitize image for upload');
}
return sanitizedBytes;
}
}
String _safeAttachmentFilename(String filename) {
final segments = filename.split(RegExp(r'[/\\]'));
final basename = segments.isEmpty ? '' : segments.last;
final sanitized = StringBuffer();
var byteLength = 0;
for (final rune in basename.runes) {
if ((rune >= 0 && rune <= 0x1f) || (rune >= 0x7f && rune <= 0x9f)) {
continue;
}
final character = String.fromCharCode(rune);
final characterByteLength = utf8.encode(character).length;
if (byteLength + characterByteLength > 255) break;
sanitized.write(character);
byteLength += characterByteLength;
}
final safeBasename = sanitized.toString().trim();
return safeBasename.isEmpty ? 'file' : safeBasename;
}
Stream<List<int>> _uploadByteStream(
Uint8List bytes,
ValueChanged<double>? onProgress,
) async* {
const chunkSize = 64 * 1024;
onProgress?.call(0);
if (bytes.isEmpty) {
onProgress?.call(1);
return;
}
for (var start = 0; start < bytes.length; start += chunkSize) {
final end = math.min(start + chunkSize, bytes.length);
yield Uint8List.sublistView(bytes, start, end);
onProgress?.call(end / bytes.length);
}
}
String _sha256Hex(Uint8List bytes) {
final digest = SHA256Digest().process(bytes);
return digest.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
String? _tryDetectImageMimeType(Uint8List bytes) {
try {
return _detectImageMimeType(bytes);
} on Exception {
return null;
}
}
String _detectImageMimeType(Uint8List bytes) {
if (_startsWith(bytes, const [0xff, 0xd8, 0xff])) {
return 'image/jpeg';
}
if (_startsWith(bytes, const [
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
])) {
return 'image/png';
}
if (_startsWith(bytes, ascii.encode('GIF87a')) ||
_startsWith(bytes, ascii.encode('GIF89a'))) {
return 'image/gif';
}
if (_startsWith(bytes, ascii.encode('RIFF')) &&
bytes.length >= 12 &&
ascii.decode(bytes.sublist(8, 12), allowInvalid: true) == 'WEBP') {
return 'image/webp';
}
throw Exception('unsupported file type');
}
bool _shouldTranscodePickedImage(XFile pickedImage, Uint8List bytes) {
return _supportsNativeUploadImageProcessing() &&
(_hasHeicFileExtension(pickedImage) || _looksLikeHeicOrHeif(bytes));
}
bool _isAnimatedPng(Uint8List bytes) {
if (!_startsWith(bytes, const [
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
])) {
return false;
}
var offset = 8;
while (offset + 12 <= bytes.length) {
final chunkSize = _readUint32BigEndian(bytes, offset);
if (offset + 12 + chunkSize > bytes.length) {
return false;
}
if (_matchesAscii(bytes, offset + 4, 'acTL')) {
return true;
}
offset += 12 + chunkSize;
}
return false;
}
bool _isAnimatedWebp(Uint8List bytes) {
if (!_startsWith(bytes, ascii.encode('RIFF')) ||
bytes.length < 12 ||
ascii.decode(bytes.sublist(8, 12), allowInvalid: true) != 'WEBP') {
return false;
}
var offset = 12;
while (offset + 8 <= bytes.length) {
final chunkSize = _readUint32LittleEndian(bytes, offset + 4);
final payloadOffset = offset + 8;
if (payloadOffset + chunkSize > bytes.length) {
return false;
}
if (_matchesAscii(bytes, offset, 'ANIM') ||
_matchesAscii(bytes, offset, 'ANMF')) {
return true;
}
if (_matchesAscii(bytes, offset, 'VP8X') &&
chunkSize >= 1 &&
(bytes[payloadOffset] & 0x02) != 0) {
return true;
}
offset = payloadOffset + chunkSize + (chunkSize.isOdd ? 1 : 0);
}
return false;
}
bool _shouldSanitizePickedImage(String mimeType) {
return _supportsNativeUploadImageProcessing() &&
(mimeType == 'image/jpeg' ||
mimeType == 'image/png' ||
mimeType == 'image/webp');
}
bool _supportsNativeUploadImageProcessing() {
return switch (defaultTargetPlatform) {
TargetPlatform.android || TargetPlatform.iOS => true,
_ => false,
};
}
bool _hasHeicFileExtension(XFile pickedImage) {
for (final candidate in [pickedImage.name, pickedImage.path]) {
final normalizedCandidate = candidate.toLowerCase();
if (normalizedCandidate.endsWith('.heic') ||
normalizedCandidate.endsWith('.heif')) {
return true;
}
}
return false;
}
bool _looksLikeHeicOrHeif(Uint8List bytes) {
if (bytes.length < 12 || !_matchesAscii(bytes, 4, 'ftyp')) {
return false;
}
final upperBound = bytes.length < 32 ? bytes.length : 32;
for (var offset = 8; offset + 4 <= upperBound; offset += 4) {
final brand = ascii.decode(
bytes.sublist(offset, offset + 4),
allowInvalid: true,
);
if (_heicBrands.contains(brand.toLowerCase())) {
return true;
}
}
return false;
}
bool _startsWith(Uint8List bytes, List<int> prefix) {
if (bytes.length < prefix.length) return false;
for (var i = 0; i < prefix.length; i++) {
if (bytes[i] != prefix[i]) return false;
}
return true;
}
bool _matchesAscii(Uint8List bytes, int offset, String value) {
final codeUnits = ascii.encode(value);
if (bytes.length < offset + codeUnits.length) return false;
for (var i = 0; i < codeUnits.length; i++) {
if (bytes[offset + i] != codeUnits[i]) return false;
}
return true;
}
int _readUint32BigEndian(Uint8List bytes, int offset) {
return (bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3];
}
int _readUint32LittleEndian(Uint8List bytes, int offset) {
return bytes[offset] |
(bytes[offset + 1] << 8) |
(bytes[offset + 2] << 16) |
(bytes[offset + 3] << 24);
}
Future<Uint8List?> _readPlatformClipboardImage() async {
return _mediaUploadPlatformChannel.invokeMethod<Uint8List>(
_readClipboardImageMethod,
);
}
Future<Uint8List?> _generatePickedVideoPoster(String filePath) {
return _mediaUploadPlatformChannel.invokeMethod<Uint8List>(
_generateVideoPosterMethod,
filePath,
);
}
Future<String> _transcodePickedVideoToMp4(String filePath) async {
final result = await _mediaUploadPlatformChannel.invokeMethod<String>(
_transcodeVideoToMp4Method,
filePath,
);
if (result == null || result.isEmpty) {
throw Exception('Failed to convert video to MP4.');
}
if (defaultTargetPlatform == TargetPlatform.android) {
final source = File(result);
final destination = File(
'$result.faststart-${DateTime.now().microsecondsSinceEpoch}.mp4',
);
try {
await rewriteMp4ForFastStart(source, destination);
await source.delete();
return destination.path;
} catch (_) {
try {
await destination.delete();
} on FileSystemException {
// Best-effort cleanup; preserve the original platform error.
}
rethrow;
}
}
return result;
}
Future<Uint8List> _transcodePickedImageToJpeg(Uint8List bytes) async {
return _invokeRequiredPlatformBytesMethod(
_transcodeImageToJpegMethod,
arguments: bytes,
errorMessage: 'failed to convert image for upload',
);
}
Future<Uint8List> _sanitizePickedImageBytes(
Uint8List bytes,
String mimeType,
) async {
return _invokeRequiredPlatformBytesMethod(
_sanitizeImageForUploadMethod,
arguments: {'bytes': bytes, 'mimeType': mimeType},
errorMessage: 'failed to sanitize image for upload',
);
}
Future<Uint8List> _invokeRequiredPlatformBytesMethod(
String method, {
Object? arguments,
required String errorMessage,
}) async {
final result = await _mediaUploadPlatformChannel.invokeMethod<Uint8List>(
method,
arguments,
);
if (result == null || result.isEmpty) {
throw Exception(errorMessage);
}
return result;
}
final mediaUploadServiceProvider = Provider<MediaUploadService>((ref) {
final config = ref.watch(relayConfigProvider);
final picker = ImagePicker();
final service = MediaUploadService(
baseUrl: config.baseUrl,
nsec: config.nsec,
pickGalleryImage: () => picker.pickImage(
source: ImageSource.gallery,
requestFullMetadata: false,
),
pickGalleryImages: () => picker.pickMultiImage(requestFullMetadata: false),
pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery),
pickAttachmentFile: file_selector.openFile,
);
ref.onDispose(service.dispose);
return service;
});
+345
View File
@@ -0,0 +1,345 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
const _maxTopLevelBoxes = 4096;
const _maxNestedBoxes = 100000;
const _maxBoxDepth = 32;
const _maxMoovBytes = 64 * 1024 * 1024;
const _copyBufferBytes = 1024 * 1024;
const _uint32Max = 0xffffffff;
const _uint64Max = 0x7fffffffffffffff;
const _containerTypes = {
'moov',
'trak',
'mdia',
'minf',
'stbl',
'edts',
'dinf',
};
final class _Mp4Box {
final int offset;
final int size;
final int headerSize;
final String type;
const _Mp4Box({
required this.offset,
required this.size,
required this.headerSize,
required this.type,
});
}
/// Rewrites [source] to [destination] with `moov` before the first `mdat`.
///
/// Only chunk offsets into the region moved by the relocation are adjusted.
/// The files must be distinct, and malformed or excessively nested inputs fail
/// closed rather than producing a partially valid upload.
Future<void> rewriteMp4ForFastStart(File source, File destination) async {
if (source.absolute.path == destination.absolute.path) {
throw const FormatException(
'MP4 fast-start rewrite requires a distinct destination',
);
}
final input = await source.open();
try {
final boxes = await _readTopLevelBoxes(input);
if (boxes.isEmpty || boxes.first.type != 'ftyp') {
throw const FormatException('MP4 must start with an ftyp box');
}
final moovBoxes = boxes.where((box) => box.type == 'moov').toList();
if (moovBoxes.length != 1) {
throw const FormatException('MP4 must contain exactly one moov box');
}
final moov = moovBoxes.single;
final mdatBoxes = boxes.where((box) => box.type == 'mdat').toList();
final firstMdat = mdatBoxes.isEmpty ? null : mdatBoxes.first;
if (firstMdat == null) {
throw const FormatException('MP4 is missing an mdat box');
}
if (moov.size > _maxMoovBytes) {
throw const FormatException('MP4 moov box is too large');
}
await input.setPosition(moov.offset);
final moovBytes = await input.read(moov.size);
if (moovBytes.length != moov.size) {
throw const FormatException('truncated MP4 moov box');
}
if (moov.offset > firstMdat.offset) {
_patchChunkOffsets(
moovBytes,
moov.headerSize,
moovBytes.length,
firstMdat.offset,
moov.offset,
moov.size,
0,
[0],
);
}
final output = await destination.open(mode: FileMode.write);
try {
var insertedMoov = false;
for (final box in boxes) {
if (box.type == 'moov') continue;
if (!insertedMoov && box.type == 'mdat') {
await output.writeFrom(moovBytes);
insertedMoov = true;
}
await _copyRange(input, output, box.offset, box.size);
}
if (!insertedMoov) {
throw const FormatException('MP4 is missing an mdat box');
}
await output.flush();
} finally {
await output.close();
}
} catch (_) {
try {
await destination.delete();
} on FileSystemException {
// Best-effort cleanup of a partial rewrite.
}
rethrow;
} finally {
await input.close();
}
}
Future<List<_Mp4Box>> _readTopLevelBoxes(RandomAccessFile input) async {
final length = await input.length();
final boxes = <_Mp4Box>[];
var offset = 0;
while (offset < length) {
if (boxes.length >= _maxTopLevelBoxes) {
throw const FormatException('MP4 has too many top-level boxes');
}
final box = await _readFileBoxHeader(input, offset, length);
boxes.add(box);
offset += box.size;
}
if (offset != length) {
throw const FormatException('MP4 boxes do not cover the file');
}
return boxes;
}
Future<_Mp4Box> _readFileBoxHeader(
RandomAccessFile input,
int offset,
int end,
) async {
if (end - offset < 8) {
throw const FormatException('truncated MP4 box header');
}
await input.setPosition(offset);
final compactHeader = await input.read(8);
final compactSize = _readUint32(compactHeader, 0);
final type = latin1.decode(compactHeader.sublist(4, 8));
final int headerSize;
final int size;
if (compactSize == 1) {
if (end - offset < 16) {
throw const FormatException('truncated extended MP4 box header');
}
final extended = await input.read(8);
headerSize = 16;
size = _readUint64(extended, 0);
} else if (compactSize == 0) {
headerSize = 8;
size = end - offset;
} else {
headerSize = 8;
size = compactSize;
}
if (size < headerSize || size > end - offset) {
throw const FormatException('invalid MP4 box size');
}
return _Mp4Box(
offset: offset,
size: size,
headerSize: headerSize,
type: type,
);
}
Future<void> _copyRange(
RandomAccessFile input,
RandomAccessFile output,
int offset,
int size,
) async {
await input.setPosition(offset);
var remaining = size;
while (remaining > 0) {
final bytes = await input.read(
remaining < _copyBufferBytes ? remaining : _copyBufferBytes,
);
if (bytes.isEmpty) {
throw const FormatException('truncated MP4 while copying');
}
await output.writeFrom(bytes);
remaining -= bytes.length;
}
}
void _patchChunkOffsets(
Uint8List bytes,
int start,
int end,
int movedRegionStart,
int movedRegionEnd,
int delta,
int depth,
List<int> boxesSeen,
) {
if (depth > _maxBoxDepth) {
throw const FormatException('MP4 box nesting is too deep');
}
var offset = start;
while (offset < end) {
boxesSeen[0]++;
if (boxesSeen[0] > _maxNestedBoxes) {
throw const FormatException('MP4 has too many nested boxes');
}
final box = _readMemoryBoxHeader(bytes, offset, end);
final boxEnd = offset + box.size;
switch (box.type) {
case 'stco':
_patchStco(
bytes,
offset + box.headerSize,
boxEnd,
movedRegionStart,
movedRegionEnd,
delta,
);
break;
case 'co64':
_patchCo64(
bytes,
offset + box.headerSize,
boxEnd,
movedRegionStart,
movedRegionEnd,
delta,
);
break;
case final type when _containerTypes.contains(type):
_patchChunkOffsets(
bytes,
offset + box.headerSize,
boxEnd,
movedRegionStart,
movedRegionEnd,
delta,
depth + 1,
boxesSeen,
);
break;
}
offset = boxEnd;
}
if (offset != end) {
throw const FormatException('MP4 child boxes do not cover their parent');
}
}
_Mp4Box _readMemoryBoxHeader(Uint8List bytes, int offset, int end) {
if (end - offset < 8) {
throw const FormatException('truncated MP4 child box');
}
final compactSize = _readUint32(bytes, offset);
final type = latin1.decode(bytes.sublist(offset + 4, offset + 8));
final int headerSize;
final int size;
if (compactSize == 1) {
if (end - offset < 16) {
throw const FormatException('truncated extended MP4 child box');
}
headerSize = 16;
size = _readUint64(bytes, offset + 8);
} else if (compactSize == 0) {
headerSize = 8;
size = end - offset;
} else {
headerSize = 8;
size = compactSize;
}
if (size < headerSize || size > end - offset) {
throw const FormatException('invalid MP4 child box size');
}
return _Mp4Box(
offset: offset,
size: size,
headerSize: headerSize,
type: type,
);
}
void _patchStco(
Uint8List bytes,
int start,
int end,
int movedRegionStart,
int movedRegionEnd,
int delta,
) {
if (end - start < 8) throw const FormatException('truncated stco box');
final count = _readUint32(bytes, start + 4);
if (count > (end - start - 8) ~/ 4) {
throw const FormatException('invalid stco entry count');
}
final data = ByteData.sublistView(bytes);
for (var index = 0; index < count; index++) {
final entry = start + 8 + index * 4;
final value = data.getUint32(entry, Endian.big);
if (value >= movedRegionStart && value < movedRegionEnd) {
final adjusted = value + delta;
if (adjusted > _uint32Max) {
throw const FormatException('stco offset overflow');
}
data.setUint32(entry, adjusted, Endian.big);
}
}
}
void _patchCo64(
Uint8List bytes,
int start,
int end,
int movedRegionStart,
int movedRegionEnd,
int delta,
) {
if (end - start < 8) throw const FormatException('truncated co64 box');
final count = _readUint32(bytes, start + 4);
if (count > (end - start - 8) ~/ 8) {
throw const FormatException('invalid co64 entry count');
}
final data = ByteData.sublistView(bytes);
for (var index = 0; index < count; index++) {
final entry = start + 8 + index * 8;
final value = data.getUint64(entry, Endian.big);
if (value >= movedRegionStart && value < movedRegionEnd) {
final adjusted = value + delta;
if (adjusted > _uint64Max) {
throw const FormatException('co64 offset overflow');
}
data.setUint64(entry, adjusted, Endian.big);
}
}
}
int _readUint32(Uint8List bytes, int offset) =>
ByteData.sublistView(bytes).getUint32(offset, Endian.big);
int _readUint64(Uint8List bytes, int offset) =>
ByteData.sublistView(bytes).getUint64(offset, Endian.big);
+215
View File
@@ -0,0 +1,215 @@
import 'nostr_models.dart';
/// Canonical [NostrFilter] constructors for common Buzz queries.
///
/// Centralising filter shapes keeps relay queries consistent across providers
/// and makes kind/tag conventions easy to audit.
abstract final class NostrFilters {
/// Channels where I'm a member (kind:39002 with `#p` = my pubkey).
static NostrFilter myChannels(String myPk) => NostrFilter(
kinds: [39002],
tags: {
'#p': [myPk],
},
limit: 500,
);
/// Channel metadata for the given channel IDs.
static NostrFilter channelMetadata(List<String> ids) =>
NostrFilter(kinds: [39000], tags: {'#d': ids}, limit: ids.length);
/// Members list for a single channel.
static NostrFilter channelMembers(String channelId) => NostrFilter(
kinds: [39002],
tags: {
'#d': [channelId],
},
limit: 1,
);
/// A single user's profile (kind:0).
static NostrFilter profile(String pubkey) =>
NostrFilter(kinds: [0], authors: [pubkey], limit: 1);
/// Batch user profiles (kind:0) for multiple pubkeys.
static NostrFilter profilesBatch(List<String> pubkeys) =>
NostrFilter(kinds: [0], authors: pubkeys, limit: pubkeys.length);
/// Channel messages (all event kinds that appear in channels).
static NostrFilter messages(
String channelId, {
int limit = 200,
int? until,
}) => NostrFilter(
kinds: EventKind.channelEventKinds,
tags: {
'#h': [channelId],
},
limit: limit,
until: until,
);
/// Reactions (kind:7) on a specific event.
static NostrFilter reactions(String eventId) => NostrFilter(
kinds: [7],
tags: {
'#e': [eventId],
},
);
/// Canvas event for a channel.
static NostrFilter canvas(String channelId) => NostrFilter(
kinds: [40100],
tags: {
'#h': [channelId],
},
limit: 1,
);
/// Workflows (kind:30620) in a channel.
static NostrFilter workflows(String channelId) => NostrFilter(
kinds: [30620],
tags: {
'#h': [channelId],
},
);
/// DM channels where I'm a participant.
static NostrFilter dmList(String myPk) => NostrFilter(
kinds: [39000],
tags: {
'#t': ['dm'],
'#p': [myPk],
},
);
/// Latest per-viewer hidden-DM snapshot (kind:30622, `#p` = my pubkey).
static NostrFilter hiddenDms(String myPk) => NostrFilter(
kinds: [EventKind.dmVisibility],
tags: {
'#p': [myPk],
},
limit: 1,
);
/// Forum posts (kind:45001) in a channel.
static NostrFilter forumPosts(
String channelId, {
int limit = 50,
int? until,
}) => NostrFilter(
kinds: [45001],
tags: {
'#h': [channelId],
},
limit: limit,
until: until,
);
/// Replies in a forum thread (root event id + channel scope).
static NostrFilter forumThread(String rootId, String channelId) =>
NostrFilter(
kinds: [9, 45003],
tags: {
'#e': [rootId],
'#h': [channelId],
},
);
/// NIP-50 message search, optionally scoped to a channel.
static NostrFilter searchMessages(
String query, {
String? channelId,
int limit = 20,
}) => NostrFilter(
kinds: [9, 40002, 45001, 45003],
tags: channelId != null
? {
'#h': [channelId],
}
: const {},
search: query,
limit: limit,
);
/// Global user search over kind:0 profiles (NIP-50 via the HTTP bridge).
///
/// `search_mode: "prefix"` is a Buzz bridge-only extension: every caller is
/// a typeahead surface, so a partially typed name must match ("rac" →
/// "raccoon"). Mirrors desktop's `build_user_search_filter`
/// (desktop/src-tauri/src/commands/profile.rs). Bridge-only — send through
/// `queryRelay`, not a WebSocket REQ.
static NostrFilter searchUsers(String query, {int limit = 50}) => NostrFilter(
kinds: [0],
search: query,
limit: limit,
extensions: const {'search_mode': 'prefix'},
);
/// Deletions (kind:5) targeting event IDs.
static NostrFilter deletionsByTargetIds(
List<String> ids, {
List<String>? authors,
}) => NostrFilter(
kinds: [EventKind.deletion],
authors: authors,
tags: {'#e': ids},
limit: ids.length,
);
/// User notes (kind:1) for the global Pulse timeline.
static NostrFilter globalNotes({int limit = 50, int? until}) =>
NostrFilter(kinds: [EventKind.note], limit: limit, until: until);
/// Notes by a set of authors for Pulse timelines.
static NostrFilter notesTimeline(
List<String> pubkeys, {
int limit = 200,
int? until,
}) => NostrFilter(
kinds: [EventKind.note],
authors: pubkeys,
limit: limit,
until: until,
);
/// Reactions authored by a user.
static NostrFilter userReactions(String pubkey, {int limit = 200}) =>
NostrFilter(kinds: [EventKind.reaction], authors: [pubkey], limit: limit);
/// Reactions targeting notes.
static NostrFilter noteReactions(List<String> noteIds) => NostrFilter(
kinds: [EventKind.reaction],
tags: {'#e': noteIds},
limit: 500,
);
/// Fetch notes by ids.
static NostrFilter notesByIds(List<String> ids) =>
NostrFilter(kinds: [EventKind.note], ids: ids, limit: ids.length);
/// User notes (kind:1) for a single author.
static NostrFilter userNotes(String pubkey, {int limit = 20, int? until}) =>
NostrFilter(
kinds: [EventKind.note],
authors: [pubkey],
limit: limit,
until: until,
);
/// Contact list (kind:3) for a user.
static NostrFilter contactList(String pubkey) =>
NostrFilter(kinds: [EventKind.contactList], authors: [pubkey], limit: 1);
/// Relay membership list (kind:13534).
static NostrFilter relayMembers() =>
const NostrFilter(kinds: [13534], limit: 1);
/// Agent profiles (kind:10100).
static NostrFilter agentProfiles() =>
const NostrFilter(kinds: [10100], limit: 100);
/// User status (NIP-38, kind:30315).
static NostrFilter userStatus(String pubkey) =>
NostrFilter(kinds: [30315], authors: [pubkey], limit: 1);
}
+407
View File
@@ -0,0 +1,407 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
/// Nostr event kind constants.
///
/// Keep in sync with `desktop/src/shared/constants/kinds.ts`.
abstract final class EventKind {
static const note = 1;
static const contactList = 3;
static const deletion = 5;
static const reaction = 7;
static const streamMessage = 9;
static const nip29DeleteEvent = 9005;
static const presenceUpdate = 20001;
static const typingIndicator = 20002;
static const auth = 22242;
static const agentObserverFrame = 24200;
static const huddleReaction = 24810;
static const readState = 30078;
static const eventReminder = 30300;
static const userStatus = 30315;
static const dmVisibility = 30622;
static const streamMessageV2 = 40002;
static const channelThreadSummary = 39005;
static const channelWindowBounds = 39006;
static const streamMessageEdit = 40003;
static const streamMessageDiff = 40008;
static const systemMessage = 40099;
static const jobRequest = 43001;
static const jobAccepted = 43002;
static const jobProgress = 43003;
static const jobResult = 43004;
static const jobCancel = 43005;
static const jobError = 43006;
static const forumPost = 45001;
static const forumComment = 45003;
static const huddleStarted = 48100;
static const huddleParticipantJoined = 48101;
static const huddleParticipantLeft = 48102;
static const huddleEnded = 48103;
/// Event kinds that represent user-visible channel messages.
static const channelMessageEventKinds = [
streamMessage, // 9
streamMessageV2, // 40002
forumPost, // 45001
forumComment, // 45003
];
/// Event kinds that represent channel activity (messages, edits, reactions,
/// deletions, system events). Matches the desktop's `CHANNEL_EVENT_KINDS`.
static const channelEventKinds = [
deletion, // 5
reaction, // 7
nip29DeleteEvent, // 9005 — Buzz-native deletion
...channelMessageEventKinds,
40001, // legacy pre-migration stream messages
streamMessageEdit, // 40003
streamMessageDiff, // 40008
systemMessage, // 40099
huddleStarted, // 48100 — visible huddle session row
huddleParticipantJoined, // 48101 — huddle lifecycle metadata
huddleParticipantLeft, // 48102 — huddle lifecycle metadata
huddleEnded, // 48103 — visible huddle ended row
];
/// Auxiliary timeline kinds that overlay or hide existing rows.
static const channelAuxEventKinds = [
deletion,
reaction,
nip29DeleteEvent,
streamMessageEdit,
];
/// Visible content kinds requested by the NIP-CW channel-window path.
static const channelTimelineContentKinds = [
streamMessage,
streamMessageV2,
streamMessageDiff,
systemMessage,
jobRequest,
jobAccepted,
jobProgress,
jobResult,
jobCancel,
jobError,
huddleStarted,
];
}
/// A Nostr event as defined by NIP-01.
@immutable
class NostrEvent {
final String id;
final String pubkey;
final int createdAt;
final int kind;
final List<List<String>> tags;
final String content;
final String sig;
const NostrEvent({
required this.id,
required this.pubkey,
required this.createdAt,
required this.kind,
required this.tags,
required this.content,
required this.sig,
});
factory NostrEvent.fromJson(Map<String, dynamic> json) {
return NostrEvent(
id: json['id'] as String,
pubkey: json['pubkey'] as String,
createdAt: json['created_at'] as int,
kind: json['kind'] as int,
tags: (json['tags'] as List<dynamic>)
.map((t) => (t as List<dynamic>).map((e) => e as String).toList())
.toList(),
content: json['content'] as String,
sig: json['sig'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'pubkey': pubkey,
'created_at': createdAt,
'kind': kind,
'tags': tags,
'content': content,
'sig': sig,
};
/// Get the first value for a given tag key.
String? getTagValue(String key) {
for (final tag in tags) {
if (tag.isNotEmpty && tag[0] == key && tag.length > 1) {
return tag[1];
}
}
return null;
}
/// The channel/group ID from the `h` tag (NIP-29).
String? get channelId => getTagValue('h');
/// Extract thread parent and root IDs from `e` tags.
///
/// Matches the desktop's `getThreadReference` logic:
/// - Tags with marker `"reply"` identify the direct parent.
/// - Tags with marker `"root"` identify the thread root.
/// - If no markers are present, falls back to null (top-level message).
({String? parentId, String? rootId}) get threadReference {
final eTags = [
for (final tag in tags)
if (tag.length >= 2 && tag[0] == 'e') tag,
];
if (eTags.isEmpty) return (parentId: null, rootId: null);
// Find tagged root and reply markers (desktop convention).
List<String>? rootTag;
List<String>? replyTag;
for (final tag in eTags) {
if (tag.length >= 4) {
if (tag[3] == 'root') rootTag = tag;
if (tag[3] == 'reply') replyTag = tag;
}
}
if (replyTag == null) return (parentId: null, rootId: null);
final parentId = replyTag[1];
final rootId = rootTag?[1] ?? parentId;
return (parentId: parentId, rootId: rootId);
}
/// The parent event ID from the `e` tag.
String? get parentEventId => threadReference.parentId;
@override
bool operator ==(Object other) =>
identical(this, other) || other is NostrEvent && id == other.id;
@override
int get hashCode => id.hashCode;
}
/// A NIP-01 subscription filter.
@immutable
class NostrFilter {
final List<int> kinds;
final List<String>? authors;
/// Specific event IDs (NIP-01 single-event lookup).
final List<String>? ids;
final int limit;
final int? since;
final int? until;
/// NIP-50 full-text search query.
final String? search;
/// Tag filters, e.g. `{'#h': ['channel-id']}`.
final Map<String, List<String>> tags;
/// Buzz relay bridge filter extensions (for example NIP-CW `top_level`).
final Map<String, Object?> extensions;
const NostrFilter({
required this.kinds,
this.authors,
this.ids,
this.limit = 100,
this.since,
this.until,
this.search,
this.tags = const {},
this.extensions = const {},
});
/// Return a copy with an updated `since` value.
NostrFilter copyWithSince(int since) => NostrFilter(
kinds: kinds,
authors: authors,
ids: ids,
limit: limit,
since: since,
until: until,
search: search,
tags: tags,
extensions: extensions,
);
Map<String, dynamic> toJson() {
final json = <String, dynamic>{'kinds': kinds, 'limit': limit};
if (authors != null) json['authors'] = authors;
if (ids != null) json['ids'] = ids;
if (since != null) json['since'] = since;
if (until != null) json['until'] = until;
if (search != null) json['search'] = search;
for (final entry in tags.entries) {
json[entry.key] = entry.value;
}
for (final entry in extensions.entries) {
json[entry.key] = entry.value;
}
return json;
}
}
/// Parsed kind:0 user profile metadata.
@immutable
class ProfileData {
final String pubkey;
final String? displayName;
final String? avatarUrl;
final String? about;
final String? nip05;
const ProfileData({
required this.pubkey,
this.displayName,
this.avatarUrl,
this.about,
this.nip05,
});
factory ProfileData.fromEvent(NostrEvent event) {
Map<String, dynamic> meta = {};
try {
final decoded = jsonDecode(event.content);
if (decoded is Map<String, dynamic>) meta = decoded;
} catch (_) {}
return ProfileData(
pubkey: event.pubkey,
displayName:
(meta['display_name'] as String?) ?? (meta['name'] as String?),
avatarUrl: meta['picture'] as String?,
about: meta['about'] as String?,
nip05: meta['nip05'] as String?,
);
}
}
/// Parsed kind:39000 channel metadata.
@immutable
class ChannelData {
final String id;
final String name;
final String channelType;
final String visibility;
final String description;
final String? topic;
final List<String> participantPubkeys;
final int? ttlSeconds;
final DateTime? ttlDeadline;
final bool isArchived;
const ChannelData({
required this.id,
required this.name,
required this.channelType,
required this.visibility,
required this.description,
this.topic,
this.participantPubkeys = const [],
this.ttlSeconds,
this.ttlDeadline,
this.isArchived = false,
});
factory ChannelData.fromEvent(NostrEvent event) {
final id = event.getTagValue('d') ?? '';
final name = event.getTagValue('name') ?? '';
// Prefer explicit ["t", type]; fall back to ["hidden"] => dm, else "stream".
// The fallback exists for relays that haven't been upgraded to emit the
// explicit type tag yet.
final explicitType = event.getTagValue('t');
final hasHidden = event.tags.any((t) => t.isNotEmpty && t[0] == 'hidden');
final channelType = explicitType ?? (hasHidden ? 'dm' : 'stream');
// Prefer explicit ["public"]; fall back to NIP-29 absence-of-"private".
final hasPublic = event.tags.any((t) => t.isNotEmpty && t[0] == 'public');
final hasPrivate = event.tags.any((t) => t.isNotEmpty && t[0] == 'private');
final visibility = hasPublic
? 'open'
: hasPrivate
? 'private'
: 'open';
final description = event.getTagValue('about') ?? '';
final topic = event.getTagValue('topic');
final participants = [
for (final t in event.tags)
if (t.length >= 2 && t[0] == 'p') t[1],
];
final ttlRaw = event.getTagValue('ttl');
final ttlSeconds = ttlRaw != null ? int.tryParse(ttlRaw) : null;
final ttlDeadlineRaw = event.getTagValue('ttl_deadline');
final ttlDeadline = ttlDeadlineRaw != null
? DateTime.tryParse(ttlDeadlineRaw)
: null;
// Relay republishes kind:39000 with `["archived", "true"]` when a channel
// is archived (including the auto-archive emitted by the TTL reaper). The
// tag value "false" is also accepted server-side, so only treat "true" as
// archived — anything else (missing tag, "false", unexpected value) means
// active.
final isArchived = event.getTagValue('archived') == 'true';
return ChannelData(
id: id,
name: name,
channelType: channelType,
visibility: visibility,
description: description,
topic: topic,
participantPubkeys: participants,
ttlSeconds: ttlSeconds,
ttlDeadline: ttlDeadline,
isArchived: isArchived,
);
}
}
/// A single member entry parsed from a kind:39002 members event.
@immutable
class MemberEntry {
final String pubkey;
final String role;
const MemberEntry({required this.pubkey, required this.role});
}
/// Parse a kind:39002 members event into the list of `(pubkey, role)` entries.
///
/// NIP-29 members tags follow the shape `["p", <pubkey>, <relay>, <role>]`.
List<MemberEntry> membersFromEvent(NostrEvent event) {
return [
for (final t in event.tags)
if (t.length >= 2 && t[0] == 'p')
MemberEntry(pubkey: t[1], role: t.length >= 4 ? t[3] : 'member'),
];
}
/// Parse a Buzz command response from the relay's OK message content.
///
/// Command kinds (e.g. 41010, 30620, 46020) return `"response:{...}"` in the
/// OK message. Returns `null` if the message is not a command response or the
/// JSON is invalid.
Map<String, dynamic>? parseCommandResponse(String message) {
// Try the spec format first: "response:{...}".
const prefix = 'response:';
if (message.startsWith(prefix)) {
try {
final decoded = jsonDecode(message.substring(prefix.length));
if (decoded is Map<String, dynamic>) return decoded;
} catch (_) {}
return null;
}
// Fallback: raw JSON object (older relays, backward compat).
try {
final decoded = jsonDecode(message);
if (decoded is Map<String, dynamic>) return decoded;
} catch (_) {}
return null;
}
+14
View File
@@ -0,0 +1,14 @@
export 'app_lifecycle_provider.dart';
export 'identity_scoped_prefs.dart';
export 'media_auth.dart';
export 'media_image.dart';
export 'media_upload.dart';
export 'nostr_filters.dart';
export 'nostr_models.dart';
export 'relay_closed_policy.dart';
export 'relay_client.dart';
export 'relay_provider.dart';
export 'relay_rate_limit_gate.dart';
export 'relay_session.dart';
export 'relay_socket.dart';
export 'signed_event_relay.dart';
+47
View File
@@ -0,0 +1,47 @@
import 'package:http/http.dart' as http;
/// Lightweight HTTP context for talking to the Buzz relay.
///
/// In the pure-nostr architecture, all data flow happens over the relay
/// WebSocket. This client now exists only to provide a base URL (and a
/// shared HTTP client) for the media upload endpoint, which is the one
/// remaining HTTP path because Blossom uses kind:24242 NIP-98 auth on a
/// regular HTTP PUT.
class RelayClient {
final String baseUrl;
final http.Client _http;
RelayClient({required this.baseUrl, http.Client? httpClient})
: _http = httpClient ?? http.Client();
/// Shared underlying HTTP client (used by [MediaUploader]).
http.Client get httpClient => _http;
/// Fully-qualified URL for the relay's Blossom-style media upload endpoint.
String get mediaUploadUrl {
final base = Uri.parse(baseUrl);
return base.resolve('/upload').toString();
}
void dispose() => _http.close();
}
/// Thrown when an HTTP call to the relay returns a non-2xx status code.
///
/// Retained for backwards compatibility with provider code that still
/// references it during the migration to pure-nostr WebSocket flows.
class RelayException implements Exception {
final int statusCode;
final String body;
RelayException(this.statusCode, this.body);
@override
String toString() {
final trimmedBody = body.trim();
if (trimmedBody.isEmpty) {
return 'RelayException($statusCode)';
}
return 'RelayException($statusCode): $trimmedBody';
}
}
@@ -0,0 +1,39 @@
/// Recovery policy for a relay `CLOSED` subscription message.
enum RelayClosedClass {
/// A transient failure that may recover when the same REQ is retried.
retryable,
/// Relay back-pressure that must also arm the shared request gate.
rateLimited,
/// An authorization, access, or filter failure that cannot recover unchanged.
terminal,
}
/// Classifies whether a relay `CLOSED` message should be retried.
RelayClosedClass classifyRelayClosed(String message) {
final normalized = message.trim().toLowerCase();
if (normalized.startsWith('rate-limited:')) {
return RelayClosedClass.rateLimited;
}
if (normalized.startsWith('restricted:') ||
normalized.startsWith('auth-required:') ||
normalized.startsWith('blocked:') ||
normalized.startsWith('invalid:') ||
normalized.startsWith('pow:') ||
normalized.startsWith('duplicate:') ||
normalized.startsWith('unsupported:') ||
normalized.startsWith('error: mixed search') ||
normalized.startsWith('error: too many subscriptions')) {
return RelayClosedClass.terminal;
}
return RelayClosedClass.retryable;
}
final _rateLimitRetryPattern = RegExp(r'retry in (\d+)s', caseSensitive: false);
/// Parses the relay's canonical `retry in Ns` hint, when present.
int? parseRateLimitRetrySeconds(String message) {
final match = _rateLimitRetryPattern.firstMatch(message);
return match == null ? null : int.tryParse(match.group(1)!);
}
+126
View File
@@ -0,0 +1,126 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../community/community_provider.dart';
import 'relay_client.dart';
/// Relay connection configuration.
///
/// In the pure-nostr world the only secrets the app cares about are:
/// - `baseUrl` — where the relay lives (used for WS + media upload)
/// - `nsec` — the user's signing key (drives NIP-42 AUTH and event sigs)
class RelayConfig {
const RelayConfig({required String baseUrl, this.nsec}) : _baseUrl = baseUrl;
/// Relay origin exactly as the active community stored it.
final String _baseUrl;
/// Nostr secret key (bech32 nsec) for signing events and NIP-42 AUTH.
final String? nsec;
/// The origin as persisted, before scheme canonicalization.
///
/// Exists solely so identity-scoped storage keys written before [baseUrl]
/// was canonicalized stay reachable — see [readMigratedPref]. Never use it
/// for network I/O; [baseUrl] and [wsUrl] are the addresses to connect to.
String get storedOrigin => _baseUrl;
/// Relay origin as an HTTP(S) URL.
///
/// Communities are persisted with whichever scheme their onboarding flow
/// used: device pairing stores `https://` (it rejects anything else), while
/// an invite join stores the `wss://` relay URL carried by the invite link.
/// Every consumer treats this as an HTTP origin — [wsUrl], the `/query`
/// endpoint, media upload and Blossom auth — so a `wss://` base silently
/// degrades all of them. Folding the websocket schemes back here keeps both
/// onboarding paths equivalent, including for already-persisted communities.
///
/// Derived rather than normalized in the constructor so that the constructor
/// stays `const`: the compile-time fallback below relies on canonicalization
/// to keep its identity stable across rebuilds, and Riverpod's default
/// `updateShouldNotify` is `previous != next`, which falls back to identity
/// here. A fresh instance per rebuild would resubscribe every listener.
String get baseUrl {
final uri = Uri.tryParse(_baseUrl);
if (uri == null) return _baseUrl;
final scheme = switch (uri.scheme) {
'wss' => 'https',
'ws' => 'http',
_ => null,
};
return scheme == null ? _baseUrl : uri.replace(scheme: scheme).toString();
}
/// Derive the websocket URL from the HTTP base URL.
String get wsUrl {
final uri = Uri.parse(baseUrl);
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
return uri.replace(scheme: scheme).toString();
}
}
/// Compile-time environment config via --dart-define.
///
/// Run with:
/// flutter run --dart-define=BUZZ_RELAY_URL=http://localhost:3000
///
/// Or create a `.env.json` and use --dart-define-from-file=.env.json
class Env {
static const relayUrl = String.fromEnvironment(
'BUZZ_RELAY_URL',
defaultValue: 'http://localhost:3000',
);
}
class RelayConfigNotifier extends Notifier<RelayConfig> {
@override
RelayConfig build() {
// Watch the active community so that when it changes (community switch),
// the config rebuilds, triggering the full provider cascade.
final activeAsync = ref.watch(activeCommunityProvider);
final active = activeAsync.value;
if (active != null) {
return RelayConfig(baseUrl: active.relayUrl, nsec: active.nsec);
}
// Fallback to compile-time env config (dev mode).
return const RelayConfig(baseUrl: Env.relayUrl);
}
void update({required String baseUrl, String? nsec}) {
state = RelayConfig(baseUrl: baseUrl, nsec: nsec);
}
}
final relayConfigProvider = NotifierProvider<RelayConfigNotifier, RelayConfig>(
RelayConfigNotifier.new,
);
/// Derive the hex pubkey from a bech32 nsec, or null on any failure.
String? pubkeyFromNsec(String? nsec) {
if (nsec == null || nsec.isEmpty) return null;
try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
} catch (_) {
return null;
}
}
/// The current user's hex pubkey, derived from the active community nsec.
final myPubkeyProvider = Provider<String?>((ref) {
final config = ref.watch(relayConfigProvider);
return pubkeyFromNsec(config.nsec);
});
/// Provides a [RelayClient] that reacts to config changes.
///
/// Only used for the media upload HTTP endpoint now — all data flow goes
/// through the relay WebSocket session.
final relayClientProvider = Provider<RelayClient>((ref) {
final config = ref.watch(relayConfigProvider);
final client = RelayClient(baseUrl: config.baseUrl);
ref.onDispose(client.dispose);
return client;
});
@@ -0,0 +1,80 @@
import 'dart:async';
import 'dart:math';
/// Creates a timer used by [RelayRateLimitGate].
typedef RelayTimerFactory =
Timer Function(Duration duration, void Function() callback);
/// Session-owned gate that pauses relay requests after back-pressure.
class RelayRateLimitGate {
/// Default gate duration when the relay omits a positive retry hint.
static const defaultRetrySeconds = 10;
/// Longest retry hint accepted from a relay response.
static const maxRetrySeconds = 300;
RelayRateLimitGate({
DateTime Function()? now,
RelayTimerFactory timerFactory = Timer.new,
}) : _now = now ?? DateTime.now,
_timerFactory = timerFactory;
final DateTime Function() _now;
final RelayTimerFactory _timerFactory;
DateTime? _expiresAt;
Timer? _timer;
Completer<void>? _completer;
/// Whether a rate-limit window is currently active.
bool get isActive {
final expiresAt = _expiresAt;
return expiresAt != null && _now().isBefore(expiresAt);
}
/// Activates or extends the gate without shrinking an existing window.
void activate(int? retryInSeconds) {
final seconds = retryInSeconds != null && retryInSeconds > 0
? min(retryInSeconds, maxRetrySeconds)
: defaultRetrySeconds;
final duration = Duration(seconds: seconds);
final newExpiry = _now().add(duration);
final currentExpiry = _expiresAt;
if (currentExpiry != null && !newExpiry.isAfter(currentExpiry)) return;
_expiresAt = newExpiry;
_timer?.cancel();
_completer ??= Completer<void>();
_timer = _timerFactory(duration, _expire);
}
/// Resolves when the active rate-limit window expires.
Future<void> wait() {
if (!isActive) return Future.value();
return _completer!.future;
}
/// Milliseconds remaining in the active window, or zero when inactive.
int remainingMs() {
final expiresAt = _expiresAt;
if (expiresAt == null) return 0;
return max(0, expiresAt.difference(_now()).inMilliseconds);
}
/// Clears the gate and releases all current waiters.
void reset() {
_timer?.cancel();
_timer = null;
_expiresAt = null;
final completer = _completer;
_completer = null;
if (completer != null && !completer.isCompleted) completer.complete();
}
void _expire() {
_timer = null;
_expiresAt = null;
final completer = _completer;
_completer = null;
if (completer != null && !completer.isCompleted) completer.complete();
}
}
+978
View File
@@ -0,0 +1,978 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
import 'package:uuid/uuid.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../auth/auth.dart';
import 'nostr_models.dart';
import 'relay_client.dart';
import 'relay_closed_policy.dart';
import 'relay_provider.dart';
import 'relay_rate_limit_gate.dart';
import 'relay_socket.dart';
enum SessionStatus { disconnected, connecting, connected, reconnecting }
@immutable
class SessionState {
final SessionStatus status;
final int reconnectAttempt;
const SessionState({required this.status, this.reconnectAttempt = 0});
}
class _HistorySubscription {
final List<NostrEvent> events = [];
final Completer<List<NostrEvent>> completer;
final Timer timeout;
_HistorySubscription({required this.completer, required this.timeout});
}
class _LiveSubscription {
final NostrFilter filter;
final void Function(NostrEvent) onEvent;
final void Function(String message)? onClosed;
Completer<void>? readyCompleter;
int? lastSeenCreatedAt;
int closedRetryAttempt = 0;
Timer? closedRetryTimer;
_LiveSubscription({
required this.filter,
required this.onEvent,
this.onClosed,
this.readyCompleter,
});
}
class _ClosedRetry {
final _LiveSubscription subscription;
final int generation;
_ClosedRetry({required this.subscription, required this.generation});
}
class _PendingEvent {
final Completer<NostrEvent> completer;
final Timer timeout;
_PendingEvent({required this.completer, required this.timeout});
}
class _BufferedEvent {
final String subId;
final NostrEvent event;
_BufferedEvent(this.subId, this.event);
}
/// Manages websocket subscriptions, event batching, reconnection with replay,
/// and pending event tracking. Equivalent to the desktop's RelayClientSession.
typedef RelaySocketFactory =
RelaySocket Function({
required String wsUrl,
required String? nsec,
required void Function(List<dynamic> message) onMessage,
required void Function() onConnected,
required void Function(Object? error) onDisconnected,
});
class RelaySessionNotifier extends Notifier<SessionState> {
RelaySessionNotifier({
http.Client? httpClient,
RelaySocketFactory socketFactory = RelaySocket.new,
DateTime Function()? now,
RelayRateLimitGate? rateLimitGate,
RelayTimerFactory retryTimerFactory = Timer.new,
Future<void> Function(Duration) replayDelay = Future.delayed,
}) : _httpClient = httpClient,
_socketFactory = socketFactory,
_now = now ?? DateTime.now,
_rateLimitGate = rateLimitGate ?? RelayRateLimitGate(),
_retryTimerFactory = retryTimerFactory,
_replayDelay = replayDelay;
final http.Client? _httpClient;
final RelaySocketFactory _socketFactory;
final DateTime Function() _now;
final RelayRateLimitGate _rateLimitGate;
final RelayTimerFactory _retryTimerFactory;
final Future<void> Function(Duration) _replayDelay;
static const _baseReconnectDelayMs = 1000;
static const _maxReconnectDelayMs = 30000;
static const _eventBatchMs = 16;
static const _reconnectReplaySkewSeconds = 5;
static const _replayBatchSize = 8;
static const _replayInterBatchDelay = Duration(milliseconds: 50);
static const _maxRecentDeliveryKeys = 5000;
static const _backgroundGraceDuration = Duration(seconds: 5);
RelaySocket? _socket;
final Map<String, _HistorySubscription> _historySubscriptions = {};
final Map<String, _LiveSubscription> _liveSubscriptions = {};
final Map<String, _ClosedRetry> _pendingClosedRetries = {};
final Map<String, _PendingEvent> _pendingEvents = {};
final List<_BufferedEvent> _eventBuffer = [];
final Set<String> _recentDeliveryKeys = {};
Timer? _reconnectTimer;
Timer? _flushTimer;
Timer? _backgroundGraceTimer;
DateTime? _backgroundedAt;
int _reconnectDelayMs = _baseReconnectDelayMs;
int _subIdCounter = 0;
bool _disposed = false;
bool _paused = false;
bool _hasConnectedOnce = false;
int _connectionGeneration = 0;
final Map<Object, String> _visibleChannelsByOwner = {};
bool _socketConnected = false;
bool _closedRetryReplayScheduled = false;
@override
SessionState build() {
final config = ref.watch(relayConfigProvider);
final authState = ref.watch(authProvider);
// Reset disposed flag — build() may re-run on the same Notifier instance
// after a provider dependency changes (e.g. auth completing).
_disposed = false;
ref.onDispose(_dispose);
// Auto-connect when authenticated and we have a signing key (NIP-42 AUTH).
final isAuthenticated = authState.value?.status == AuthStatus.authenticated;
if (isAuthenticated && config.nsec != null) {
// Schedule connection after build completes.
Future.microtask(() => _connect(config));
}
return const SessionState(status: SessionStatus.disconnected);
}
/// Execute a one-shot query via the relay's HTTP bridge (`POST /query`).
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
final config = ref.read(relayConfigProvider);
final url = Uri.parse(config.baseUrl).resolve('/query').toString();
final bodyBytes = utf8.encode(
jsonEncode(filters.map((filter) => filter.toJson()).toList()),
);
final client = _httpClient ?? http.Client();
final shouldCloseClient = _httpClient == null;
final response = await client
.post(
Uri.parse(url),
headers: {
'Authorization': buildNip98AuthHeader(
method: 'POST',
url: url,
bodyBytes: bodyBytes,
nsec: config.nsec,
),
'Content-Type': 'application/json',
},
body: bodyBytes,
)
.timeout(timeout)
.whenComplete(() {
if (shouldCloseClient) client.close();
});
if (response.statusCode < 200 || response.statusCode >= 300) {
_activateRateLimitGateFromHttpError(response.body);
throw RelayException(response.statusCode, response.body);
}
final decoded = jsonDecode(response.body);
if (decoded is! List) {
throw const FormatException('relay returned malformed query response');
}
try {
return [
for (final eventJson in decoded)
if (eventJson is Map<String, dynamic>)
NostrEvent.fromJson(eventJson)
else
throw const FormatException('relay returned malformed query event'),
];
} catch (error) {
if (error is FormatException) rethrow;
throw FormatException('relay returned malformed query event: $error');
}
}
void _activateRateLimitGateFromHttpError(String body) {
final dynamic decoded;
try {
decoded = jsonDecode(body);
} on FormatException {
return;
}
if (decoded is! Map<String, dynamic>) return;
final message = decoded['error'];
if (message is! String ||
classifyRelayClosed(message) != RelayClosedClass.rateLimited) {
return;
}
_rateLimitGate.activate(parseRateLimitRetrySeconds(message));
}
/// Fetch historical events matching [filter]. Sends REQ, collects events
/// until EOSE, then resolves. One-shot subscription.
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
if (_disposed) throw StateError('Relay session is disposed');
final subId = _nextSubId('h');
final completer = Completer<List<NostrEvent>>();
final timer = Timer(timeout, () {
final sub = _historySubscriptions.remove(subId);
if (sub != null && !sub.completer.isCompleted) {
sub.completer.completeError(
TimeoutException('Relay history request timed out after $timeout'),
);
}
_sendClose(subId);
});
_historySubscriptions[subId] = _HistorySubscription(
completer: completer,
timeout: timer,
);
_sendReq(subId, filter);
return completer.future;
}
/// Subscribe to live events matching [filter]. Returns an unsubscribe
/// function. Live subscriptions survive reconnects — they are replayed with
/// `since: lastSeenCreatedAt - 5s` on reconnect.
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
if (_disposed) throw StateError('Relay session is disposed');
final subId = _nextSubId('l');
final readyCompleter = Completer<void>();
_liveSubscriptions[subId] = _LiveSubscription(
filter: filter,
onEvent: onEvent,
onClosed: onClosed,
readyCompleter: readyCompleter,
);
_sendReq(subId, filter);
// Wait for EOSE or a short fallback timeout.
try {
await readyCompleter.future.timeout(
const Duration(milliseconds: 500),
onTimeout: () {},
);
} catch (_) {
_liveSubscriptions.remove(subId);
_recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:'));
rethrow;
}
final liveSub = _liveSubscriptions[subId];
if (liveSub != null && liveSub.readyCompleter == readyCompleter) {
liveSub.readyCompleter = null;
}
return () => _unsubscribe(subId);
}
/// Publish an event and wait for the relay's OK confirmation.
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) {
final completer = Completer<NostrEvent>();
final timer = Timer(timeout, () {
final pending = _pendingEvents.remove(event.id);
if (pending != null && !pending.completer.isCompleted) {
pending.completer.completeError(
TimeoutException(
'Event ${event.id} not acknowledged within $timeout',
),
);
}
});
_pendingEvents[event.id] = _PendingEvent(
completer: completer,
timeout: timer,
);
_socket?.send(['EVENT', event.toJson()]);
return completer.future;
}
/// Send a raw message over the WebSocket without waiting for acknowledgement.
/// Used for ephemeral events like typing indicators.
void sendRaw(List<dynamic> payload) {
_socket?.send(payload);
}
@visibleForTesting
void debugHandleMessage(List<dynamic> data) => _handleMessage(data);
@visibleForTesting
void debugFlushEventBuffer() => _flushEventBuffer();
@visibleForTesting
Future<void> debugHandleConnected() =>
_handleConnected(_connectionGeneration);
@visibleForTesting
Future<void> debugReplayLiveSubscriptions() =>
_replayLiveSubscriptions(_connectionGeneration);
@visibleForTesting
void debugDispose() => _dispose();
@visibleForTesting
void debugSupersedeConnection() => _connectionGeneration++;
@visibleForTesting
void debugHandleDisconnected([Object? error]) {
_socketConnected = false;
_handleDisconnected(_connectionGeneration, error);
}
@visibleForTesting
void debugResetClosedRetriesForDisconnect() {
_socketConnected = false;
_resetAllClosedRetries();
}
@visibleForTesting
void debugSetSessionStatus(SessionStatus status) {
_socketConnected = status == SessionStatus.connected;
}
@visibleForTesting
void debugPauseNow() => _pauseNow();
@visibleForTesting
void debugHandleSocketMessageForTest(List<dynamic> data) =>
_handleMessage(data);
@visibleForTesting
void debugAttachSocketForTest(RelaySocket socket) {
_socket?.dispose();
_socket = socket;
_socketConnected = true;
}
/// Registers a visible channel and returns an owner-scoped release callback.
/// The most recently registered owner is prioritized during reconnect replay.
void Function() registerVisibleChannel(String channelId) {
final owner = Object();
_visibleChannelsByOwner[owner] = channelId;
return () => _visibleChannelsByOwner.remove(owner);
}
/// Force a reconnect (e.g., returning from background).
Future<void> reconnect() async {
_socketConnected = false;
await _socket?.disconnect();
_reconnectDelayMs = _baseReconnectDelayMs;
final config = ref.read(relayConfigProvider);
await _connect(config);
}
/// Called by the app lifecycle provider when the app goes to background.
void onAppPaused() {
_backgroundedAt = _now();
_backgroundGraceTimer?.cancel();
_backgroundGraceTimer = Timer(_backgroundGraceDuration, _pauseNow);
}
void _pauseNow() {
_paused = true;
_socketConnected = false;
_reconnectTimer?.cancel();
_cancelAllHistory(Exception('App moved to background'));
_rejectAllPending(Exception('App moved to background'));
_socket?.disconnect();
state = const SessionState(status: SessionStatus.disconnected);
}
/// Called by the app lifecycle provider when the app returns to foreground.
void onAppResumed() {
_paused = false;
final backgroundedAt = _backgroundedAt;
_backgroundedAt = null;
_backgroundGraceTimer?.cancel();
_backgroundGraceTimer = null;
final backgroundedLongEnoughToRequireReconnect =
backgroundedAt != null &&
_now().difference(backgroundedAt) >= _backgroundGraceDuration;
if (!backgroundedLongEnoughToRequireReconnect &&
state.status == SessionStatus.connected) {
return;
}
// Cancel any in-flight reconnect backoff timer so we reconnect immediately
// instead of waiting for the (possibly large) exponential delay.
_reconnectTimer?.cancel();
_reconnectDelayMs = _baseReconnectDelayMs;
final config = ref.read(relayConfigProvider);
_connect(config);
}
Future<void> _connect(RelayConfig config) async {
if (_disposed) return;
final generation = ++_connectionGeneration;
state = SessionState(
status: _hasConnectedOnce
? SessionStatus.reconnecting
: SessionStatus.connecting,
reconnectAttempt: state.reconnectAttempt,
);
_socket?.dispose();
final socket = _socketFactory(
wsUrl: config.wsUrl,
nsec: config.nsec,
onMessage: (message) {
if (generation == _connectionGeneration) _handleMessage(message);
},
onConnected: () => _handleConnected(generation),
onDisconnected: (error) => _handleDisconnected(generation, error),
);
_socket = socket;
await socket.connect();
}
Future<void> _handleConnected(int generation) async {
if (_disposed || generation != _connectionGeneration) return;
_socketConnected = true;
_hasConnectedOnce = true;
_reconnectDelayMs = _baseReconnectDelayMs;
state = const SessionState(status: SessionStatus.connected);
await _replayLiveSubscriptions(generation);
}
void _handleDisconnected(int generation, Object? error) {
if (_disposed || generation != _connectionGeneration) return;
_socketConnected = false;
_cancelAllHistory(error);
_rejectAllPending(error);
_resetAllClosedRetries();
_eventBuffer.clear();
_flushTimer?.cancel();
_flushTimer = null;
if (error is RelayAuthRejectedException) {
_reconnectTimer?.cancel();
state = const SessionState(status: SessionStatus.disconnected);
return;
}
_scheduleReconnect();
}
void _scheduleReconnect() {
if (_disposed || _paused) return;
final attempt = state.reconnectAttempt + 1;
state = SessionState(
status: SessionStatus.reconnecting,
reconnectAttempt: attempt,
);
_reconnectTimer?.cancel();
_reconnectTimer = Timer(Duration(milliseconds: _reconnectDelayMs), () {
_reconnectDelayMs = min(_reconnectDelayMs * 2, _maxReconnectDelayMs);
final config = ref.read(relayConfigProvider);
_connect(config);
});
}
/// Replay all live subscriptions after a reconnect, with a time skew to
/// catch events that occurred during the disconnect.
Future<void> _replayLiveSubscriptions(int generation) async {
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
if (!_isActiveConnection(generation)) return;
final entries = _liveSubscriptions.entries.toList();
final visibleChannelId = _visibleChannelsByOwner.isEmpty
? null
: _visibleChannelsByOwner.values.last;
if (visibleChannelId != null) {
entries.sort((left, right) {
final leftVisible =
left.value.filter.tags['#h']?.contains(visibleChannelId) ?? false;
final rightVisible =
right.value.filter.tags['#h']?.contains(visibleChannelId) ?? false;
if (leftVisible == rightVisible) return 0;
return leftVisible ? -1 : 1;
});
}
await _sendReplayBatches(entries, generation);
}
Future<void> _replayPendingClosedRetries(int generation) async {
if (!_isActiveConnection(generation)) return;
final entries = _pendingClosedRetries.entries
.where((entry) => entry.value.generation == generation)
.map(
(entry) => MapEntry<String, _LiveSubscription>(
entry.key,
entry.value.subscription,
),
)
.toList();
await _sendReplayBatches(entries, generation, pendingClosedRetries: true);
}
Future<void> _sendReplayBatches(
List<MapEntry<String, _LiveSubscription>> entries,
int generation, {
bool pendingClosedRetries = false,
}) async {
for (var i = 0; i < entries.length; i += _replayBatchSize) {
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
if (!_isActiveConnection(generation)) return;
final batch = entries.sublist(
i,
min(i + _replayBatchSize, entries.length),
);
for (final entry in batch) {
if (_liveSubscriptions[entry.key] != entry.value) continue;
if (pendingClosedRetries) {
final pendingRetry = _pendingClosedRetries[entry.key];
if (pendingRetry?.subscription != entry.value ||
pendingRetry?.generation != generation) {
continue;
}
_pendingClosedRetries.remove(entry.key);
}
_sendReq(entry.key, _replayFilter(entry.value));
}
if (i + _replayBatchSize < entries.length) {
await _replayDelay(_replayInterBatchDelay);
}
}
}
bool _isActiveConnection(int generation) =>
!_disposed && generation == _connectionGeneration;
NostrFilter _replayFilter(_LiveSubscription subscription) {
final since = subscription.lastSeenCreatedAt;
return since == null
? subscription.filter
: subscription.filter.copyWithSince(
max(0, since - _reconnectReplaySkewSeconds),
);
}
void _handleMessage(List<dynamic> data) {
if (data.isEmpty) return;
final type = data[0] as String;
switch (type) {
case 'EVENT':
_handleEvent(data);
case 'EOSE':
_handleEose(data);
case 'CLOSED':
_handleClosed(data);
case 'OK':
_handleOk(data);
}
}
void _handleEvent(List<dynamic> data) {
if (data.length < 3) return;
final subId = data[1] as String;
final eventJson = data[2] as Map<String, dynamic>;
final event = NostrEvent.fromJson(eventJson);
// History subscriptions accumulate immediately.
final historySub = _historySubscriptions[subId];
if (historySub != null) {
historySub.events.add(event);
return;
}
// Live subscriptions get batched.
final liveSub = _liveSubscriptions[subId];
if (liveSub != null) {
_resetClosedRetry(liveSub);
// Track last seen timestamp for reconnect replay.
if (liveSub.lastSeenCreatedAt == null ||
event.createdAt > liveSub.lastSeenCreatedAt!) {
liveSub.lastSeenCreatedAt = event.createdAt;
}
_eventBuffer.add(_BufferedEvent(subId, event));
_scheduleFlush();
}
}
void _handleEose(List<dynamic> data) {
if (data.length < 2) return;
final subId = data[1] as String;
// History subscription: resolve with collected events.
final historySub = _historySubscriptions.remove(subId);
if (historySub != null) {
historySub.timeout.cancel();
if (!historySub.completer.isCompleted) {
historySub.completer.complete(historySub.events);
}
_sendClose(subId);
return;
}
// Live subscription: signal ready.
final liveSub = _liveSubscriptions[subId];
if (liveSub != null) {
_resetClosedRetry(liveSub);
}
if (liveSub != null &&
liveSub.readyCompleter != null &&
!liveSub.readyCompleter!.isCompleted) {
// EOSE is the boundary between replay and live delivery. Flush any
// replay events before resolving subscribe(), so callers that begin a
// one-shot query immediately afterwards cannot classify a delayed batch
// callback as having arrived during that query.
_flushBufferedEventsNow();
liveSub.readyCompleter!.complete();
liveSub.readyCompleter = null;
}
}
void _handleClosed(List<dynamic> data) {
if (data.length < 2) return;
final subId = data[1] as String;
final message = data.length >= 3 && data[2] is String
? data[2] as String
: 'subscription closed by relay';
final closedClass = classifyRelayClosed(message);
final historySub = _historySubscriptions.remove(subId);
if (historySub != null) {
if (closedClass == RelayClosedClass.rateLimited) {
_rateLimitGate.activate(parseRateLimitRetrySeconds(message));
}
historySub.timeout.cancel();
if (!historySub.completer.isCompleted) {
historySub.completer.completeError(Exception(message));
}
return;
}
final liveSub = _liveSubscriptions[subId];
if (liveSub == null) return;
final readyCompleter = liveSub.readyCompleter;
if (closedClass == RelayClosedClass.terminal) {
if (readyCompleter != null && !readyCompleter.isCompleted) {
readyCompleter.completeError(Exception(message));
}
liveSub.onClosed?.call(message);
_removeLiveSubscription(subId, liveSub);
return;
}
if (readyCompleter != null && !readyCompleter.isCompleted) {
readyCompleter.complete();
liveSub.readyCompleter = null;
}
if (liveSub.closedRetryTimer != null) return;
final attempt = liveSub.closedRetryAttempt;
final backoffMs = attempt >= 5
? _maxReconnectDelayMs
: _baseReconnectDelayMs * (1 << attempt);
var delayMs = backoffMs;
if (closedClass == RelayClosedClass.rateLimited) {
final retrySeconds = parseRateLimitRetrySeconds(message);
_rateLimitGate.activate(retrySeconds);
final fallbackMs =
(retrySeconds != null && retrySeconds > 0
? min(retrySeconds, RelayRateLimitGate.maxRetrySeconds)
: RelayRateLimitGate.defaultRetrySeconds) *
1000;
delayMs = max(
backoffMs,
_rateLimitGate.remainingMs() == 0
? fallbackMs
: _rateLimitGate.remainingMs(),
);
}
liveSub.closedRetryAttempt = attempt + 1;
final retryGeneration = _connectionGeneration;
liveSub.closedRetryTimer = _retryTimerFactory(
Duration(milliseconds: delayMs),
() async {
liveSub.closedRetryTimer = null;
if (!_isActiveConnection(retryGeneration) ||
_liveSubscriptions[subId] != liveSub) {
return;
}
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
if (!_isActiveConnection(retryGeneration) ||
_liveSubscriptions[subId] != liveSub ||
!_socketConnected) {
return;
}
_pendingClosedRetries[subId] = _ClosedRetry(
subscription: liveSub,
generation: retryGeneration,
);
_scheduleClosedRetryReplay(retryGeneration);
},
);
}
void _scheduleClosedRetryReplay(int generation) {
if (_closedRetryReplayScheduled) return;
_closedRetryReplayScheduled = true;
scheduleMicrotask(() async {
try {
await _replayPendingClosedRetries(generation);
} finally {
_closedRetryReplayScheduled = false;
_pendingClosedRetries.removeWhere(
(_, retry) => retry.generation != _connectionGeneration,
);
if (_pendingClosedRetries.values.any(
(retry) => retry.generation == _connectionGeneration,
)) {
_scheduleClosedRetryReplay(_connectionGeneration);
}
}
});
}
void _handleOk(List<dynamic> data) {
if (data.length < 3) return;
final eventId = data[1] as String;
final accepted = data[2] as bool;
final message = data.length > 3 && data[3] is String
? data[3] as String
: '';
final pending = _pendingEvents.remove(eventId);
if (pending == null) return;
pending.timeout.cancel();
if (accepted) {
// We don't have the full event here; create a minimal placeholder.
// Command kinds (e.g. 41010, 30620, 46020) return "response:{...}" in
// the OK message — preserve it in `content` so callers can parse it.
if (!pending.completer.isCompleted) {
pending.completer.complete(
NostrEvent(
id: eventId,
pubkey: '',
createdAt: 0,
kind: 0,
tags: [],
content: message,
sig: '',
),
);
}
} else {
if (!pending.completer.isCompleted) {
pending.completer.completeError(
Exception(message.isNotEmpty ? message : 'Event rejected'),
);
}
}
}
void _scheduleFlush() {
_flushTimer ??= Timer(
const Duration(milliseconds: _eventBatchMs),
_flushEventBuffer,
);
}
void _flushBufferedEventsNow() {
_flushTimer?.cancel();
_flushTimer = null;
_flushEventBuffer();
}
void _flushEventBuffer() {
_flushTimer = null;
if (_eventBuffer.isEmpty) return;
final batch = List<_BufferedEvent>.from(_eventBuffer);
_eventBuffer.clear();
for (final buffered in batch) {
final sub = _liveSubscriptions[buffered.subId];
if (sub == null) continue;
// Deduplicate per subscription. The same relay event can legitimately
// match multiple live subscriptions, e.g. the channel list unread listener
// and the open channel message listener.
final deliveryKey = '${buffered.subId}:${buffered.event.id}';
if (_recentDeliveryKeys.contains(deliveryKey)) continue;
// Cap the dedup set to prevent unbounded memory growth.
if (_recentDeliveryKeys.length >= _maxRecentDeliveryKeys) {
_recentDeliveryKeys.clear();
}
_recentDeliveryKeys.add(deliveryKey);
sub.onEvent(buffered.event);
}
}
String _nextSubId(String prefix) {
_subIdCounter++;
return '$prefix-$_subIdCounter';
}
void _sendReq(String subId, NostrFilter filter) {
_socket?.send(['REQ', subId, filter.toJson()]);
}
void _sendClose(String subId) {
_socket?.send(['CLOSE', subId]);
}
void _unsubscribe(String subId) {
final subscription = _liveSubscriptions[subId];
if (subscription != null) {
_removeLiveSubscription(subId, subscription);
}
_sendClose(subId);
}
void _removeLiveSubscription(String subId, _LiveSubscription subscription) {
if (_liveSubscriptions[subId] != subscription) return;
_liveSubscriptions.remove(subId);
_pendingClosedRetries.remove(subId);
subscription.closedRetryTimer?.cancel();
subscription.closedRetryTimer = null;
_recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:'));
}
void _resetClosedRetry(_LiveSubscription subscription) {
subscription.closedRetryAttempt = 0;
subscription.closedRetryTimer?.cancel();
subscription.closedRetryTimer = null;
}
void _cancelAllClosedRetries() {
_pendingClosedRetries.clear();
for (final subscription in _liveSubscriptions.values) {
subscription.closedRetryTimer?.cancel();
subscription.closedRetryTimer = null;
}
}
void _resetAllClosedRetries() {
_pendingClosedRetries.clear();
for (final subscription in _liveSubscriptions.values) {
_resetClosedRetry(subscription);
}
}
void _cancelAllHistory(Object? error) {
for (final entry in _historySubscriptions.values) {
entry.timeout.cancel();
if (!entry.completer.isCompleted) {
entry.completer.completeError(error ?? Exception('Connection lost'));
}
}
_historySubscriptions.clear();
}
void _rejectAllPending(Object? error) {
for (final entry in _pendingEvents.values) {
entry.timeout.cancel();
if (!entry.completer.isCompleted) {
entry.completer.completeError(error ?? Exception('Connection lost'));
}
}
_pendingEvents.clear();
}
void _dispose() {
_disposed = true;
_connectionGeneration++;
_reconnectTimer?.cancel();
_flushTimer?.cancel();
_backgroundGraceTimer?.cancel();
_backgroundedAt = null;
_cancelAllClosedRetries();
_rateLimitGate.reset();
_visibleChannelsByOwner.clear();
_socketConnected = false;
_cancelAllHistory(null);
_rejectAllPending(null);
final subscriptions = _liveSubscriptions.values.toList();
_liveSubscriptions.clear();
for (final subscription in subscriptions) {
subscription.closedRetryTimer?.cancel();
subscription.closedRetryTimer = null;
}
_recentDeliveryKeys.clear();
_socket?.dispose();
_socket = null;
_httpClient?.close();
}
}
final relaySessionProvider =
NotifierProvider<RelaySessionNotifier, SessionState>(
RelaySessionNotifier.new,
);
String buildNip98AuthHeader({
required String method,
required String url,
required List<int> bodyBytes,
required String? nsec,
}) {
if (nsec == null || nsec.isEmpty) {
throw Exception('Cannot query relay: no signing key available');
}
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
final payloadHash = SHA256Digest()
.process(Uint8List.fromList(bodyBytes))
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final event = nostr.Event.from(
kind: 27235,
content: '',
tags: [
['u', url],
['method', method.toUpperCase()],
['payload', payloadHash],
['nonce', const Uuid().v4()],
],
secretKey: privkeyHex,
verify: false,
);
return 'Nostr ${base64.encode(utf8.encode(event.toJson()))}';
}
+261
View File
@@ -0,0 +1,261 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'nostr_models.dart';
/// Low-level websocket connection with NIP-42 authentication.
///
/// Handles the raw websocket lifecycle: connect, authenticate via NIP-42
/// challenge/response, send/receive JSON frames, and disconnect.
///
/// Does NOT handle reconnection — that is [RelaySessionNotifier]'s job.
enum SocketState { disconnected, connecting, authenticating, connected }
class RelayAuthRejectedException implements Exception {
final String message;
const RelayAuthRejectedException(this.message);
@override
String toString() => 'Relay authentication rejected: $message';
}
Exception classifyRelayAuthFailure(String message) {
if (message.startsWith('error:')) return Exception(message);
return RelayAuthRejectedException(message);
}
class RelaySocket {
/// Interval for sending a ping and awaiting its pong before disconnecting.
static const pingInterval = Duration(seconds: 30);
@visibleForTesting
static Duration debugPingInterval = pingInterval;
final String _wsUrl;
final String? _nsec;
final void Function(List<dynamic> message) _onMessage;
final void Function() _onConnected;
final void Function(Object? error) _onDisconnected;
WebSocketChannel? _channel;
StreamSubscription<dynamic>? _subscription;
SocketState _state = SocketState.disconnected;
Completer<void>? _authCompleter;
Timer? _authTimeout;
String? _pendingAuthEventId;
SocketState get state => _state;
RelaySocket({
required String wsUrl,
required String? nsec,
required void Function(List<dynamic> message) onMessage,
required void Function() onConnected,
required void Function(Object? error) onDisconnected,
}) : _wsUrl = wsUrl,
_nsec = nsec,
_onMessage = onMessage,
_onConnected = onConnected,
_onDisconnected = onDisconnected;
/// Connect to the relay and complete NIP-42 authentication.
Future<void> connect() async {
if (_state != SocketState.disconnected) return;
_state = SocketState.connecting;
try {
_channel = IOWebSocketChannel.connect(
Uri.parse(_wsUrl),
pingInterval: debugPingInterval,
);
await _channel!.ready;
} catch (e) {
_state = SocketState.disconnected;
_onDisconnected(e);
return;
}
// The channel may have been disposed while we were awaiting ready
// (e.g. provider rebuild triggered dispose() concurrently).
if (_channel == null) {
_state = SocketState.disconnected;
return;
}
_state = SocketState.authenticating;
_authCompleter = Completer<void>();
_subscription = _channel!.stream.listen(
_handleRawMessage,
onError: (Object error) {
_failAuth(error);
_resetConnection();
_onDisconnected(error);
},
onDone: () {
_failAuth(null);
_resetConnection();
_onDisconnected(null);
},
);
// Wait for auth to complete (or timeout).
_authTimeout = Timer(const Duration(seconds: 8), () {
if (_authCompleter != null && !_authCompleter!.isCompleted) {
_authCompleter!.completeError(
TimeoutException('NIP-42 auth timed out after 8s'),
);
}
});
try {
await _authCompleter!.future;
_authTimeout?.cancel();
_state = SocketState.connected;
_onConnected();
} catch (e) {
_authTimeout?.cancel();
await disconnect();
_onDisconnected(e);
}
}
/// Send a raw JSON array over the websocket.
void send(List<dynamic> payload) {
_channel?.sink.add(jsonEncode(payload));
}
/// Gracefully close the connection.
Future<void> disconnect() async {
_resetConnection();
final channel = _channel;
_channel = null;
if (channel != null) {
await channel.sink.close();
}
}
void dispose() {
_resetConnection();
_channel?.sink.close();
_channel = null;
}
void _resetConnection() {
_state = SocketState.disconnected;
_subscription?.cancel();
_subscription = null;
_authTimeout?.cancel();
_authTimeout = null;
_pendingAuthEventId = null;
}
void _failAuth(Object? error) {
if (_authCompleter != null && !_authCompleter!.isCompleted) {
_authCompleter!.completeError(error ?? Exception('Connection closed'));
}
}
void _handleRawMessage(dynamic raw) {
final String text;
if (raw is String) {
text = raw;
} else {
return; // Binary frames are not part of the Nostr protocol.
}
final List<dynamic> data;
try {
data = jsonDecode(text) as List<dynamic>;
} catch (_) {
return; // Malformed JSON.
}
if (data.isEmpty) return;
final type = data[0] as String;
switch (type) {
case 'AUTH':
_handleAuthChallenge(data);
case 'OK':
_handleOk(data);
default:
// Pass EVENT, EOSE, NOTICE, etc. upstream.
_onMessage(data);
}
}
/// Handle the relay's AUTH challenge: sign a kind:22242 event and respond.
void _handleAuthChallenge(List<dynamic> data) {
if (data.length < 2) return;
final challenge = data[1] as String;
if (_nsec == null) {
_failAuth(Exception('No nsec available for NIP-42 auth'));
return;
}
try {
// Decode bech32 nsec to hex private key.
final privkeyHex = nostr.Nip19.decode(payload: _nsec).data;
if (privkeyHex.isEmpty) {
_failAuth(Exception('Invalid nsec'));
return;
}
// Build the auth tags.
final tags = <List<String>>[
['relay', _wsUrl],
['challenge', challenge],
];
// Create and sign the kind:22242 AUTH event.
final event = nostr.Event.from(
kind: EventKind.auth,
content: '',
tags: tags,
secretKey: privkeyHex,
);
_pendingAuthEventId = event.id;
send(['AUTH', event.toMap()]);
} catch (e) {
_failAuth(e);
}
}
@visibleForTesting
void debugHandleOkForTest(List<dynamic> data) => _onMessage(data);
/// Handle OK frames. During auth, complete the auth flow.
void _handleOk(List<dynamic> data) {
if (data.length < 3) return;
final eventId = data[1] as String;
final accepted = data[2] as bool;
// Check if this OK is for our pending AUTH event.
if (_pendingAuthEventId != null && eventId == _pendingAuthEventId) {
_pendingAuthEventId = null;
if (accepted) {
if (_authCompleter != null && !_authCompleter!.isCompleted) {
_authCompleter!.complete();
}
} else {
final message = data.length > 3
? data[3] as String
: 'Auth rejected by relay';
_failAuth(classifyRelayAuthFailure(message));
}
return;
}
// Pass non-auth OK frames upstream for pending event tracking.
_onMessage(data);
}
}
@@ -0,0 +1,164 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
/// Validates an untrusted relay origin before the mobile app connects to it.
///
/// Production relay URLs must use TLS and may not contain local or otherwise
/// non-public IP literals. Debug builds retain plaintext localhost support for
/// local development, but other non-public destinations remain blocked.
void validateInviteRelayUri(
Uri uri, {
bool allowInsecureLocalhost = kDebugMode,
}) {
if (uri.host.isEmpty ||
uri.userInfo.isNotEmpty ||
uri.hasFragment ||
(uri.path.isNotEmpty && uri.path != '/') ||
uri.hasQuery) {
throw const FormatException('Relay URL must be an origin');
}
if (uri.scheme != 'ws' && uri.scheme != 'wss') {
throw FormatException('Invalid relay URL scheme: ${uri.scheme}');
}
final host = uri.host.toLowerCase();
final isLocalhost = host == 'localhost' || host.endsWith('.localhost');
if (uri.scheme != 'wss' && !(allowInsecureLocalhost && isLocalhost)) {
throw const FormatException('Relay URL must use WSS');
}
if (isLocalhost) {
if (!allowInsecureLocalhost) {
throw const FormatException('Relay URL cannot target localhost');
}
return;
}
final address = InternetAddress.tryParse(host);
if (_looksLikeNumericAddress(host)) {
// Only canonical dotted-decimal IPv4 is accepted. Ambiguous legacy forms
// such as a single integer, hexadecimal, shortened components, or leading
// zeroes have varied interpretation across URI/network stacks.
final ipv4Parts = host.split('.');
final hasLeadingZero = ipv4Parts.any(
(part) => part.length > 1 && part.startsWith('0'),
);
if (address == null ||
address.type != InternetAddressType.IPv4 ||
address.address != host ||
ipv4Parts.length != 4 ||
hasLeadingZero) {
throw const FormatException('Relay URL contains an invalid IP address');
}
}
if (address != null) {
if (!_isPublicAddress(address)) {
throw const FormatException(
'Relay URL cannot target non-public network addresses',
);
}
return;
}
// DNS hostnames are allowed here. Preventing a hostname from resolving or
// rebinding to a non-public address requires connect-time resolution and
// address pinning in the networking layer.
}
bool _looksLikeNumericAddress(String host) {
if (RegExp(r'^\d+$').hasMatch(host) ||
RegExp(r'^0x[0-9a-f]+$', caseSensitive: false).hasMatch(host)) {
return true;
}
final parts = host.split('.');
return parts.length > 1 &&
parts.every(
(part) =>
RegExp(r'^\d+$').hasMatch(part) ||
RegExp(r'^0x[0-9a-f]+$', caseSensitive: false).hasMatch(part),
);
}
bool _isPublicAddress(InternetAddress address) {
final bytes = address.rawAddress;
if (address.type == InternetAddressType.IPv4) {
return !_isNonPublicIpv4(bytes);
}
if (address.type != InternetAddressType.IPv6 || bytes.length != 16) {
return false;
}
// IPv4-compatible and IPv4-mapped IPv6 addresses inherit the embedded
// IPv4 address's classification.
final firstTenZero = bytes.take(10).every((byte) => byte == 0);
if (firstTenZero &&
((bytes[10] == 0 && bytes[11] == 0) ||
(bytes[10] == 0xff && bytes[11] == 0xff))) {
return !_isNonPublicIpv4(bytes.sublist(12));
}
// Accept only globally reachable IPv6 literals. Global unicast space is
// 2000::/3, with special-purpose exclusions and narrow globally reachable
// exceptions tracked by IANA (reconciled 2026-07-26):
// https://www.iana.org/assignments/iana-ipv6-special-registry/
if (!_hasPrefix(bytes, [0x20], 3)) return false;
// 2001::/23 is reserved for IETF protocol assignments. Only these entries
// are currently classified by IANA as globally reachable.
if (_hasPrefix(bytes, [0x20, 0x01, 0x00], 23) &&
!_isGloballyReachableIetfAssignment(bytes)) {
return false;
}
// Documentation and transition prefixes are not public relay destinations.
if (_hasPrefix(bytes, [0x20, 0x01, 0x0d, 0xb8], 32)) return false;
if (_hasPrefix(bytes, [0x20, 0x02], 16)) return false;
if (_hasPrefix(bytes, [0x3f, 0xff, 0x00], 20)) return false;
return true;
}
bool _isGloballyReachableIetfAssignment(List<int> bytes) {
final isGlobalAnycast =
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x01, 0, 0, 0, 0], 64) &&
bytes.sublist(8, 15).every((byte) => byte == 0) &&
bytes[15] >= 1 &&
bytes[15] <= 3;
return isGlobalAnycast ||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x03], 32) ||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x04, 0x01, 0x12], 48) ||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x20], 28) ||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x30], 28);
}
bool _hasPrefix(List<int> address, List<int> prefix, int prefixLength) {
final wholeBytes = prefixLength ~/ 8;
for (var i = 0; i < wholeBytes; i++) {
if (address[i] != prefix[i]) return false;
}
final remainingBits = prefixLength % 8;
if (remainingBits == 0) return true;
final mask = (0xff << (8 - remainingBits)) & 0xff;
return (address[wholeBytes] & mask) == (prefix[wholeBytes] & mask);
}
bool _isNonPublicIpv4(List<int> bytes) {
if (bytes.length != 4) return true;
final a = bytes[0];
final b = bytes[1];
final c = bytes[2];
return a == 0 ||
a == 10 ||
a == 127 ||
(a == 100 && b >= 64 && b <= 127) ||
(a == 169 && b == 254) ||
(a == 172 && b >= 16 && b <= 31) ||
(a == 192 && b == 0 && c == 0) ||
(a == 192 && b == 0 && c == 2) ||
(a == 192 && b == 168) ||
(a == 198 && (b == 18 || b == 19)) ||
(a == 198 && b == 51 && c == 100) ||
(a == 203 && b == 0 && c == 113) ||
a >= 224;
}
@@ -0,0 +1,59 @@
import 'package:nostr/nostr.dart' as nostr;
import 'nostr_models.dart';
import 'relay_session.dart';
/// Signs and submits Nostr events through the relay WebSocket connection.
class SignedEventRelay {
final RelaySessionNotifier _session;
final String? _nsec;
SignedEventRelay({
required RelaySessionNotifier session,
required String? nsec,
}) : _session = session,
_nsec = nsec;
/// The hex pubkey derived from the signing key, or null if no key.
String? get pubkey {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) return null;
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
}
/// Sign and submit an event. Returns the relay's OK response as a [NostrEvent]
/// whose `content` field contains the OK message (e.g. `"response:{...}"`
/// for command kinds).
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent event)? onSigned,
}) async {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) {
throw Exception('Cannot submit event: no signing key available');
}
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
final event = nostr.Event.from(
kind: kind,
content: content,
tags: tags,
secretKey: privkeyHex,
createdAt: createdAt,
verify: false,
);
final nostrEvent = NostrEvent.fromJson(event.toMap());
onSigned?.call(nostrEvent);
return _session.publish(nostrEvent);
}
}