mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
perf(mobile): reuse prepared media signing identities
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -46,6 +46,10 @@ class MediaGetAuthService {
|
||||
final String? _nsec;
|
||||
final DateTime Function() _now;
|
||||
|
||||
// Prepared only after a scoped request. Never shared across service/account
|
||||
// lifetimes; background requests return it for reuse by the next queued job.
|
||||
nostr.Keys? _signingIdentity;
|
||||
|
||||
Map<String, String>? _cachedHeaders;
|
||||
DateTime? _refreshAt;
|
||||
// Includes the running job. Bound retained work during rapid history scrolls.
|
||||
@@ -90,12 +94,15 @@ class MediaGetAuthService {
|
||||
final released = Completer<void>();
|
||||
final result = _signingTail.then((_) async {
|
||||
try {
|
||||
return await compute(_mediaHeadersInBackground, (
|
||||
final signed = await compute(_mediaHeadersInBackground, (
|
||||
baseUrl: _baseUrl,
|
||||
nsec: nsec,
|
||||
identity: _signingIdentity,
|
||||
url: url,
|
||||
now: _now(),
|
||||
), debugLabel: 'media-get-auth');
|
||||
_signingIdentity ??= signed.identity;
|
||||
return signed.headers;
|
||||
} finally {
|
||||
// A failed worker must propagate to its fetch without blocking later
|
||||
// requests in the queue.
|
||||
@@ -192,11 +199,14 @@ class MediaGetAuthService {
|
||||
}
|
||||
|
||||
nostr.Event _buildGetAuthEvent(String nsec) {
|
||||
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
|
||||
final privkeyHex =
|
||||
_signingIdentity?.secret ?? nostr.Nip19.decode(payload: nsec).data;
|
||||
if (privkeyHex.isEmpty) {
|
||||
throw Exception('Invalid nsec');
|
||||
}
|
||||
|
||||
final identity = _signingIdentity ??= nostr.Keys(privkeyHex);
|
||||
|
||||
final expiration =
|
||||
(_now().millisecondsSinceEpoch ~/ 1000) + _mediaGetAuthLifetimeSeconds;
|
||||
final tags = <List<String>>[
|
||||
@@ -210,19 +220,31 @@ class MediaGetAuthService {
|
||||
kind: _mediaGetAuthKind,
|
||||
content: 'Get buzz-media',
|
||||
tags: tags,
|
||||
secretKey: privkeyHex,
|
||||
secretKey: identity.secret,
|
||||
pubkey: identity.public,
|
||||
verify: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
({Map<String, String> headers, nostr.Keys? identity}) _mediaHeadersInBackground(
|
||||
({
|
||||
String baseUrl,
|
||||
String nsec,
|
||||
nostr.Keys? identity,
|
||||
String url,
|
||||
DateTime now,
|
||||
})
|
||||
request,
|
||||
) {
|
||||
final service = MediaGetAuthService(
|
||||
baseUrl: request.baseUrl,
|
||||
nsec: request.nsec,
|
||||
now: () => request.now,
|
||||
).._signingIdentity = request.identity;
|
||||
final headers = service.headersFor(request.url);
|
||||
return (headers: headers, identity: service._signingIdentity);
|
||||
}
|
||||
|
||||
final mediaGetAuthServiceProvider = Provider<MediaGetAuthService>((ref) {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
|
||||
@@ -3,10 +3,12 @@ import 'dart:convert';
|
||||
|
||||
import 'package:buzz/shared/relay/media_auth.dart';
|
||||
import 'package:buzz/shared/relay/media_image.dart';
|
||||
import 'package:buzz/shared/relay/relay_provider.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart' as http_testing;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
// Minimal valid 1x1 transparent PNG.
|
||||
@@ -102,6 +104,89 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'prepared identity still mints fresh proofs after clock advances',
|
||||
() async {
|
||||
final keys = nostr.Keys.generate();
|
||||
var now = DateTime.utc(2026, 10, 18, 12);
|
||||
final auth = _auth(nsec: keys.nsec, now: () => now);
|
||||
final first = await auth.headersForAsync(_mediaUrl);
|
||||
now = now.add(const Duration(minutes: 2));
|
||||
final next = await auth.headersForAsync(_mediaUrl);
|
||||
final encoded = next['Authorization']!.substring('Nostr '.length);
|
||||
final event = nostr.Event.fromJson(
|
||||
utf8.decode(base64Url.decode(base64Url.normalize(encoded))),
|
||||
);
|
||||
expect(event.pubkey, keys.public);
|
||||
expect(
|
||||
event.tags,
|
||||
contains(
|
||||
equals([
|
||||
'expiration',
|
||||
'${now.millisecondsSinceEpoch ~/ 1000 + 60}',
|
||||
]),
|
||||
),
|
||||
);
|
||||
expect(next, isNot(equals(first)));
|
||||
expect(auth.headersFor('https://elsewhere.com/media/a'), isEmpty);
|
||||
expect(
|
||||
await auth.headersForAsync('https://elsewhere.com/media/a'),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'provider replacement isolates warmed and queued identities',
|
||||
() async {
|
||||
final first = nostr.Keys.generate();
|
||||
final second = nostr.Keys.generate();
|
||||
final container = ProviderContainer(
|
||||
overrides: [relayConfigProvider.overrideWith(_TestRelayConfig.new)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final config = container.read(relayConfigProvider.notifier);
|
||||
config.update(baseUrl: _relayBase, nsec: first.nsec);
|
||||
final old = container.read(mediaGetAuthServiceProvider);
|
||||
await old.headersForAsync(_mediaUrl);
|
||||
final queued = old.headersForAsync(_mediaUrl);
|
||||
config.update(baseUrl: _relayBase, nsec: second.nsec);
|
||||
final replacement = container.read(mediaGetAuthServiceProvider);
|
||||
expect(identical(old, replacement), isFalse);
|
||||
final results = await Future.wait([
|
||||
queued,
|
||||
replacement.headersForAsync(_mediaUrl),
|
||||
replacement.headersForAsync(_mediaUrl),
|
||||
]);
|
||||
for (var i = 0; i < results.length; i++) {
|
||||
final encoded = results[i]['Authorization']!.substring(
|
||||
'Nostr '.length,
|
||||
);
|
||||
final event = nostr.Event.fromJson(
|
||||
utf8.decode(base64Url.decode(base64Url.normalize(encoded))),
|
||||
);
|
||||
expect(event.pubkey, i == 0 ? first.public : second.public);
|
||||
}
|
||||
config.update(
|
||||
baseUrl: 'https://replacement.example',
|
||||
nsec: second.nsec,
|
||||
);
|
||||
final switched = container.read(mediaGetAuthServiceProvider);
|
||||
expect(await switched.headersForAsync(_mediaUrl), isEmpty);
|
||||
expect(
|
||||
await switched.headersForAsync('https://replacement.example/media/a'),
|
||||
isNotEmpty,
|
||||
);
|
||||
config.update(baseUrl: _relayBase, nsec: null);
|
||||
expect(
|
||||
await container
|
||||
.read(mediaGetAuthServiceProvider)
|
||||
.headersForAsync(_mediaUrl),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('bounds queued signing and admits requests after it drains', () async {
|
||||
final auth = _auth(nsec: nostr.Keys.generate().nsec);
|
||||
// Enqueue in one turn, before a worker can finish. Record every failure
|
||||
@@ -399,3 +484,8 @@ class _DeferredMediaAuth extends MediaGetAuthService {
|
||||
Map<String, String> headersFor(String url) =>
|
||||
throw StateError('Image layout must not sign synchronously');
|
||||
}
|
||||
|
||||
class _TestRelayConfig extends RelayConfigNotifier {
|
||||
@override
|
||||
RelayConfig build() => const RelayConfig(baseUrl: _relayBase);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user