mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
perf(mobile): sign navigation media requests off the UI isolate
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -30,7 +30,8 @@ final videoPreviewFrameLoaderProvider = Provider<VideoPreviewFrameLoader>((
|
||||
ref,
|
||||
) {
|
||||
final auth = ref.watch(mediaGetAuthServiceProvider);
|
||||
return (url) => _loadVideoPreviewFrame(url, headers: auth.headersFor(url));
|
||||
return (url) async =>
|
||||
_loadVideoPreviewFrame(url, headers: await auth.headersForAsync(url));
|
||||
});
|
||||
|
||||
class _MessageVideoPreview extends HookConsumerWidget {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
@@ -37,6 +39,7 @@ class MediaGetAuthService {
|
||||
|
||||
Map<String, String>? _cachedHeaders;
|
||||
DateTime? _refreshAt;
|
||||
Future<void> _signingTail = Future<void>.value();
|
||||
|
||||
MediaGetAuthService({
|
||||
required String baseUrl,
|
||||
@@ -55,6 +58,35 @@ class MediaGetAuthService {
|
||||
return _isRelayMediaUrl(uri, relayUri);
|
||||
}
|
||||
|
||||
/// Mints fetch-time auth without doing cryptography on the UI isolate.
|
||||
///
|
||||
/// Image loading starts during layout. Serialize signing per auth scope so a
|
||||
/// viewport full of uncached images does not spawn an isolate per row at once.
|
||||
/// Timestamp each proof when its queued job starts, not when it was enqueued.
|
||||
Future<Map<String, String>> headersForAsync(String url) {
|
||||
final nsec = _nsec;
|
||||
if (nsec == null || nsec.isEmpty || !isRelayMediaUrl(url)) {
|
||||
return Future.value(const {});
|
||||
}
|
||||
final released = Completer<void>();
|
||||
final result = _signingTail.then((_) async {
|
||||
try {
|
||||
return await compute(_mediaHeadersInBackground, (
|
||||
baseUrl: _baseUrl,
|
||||
nsec: nsec,
|
||||
url: url,
|
||||
now: _now(),
|
||||
), debugLabel: 'media-get-auth');
|
||||
} finally {
|
||||
// A failed worker must propagate to its fetch without blocking later
|
||||
// requests in the queue.
|
||||
released.complete();
|
||||
}
|
||||
});
|
||||
_signingTail = released.future;
|
||||
return result;
|
||||
}
|
||||
|
||||
Map<String, String> headersFor(String url) {
|
||||
final nsec = _nsec;
|
||||
if (nsec == null || nsec.isEmpty) return const {};
|
||||
@@ -132,6 +164,14 @@ class MediaGetAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> _mediaHeadersInBackground(
|
||||
({String baseUrl, String nsec, String url, DateTime now}) request,
|
||||
) => MediaGetAuthService(
|
||||
baseUrl: request.baseUrl,
|
||||
nsec: request.nsec,
|
||||
now: () => request.now,
|
||||
).headersFor(request.url);
|
||||
|
||||
final mediaGetAuthServiceProvider = Provider<MediaGetAuthService>((ref) {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
return MediaGetAuthService(
|
||||
|
||||
@@ -101,6 +101,7 @@ class MediaImageProvider extends ImageProvider<MediaImageProvider> {
|
||||
}
|
||||
|
||||
final uri = Uri.parse(url);
|
||||
final headers = await auth.headersForAsync(url);
|
||||
final http.Response response;
|
||||
try {
|
||||
final abort = Completer<void>();
|
||||
@@ -110,7 +111,7 @@ class MediaImageProvider extends ImageProvider<MediaImageProvider> {
|
||||
'GET',
|
||||
uri,
|
||||
abortTrigger: abort.future,
|
||||
)..headers.addAll(auth.headersFor(url));
|
||||
)..headers.addAll(headers);
|
||||
response = await http.Response.fromStream(await client.send(request));
|
||||
} finally {
|
||||
deadline.cancel();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/shared/relay/media_auth.dart';
|
||||
import 'package:buzz/shared/relay/media_image.dart';
|
||||
@@ -70,6 +70,83 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('background media auth', () {
|
||||
test(
|
||||
'mints fresh valid server-scoped proofs for concurrent requests',
|
||||
() async {
|
||||
final keys = nostr.Keys.generate();
|
||||
final signedAt = DateTime.utc(2026, 10, 17, 12);
|
||||
final auth = _auth(nsec: keys.nsec, now: () => signedAt);
|
||||
final headers = await Future.wait([
|
||||
auth.headersForAsync(_mediaUrl),
|
||||
auth.headersForAsync(_mediaUrl),
|
||||
]);
|
||||
final events = headers.map((header) {
|
||||
final encoded = header['Authorization']!.substring('Nostr '.length);
|
||||
return nostr.Event.fromJson(
|
||||
utf8.decode(base64Url.decode(base64Url.normalize(encoded))),
|
||||
);
|
||||
}).toList();
|
||||
expect(events[0].sig, isNot(events[1].sig));
|
||||
for (final event in events) {
|
||||
expect(event.isValid(), isTrue);
|
||||
expect(event.pubkey, keys.public);
|
||||
expect(event.kind, 24242);
|
||||
expect(event.tags, contains(equals(['t', 'get'])));
|
||||
expect(event.tags, contains(equals(['server', 'relay.example.com'])));
|
||||
final expiration = int.parse(
|
||||
event.tags.singleWhere((tag) => tag.first == 'expiration')[1],
|
||||
);
|
||||
expect(expiration, signedAt.millisecondsSinceEpoch ~/ 1000 + 60);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps the signing identity separate across auth scopes', () async {
|
||||
final first = nostr.Keys.generate();
|
||||
final second = nostr.Keys.generate();
|
||||
for (final keys in [first, second]) {
|
||||
final header = await _auth(nsec: keys.nsec).headersForAsync(_mediaUrl);
|
||||
final encoded = header['Authorization']!.substring('Nostr '.length);
|
||||
final event = nostr.Event.fromJson(
|
||||
utf8.decode(base64Url.decode(base64Url.normalize(encoded))),
|
||||
);
|
||||
expect(event.pubkey, keys.public);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not expose auth to other origins or invalid paths', () async {
|
||||
final auth = _auth(nsec: nostr.Keys.generate().nsec);
|
||||
for (final url in [
|
||||
'https://elsewhere.com/media/a.png',
|
||||
'$_relayBase/media-evil/a.png',
|
||||
'ftp://relay.example.com/media/a.png',
|
||||
]) {
|
||||
expect(await auth.headersForAsync(url), isEmpty);
|
||||
}
|
||||
expect(await _auth().headersForAsync(_mediaUrl), isEmpty);
|
||||
expect(await _auth(nsec: 'invalid').headersForAsync(_mediaUrl), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'a failed queued job propagates without poisoning later jobs',
|
||||
() async {
|
||||
var calls = 0;
|
||||
final auth = _auth(
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
now: () {
|
||||
if (calls++ == 0) throw StateError('clock unavailable');
|
||||
return DateTime.now();
|
||||
},
|
||||
);
|
||||
final failed = auth.headersForAsync(_mediaUrl);
|
||||
final next = auth.headersForAsync(_mediaUrl);
|
||||
await expectLater(failed, throwsStateError);
|
||||
expect(await next, isNotEmpty);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('MediaImageProvider cache identity', () {
|
||||
test('equal url + same auth service => equal keys', () {
|
||||
final auth = _auth(nsec: nostr.Keys.generate().nsec);
|
||||
@@ -111,6 +188,34 @@ void main() {
|
||||
});
|
||||
|
||||
group('MediaImageProvider fetching', () {
|
||||
test(
|
||||
'waits for asynchronous auth before fetching, without synchronous signing',
|
||||
() async {
|
||||
var fetches = 0;
|
||||
final auth = _DeferredMediaAuth();
|
||||
final client = http_testing.MockClient((request) async {
|
||||
fetches++;
|
||||
expect(request.headers['Authorization'], 'Nostr deferred');
|
||||
return http.Response.bytes(_pngBytes, 200);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
final provider = MediaImageProvider(
|
||||
url: _mediaUrl,
|
||||
auth: auth,
|
||||
client: client,
|
||||
);
|
||||
final loaded = expectLater(
|
||||
_wait(provider.resolve(ImageConfiguration.empty)),
|
||||
completes,
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(fetches, 0);
|
||||
auth.headers.complete({'Authorization': 'Nostr deferred'});
|
||||
await loaded;
|
||||
expect(fetches, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test('resolves one fetch for repeated resolves of the same key', () async {
|
||||
var fetches = 0;
|
||||
final client = http_testing.MockClient((request) async {
|
||||
@@ -242,3 +347,13 @@ Future<Object?> _waitError(ImageStream stream) async {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
class _DeferredMediaAuth extends MediaGetAuthService {
|
||||
_DeferredMediaAuth() : super(baseUrl: _relayBase, nsec: null);
|
||||
final headers = Completer<Map<String, String>>();
|
||||
@override
|
||||
Future<Map<String, String>> headersForAsync(String url) => headers.future;
|
||||
@override
|
||||
Map<String, String> headersFor(String url) =>
|
||||
throw StateError('Image layout must not sign synchronously');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user