9dfa06ffee
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>
194 lines
4.6 KiB
Dart
194 lines
4.6 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../relay/nostr_models.dart';
|
|
|
|
const readStateDTagPrefix = 'read-state:';
|
|
const readStateFetchLimit = 500;
|
|
const readStateHorizonSeconds = 7 * 24 * 60 * 60;
|
|
const _maxContexts = 10000;
|
|
const msgContextPrefix = 'msg:';
|
|
const threadContextPrefix = 'thread:';
|
|
|
|
String msgContextKey(String messageId) => '$msgContextPrefix$messageId';
|
|
String threadContextKey(String rootId) => '$threadContextPrefix$rootId';
|
|
|
|
int? maxReadAt(Iterable<int?> markers) {
|
|
int? latest;
|
|
for (final marker in markers) {
|
|
if (marker == null) continue;
|
|
if (latest == null || marker > latest) {
|
|
latest = marker;
|
|
}
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
typedef ReadStateDecrypt = String Function(String ciphertext);
|
|
|
|
class ReadStateBlob {
|
|
final String clientId;
|
|
final Map<String, int> contexts;
|
|
|
|
ReadStateBlob({required this.clientId, required Map<String, int> contexts})
|
|
: contexts = Map.unmodifiable(contexts);
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'v': 1,
|
|
'client_id': clientId,
|
|
'contexts': contexts,
|
|
};
|
|
}
|
|
|
|
class DecodedReadStateEvent {
|
|
final NostrEvent event;
|
|
final String dTag;
|
|
final ReadStateBlob blob;
|
|
|
|
const DecodedReadStateEvent({
|
|
required this.event,
|
|
required this.dTag,
|
|
required this.blob,
|
|
});
|
|
}
|
|
|
|
bool isPlainJsonObject(Object? value) {
|
|
if (value is! Map) return false;
|
|
return value.keys.every((key) => key is String);
|
|
}
|
|
|
|
Map<String, Object?>? asStringObjectMap(Object? value) {
|
|
if (!isPlainJsonObject(value)) return null;
|
|
return (value as Map).cast<String, Object?>();
|
|
}
|
|
|
|
bool isValidReadStateDTag(String? value) {
|
|
if (value == null || !value.startsWith(readStateDTagPrefix)) {
|
|
return false;
|
|
}
|
|
|
|
final slotId = value.substring(readStateDTagPrefix.length);
|
|
if (slotId.isEmpty || slotId.length > 64) {
|
|
return false;
|
|
}
|
|
|
|
for (var index = 0; index < slotId.length; index++) {
|
|
if (slotId.codeUnitAt(index) > 0x7f) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool hasValidReadStateTags(NostrEvent event) {
|
|
final dTags = event.tags.where((tag) => tag.isNotEmpty && tag[0] == 'd');
|
|
if (dTags.length != 1) {
|
|
return false;
|
|
}
|
|
final dTag = dTags.single;
|
|
if (dTag.length < 2 || !isValidReadStateDTag(dTag[1])) {
|
|
return false;
|
|
}
|
|
|
|
final tTags = event.tags.where(
|
|
(tag) => tag.length >= 2 && tag[0] == 't' && tag[1] == 'read-state',
|
|
);
|
|
return tTags.length == 1;
|
|
}
|
|
|
|
ReadStateBlob? decodeReadStateBlob(String plaintext) {
|
|
final Object? parsed;
|
|
try {
|
|
parsed = jsonDecode(plaintext);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
|
|
final record = asStringObjectMap(parsed);
|
|
if (record == null) return null;
|
|
|
|
if (record['v'] != 1) return null;
|
|
|
|
final clientId = record['client_id'];
|
|
if (clientId is! String || clientId.isEmpty || clientId.runes.length > 64) {
|
|
return null;
|
|
}
|
|
|
|
final contexts = asStringObjectMap(record['contexts']);
|
|
if (contexts == null || contexts.length > _maxContexts) {
|
|
return null;
|
|
}
|
|
|
|
return ReadStateBlob(
|
|
clientId: clientId,
|
|
contexts: sanitizeReadStateContexts(contexts),
|
|
);
|
|
}
|
|
|
|
Map<String, int> sanitizeReadStateContexts(Map<String, Object?> contexts) {
|
|
final sanitized = <String, int>{};
|
|
for (final entry in contexts.entries) {
|
|
if (utf8.encode(entry.key).length > 256) continue;
|
|
|
|
final value = entry.value;
|
|
if (value is! int) continue;
|
|
if (value < 0 || value > 4294967295) continue;
|
|
|
|
sanitized[entry.key] = value;
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
DecodedReadStateEvent? decodeReadStateEvent(
|
|
NostrEvent event, {
|
|
required String pubkey,
|
|
required ReadStateDecrypt decrypt,
|
|
}) {
|
|
if (event.pubkey.toLowerCase() != pubkey.toLowerCase()) {
|
|
return null;
|
|
}
|
|
if (!hasValidReadStateTags(event)) {
|
|
return null;
|
|
}
|
|
|
|
final dTag = event.tags.firstWhere(
|
|
(tag) => tag.isNotEmpty && tag[0] == 'd',
|
|
)[1];
|
|
|
|
final String plaintext;
|
|
try {
|
|
plaintext = decrypt(event.content);
|
|
} catch (e) {
|
|
debugPrint(
|
|
'[ReadStateManager] decrypt failed for event ${event.id.substring(0, 8)}…: $e',
|
|
);
|
|
return null;
|
|
}
|
|
|
|
final blob = decodeReadStateBlob(plaintext);
|
|
if (blob == null) {
|
|
debugPrint(
|
|
'[ReadStateManager] blob decode failed for event ${event.id.substring(0, 8)}…',
|
|
);
|
|
return null;
|
|
}
|
|
|
|
return DecodedReadStateEvent(event: event, dTag: dTag, blob: blob);
|
|
}
|
|
|
|
Map<String, int> mergeReadStateContexts(
|
|
Iterable<Map<String, int>> contextSets,
|
|
) {
|
|
final merged = <String, int>{};
|
|
for (final contexts in contextSets) {
|
|
for (final entry in contexts.entries) {
|
|
final current = merged[entry.key] ?? 0;
|
|
if (entry.value > current) {
|
|
merged[entry.key] = entry.value;
|
|
}
|
|
}
|
|
}
|
|
return merged;
|
|
}
|