Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 50 additions & 9 deletions mobile/lib/shared/identity_archives/identity_archives.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'dart:isolate';
import 'package:buzz/shared/crypto/event_signing.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:nostr/nostr.dart' as nostr;

import '../relay/relay.dart';
import '../me/me_sync.dart';
Expand All @@ -15,6 +16,14 @@ final relayInformationClientProvider = Provider<http.Client>((ref) {
return client;
});

/// The last verified archive list per community and authority, so a refresh
/// asks the relay only for a newer list instead of downloading it again.
class _ArchiveCache {
final entries = <(String, String), ({int createdAt, Set<String> archived})>{};
}

final _archiveCacheProvider = Provider<_ArchiveCache>((_) => _ArchiveCache());

/// The identities this relay's NIP-IA archive list (kind 13535) names, never
/// the viewer: an archive stays visible to its subject. Empty when the list
/// cannot be read. This is presentation, never ownership or admission.
Expand Down Expand Up @@ -57,12 +66,37 @@ final identityArchivesProvider = FutureProvider<Set<String>>((ref) async {
!RegExp(r'^[0-9a-f]{64}$').hasMatch(authority)) {
return {};
}
final cache = ref.read(_archiveCacheProvider).entries;
final key = (sync.community, authority);
final known = cache[key];
// Ask only for a list newer than the known one. This assumes each list is
// stamped strictly later than the previous one, which the relay does
// except when two lists are published in the same second; then the
// second can be missed until a later list arrives or the app restarts.
final events = await sync.session.fetchHistory(
NostrFilter(kinds: const [13535], authors: [authority], limit: 1),
NostrFilter(
kinds: const [13535],
authors: [authority],
since: known == null ? null : known.createdAt + 1,
limit: 1,
),
timeout: const Duration(seconds: 3),
);
sync.check();
return (await _parseArchives(events, authority))..remove(sync.pubkey);
// Hand back the known instance when it does not name the viewer, so an
// unchanged list does not rebuild every watcher.
Set<String> visible(Set<String> archived) => archived.contains(sync.pubkey)
? ({...archived}..remove(sync.pubkey))
: archived;
if (known != null && events.isEmpty) return visible(known.archived);
final archived = await _parseArchives(events, authority);
if (events.length != 1 || archived == null) {
cache.remove(key);
return {};
}
final shared = Set<String>.unmodifiable(archived);
cache[key] = (createdAt: events.single.createdAt, archived: shared);
return visible(shared);
} catch (_) {
return {};
}
Expand Down Expand Up @@ -92,23 +126,30 @@ final hiddenArchivedIdentitiesProvider = Provider<Set<String>>((ref) {
return known.$2;
});

Future<Set<String>> _parseArchives(List<NostrEvent> events, String authority) =>
Isolate.run(() => _archived(events, authority));
/// The archived identities, or null when [events] is not one valid list.
Future<Set<String>?> _parseArchives(
List<NostrEvent> events,
String authority,
) => Isolate.run(() => _archived(events, authority));

Set<String> _archived(List<NostrEvent> events, String authority) {
if (events.length != 1) return {};
Set<String>? _archived(List<NostrEvent> events, String authority) {
if (events.length != 1) return null;
final event = events.single;
final markers = event.tags.where((tag) => tag.isNotEmpty && tag.first == '-');
if (event.kind != 13535 ||
event.pubkey != authority ||
event.content.isNotEmpty ||
markers.length != 1 ||
markers.single.length != 1) {
return {};
return null;
}
final encoded = jsonEncode(event.toJson());
if (utf8.encode(encoded).length > 2 * 1024 * 1024) return {};
verifiedNostrEventFromJson(encoded);
if (utf8.encode(encoded).length > 2 * 1024 * 1024) return null;
try {
verifiedNostrEventFromJson(encoded);
} on nostr.EventValidationException {
return null;
}
return {
for (final tag in event.tags)
if (tag.length >= 2 &&
Expand Down
116 changes: 98 additions & 18 deletions mobile/test/shared/identity_archives/identity_archives_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ void main() {
final authority = nostr.Keys.generate();
final impostor = nostr.Keys.generate();
final target = 'a' * 64;
var createdAt = 1700000000;
NostrEvent snapshot(nostr.Keys signer, {bool marker = true}) =>
NostrEvent.fromJson(
nostr.Event.from(
kind: 13535,
content: '',
secretKey: signer.secret,
createdAt: createdAt++,
tags: [
if (marker) ['-'],
['p', target],
Expand Down Expand Up @@ -54,12 +56,22 @@ void main() {
addTearDown(client.close);
expect(await container.read(identityArchivesProvider.future), {target});
expect(relay.author, authority.public);
for (final invalid in [
snapshot(impostor),
snapshot(authority, marker: false),
NostrEvent.fromJson(snapshot(authority).toJson()..['sig'] = '0' * 128),
for (final invalid in <NostrEvent Function()>[
() => snapshot(impostor),
() => snapshot(authority, marker: false),
() => NostrEvent.fromJson(
snapshot(authority).toJson()..['sig'] = '0' * 128,
),
]) {
relay.event = invalid;
// Each case starts from a warm cache, so it must also drop the known
// list: once the relay has nothing newer, nobody stays hidden.
relay.event = snapshot(authority);
container.invalidate(identityArchivesProvider);
expect(await container.read(identityArchivesProvider.future), {target});
relay.event = invalid();
container.invalidate(identityArchivesProvider);
expect(await container.read(identityArchivesProvider.future), isEmpty);
relay.event = null;
container.invalidate(identityArchivesProvider);
expect(await container.read(identityArchivesProvider.future), isEmpty);
}
Expand All @@ -77,17 +89,19 @@ void main() {
final authority = nostr.Keys.generate();
final archivedInA = 'a' * 64;
final archivedInB = 'b' * 64;
NostrEvent snapshot(List<String> keys) => NostrEvent.fromJson(
nostr.Event.from(
kind: 13535,
content: '',
secretKey: authority.secret,
tags: [
['-'],
for (final key in keys) ['p', key],
],
).toMap(),
);
NostrEvent snapshot(List<String> keys, {int? createdAt}) =>
NostrEvent.fromJson(
nostr.Event.from(
kind: 13535,
content: '',
secretKey: authority.secret,
createdAt: createdAt,
tags: [
['-'],
for (final key in keys) ['p', key],
],
).toMap(),
);
// Community A's list also names the viewer, who must stay visible.
final relay = _ArchiveRelay(snapshot([archivedInA, viewer.public]));
final communityB = Completer<void>();
Expand All @@ -110,7 +124,10 @@ void main() {
);
addTearDown(container.dispose);
addTearDown(client.close);
container.listen(hiddenArchivedIdentitiesProvider, (_, _) {});
var hiddenChanges = 0;
container.listen(hiddenArchivedIdentitiesProvider, (_, _) {
hiddenChanges++;
});
Future<Set<String>> settled() async {
await container.read(identityArchivesProvider.future);
await pumpEventQueue();
Expand All @@ -122,6 +139,16 @@ void main() {
container.invalidate(identityArchivesProvider);
expect(container.read(hiddenArchivedIdentitiesProvider), {archivedInA});
expect(await settled(), {archivedInA});
// An unchanged list leaves the hidden set alone for every watcher.
relay.event = snapshot([
archivedInA,
], createdAt: relay.event!.createdAt + 10);
container.invalidate(identityArchivesProvider);
expect(await settled(), {archivedInA});
final changes = hiddenChanges;
container.invalidate(identityArchivesProvider);
expect(await settled(), {archivedInA});
expect(hiddenChanges, changes);

// Switch community and account: A's list no longer applies, so nobody
// is hidden until B's list arrives.
Expand All @@ -136,6 +163,55 @@ void main() {
},
);

test('a refresh downloads the list again only when it changed', () async {
final owner = nostr.Keys.generate();
final authority = nostr.Keys.generate();
NostrEvent snapshot(String target, int createdAt) => NostrEvent.fromJson(
nostr.Event.from(
kind: 13535,
content: '',
secretKey: authority.secret,
createdAt: createdAt,
tags: [
['-'],
['p', target],
],
).toMap(),
);
final relay = _ArchiveRelay(snapshot('a' * 64, 1700000000));
final client = MockClient(
(_) async => http.Response(jsonEncode({'self': authority.public}), 200),
);
final container = ProviderContainer(
overrides: [
meSyncProvider.overrideWithValue(
MeSync(
relay,
RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec),
),
),
relayInformationClientProvider.overrideWithValue(client),
],
);
addTearDown(container.dispose);
addTearDown(client.close);
expect(await container.read(identityArchivesProvider.future), {'a' * 64});
expect(relay.since, isNull);

// Unchanged: the relay has nothing newer, and the known list stands.
container.invalidate(identityArchivesProvider);
expect(await container.read(identityArchivesProvider.future), {'a' * 64});
expect(relay.since, 1700000001);

// Replaced: the newer list wins and becomes the new baseline.
relay.event = snapshot('b' * 64, 1700000100);
container.invalidate(identityArchivesProvider);
expect(await container.read(identityArchivesProvider.future), {'b' * 64});
container.invalidate(identityArchivesProvider);
expect(await container.read(identityArchivesProvider.future), {'b' * 64});
expect(relay.since, 1700000101);
});

test('slow metadata has a total deadline and cancels its stream', () async {
final owner = nostr.Keys.generate();
final relay = _ArchiveRelay(null);
Expand Down Expand Up @@ -201,14 +277,18 @@ class _ArchiveRelay extends RelaySessionNotifier {
_ArchiveRelay(this.event);
NostrEvent? event;
String? author;
int? since;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
expect(filter.kinds, [13535]);
author = filter.authors!.single;
return [?event];
since = filter.since;
final event = this.event;
if (event == null || event.createdAt < (filter.since ?? 0)) return [];
return [event];
}
}

Expand Down
Loading