diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index 908d404835..f4113292dc 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -50,7 +50,10 @@ public protocol BuzzPushNotificationResolving { /// Reads configured Buzz communities and resolves their newest unread event. public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { - static let maximumPresentationResponseBytes = 128 * 1_024 + // Verified profiles may carry bounded inline raster avatars. Keep the refresh + // response capped, but large enough to recover the sender name from those + // otherwise valid kind-0 events when the app cache has not been populated. + static let maximumPresentationResponseBytes = 256 * 1_024 private let session: URLSession private let loadCommunitiesData: () -> Data? @@ -213,13 +216,13 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { else { return false } return cachedChannel.memberDigests?.count != memberCount }() - let channelNeedsRefresh = channelID != nil && ( - Self.isStale( + let channelNeedsRefresh = + channelID != nil + && (Self.isStale( cachedAt: cachedChannel?.cachedAt, now: timestamp, lifetime: presentationCacheLifetime - ) || membershipNeedsRefresh - ) + ) || membershipNeedsRefresh) let fallback = Self.makeResolution( event: event, community: community, @@ -237,50 +240,58 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { refreshProfile: profileNeedsRefresh, refreshChannel: channelNeedsRefresh ) { refreshedProfileEvent, refreshedChannelEvent, refreshedMembershipEvent in - let profile = refreshedProfileEvent.flatMap { - guard BuzzPushPresentationCacheStore.shouldReplace( - existingCreatedAt: cachedProfile?.eventCreatedAt, - existingID: cachedProfile?.eventID, - candidateCreatedAt: $0.createdAt, - candidateID: $0.id - ) else { return nil } - return Self.ephemeralProfile( - event: $0, - communityID: community.id, - relayOrigin: relayOrigin ?? community.relayUrl, - cached: cachedProfile, - cachedAt: timestamp - ) - } ?? cachedProfile + let profile = + refreshedProfileEvent.flatMap { + guard + BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedProfile?.eventCreatedAt, + existingID: cachedProfile?.eventID, + candidateCreatedAt: $0.createdAt, + candidateID: $0.id + ) + else { return nil } + return Self.ephemeralProfile( + event: $0, + communityID: community.id, + relayOrigin: relayOrigin ?? community.relayUrl, + cached: cachedProfile, + cachedAt: timestamp + ) + } ?? cachedProfile let newerChannelEvent: VerifiedNostrEvent? = refreshedChannelEvent.flatMap { event in - guard BuzzPushPresentationCacheStore.shouldReplace( - existingCreatedAt: cachedChannel?.eventCreatedAt, - existingID: cachedChannel?.eventID, - candidateCreatedAt: event.createdAt, - candidateID: event.id - ) else { return nil } + guard + BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedChannel?.eventCreatedAt, + existingID: cachedChannel?.eventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { return nil } return event } let newerMembershipEvent: VerifiedNostrEvent? = refreshedMembershipEvent.flatMap { event in - guard BuzzPushPresentationCacheStore.shouldReplace( - existingCreatedAt: cachedChannel?.membershipEventCreatedAt, - existingID: cachedChannel?.membershipEventID, - candidateCreatedAt: event.createdAt, - candidateID: event.id - ) else { return nil } + guard + BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedChannel?.membershipEventCreatedAt, + existingID: cachedChannel?.membershipEventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { return nil } return event } - let channel = relayMetadataPubkey.flatMap { - Self.ephemeralChannel( - metadataEvent: newerChannelEvent, - membershipEvent: newerMembershipEvent, - cached: cachedChannel, - communityID: community.id, - relayOrigin: relayOrigin ?? community.relayUrl, - relayMetadataPubkey: $0, - cachedAt: timestamp - ) - } ?? cachedChannel + let channel = + relayMetadataPubkey.flatMap { + Self.ephemeralChannel( + metadataEvent: newerChannelEvent, + membershipEvent: newerMembershipEvent, + cached: cachedChannel, + communityID: community.id, + relayOrigin: relayOrigin ?? community.relayUrl, + relayMetadataPubkey: $0, + cachedAt: timestamp + ) + } ?? cachedChannel completion( Self.makeResolution( event: event, @@ -297,9 +308,10 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { community: PushLeaseCommunity, refreshProfile: Bool, refreshChannel: Bool, - completion: @escaping ( - VerifiedNostrEvent?, VerifiedNostrEvent?, VerifiedNostrEvent? - ) -> Void + completion: + @escaping ( + VerifiedNostrEvent?, VerifiedNostrEvent?, VerifiedNostrEvent? + ) -> Void ) { guard let privateKey = loadPrivateKey(community.id), let relayURL = community.relayURL, @@ -338,14 +350,19 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { var request = URLRequest(url: url) request.httpMethod = "POST" request.httpBody = body - request.timeoutInterval = 1 + // A verified kind-0 profile may include a bounded inline raster avatar. + // Railway can take longer than one second to return that larger response, + // while three seconds remains a small fraction of the NSE execution budget. + request.timeoutInterval = 3 request.setValue("application/json", forHTTPHeaderField: "Content-Type") - guard let auth = try? NostrHTTPAuth.authorizationHeader( - url: url, - method: "POST", - body: body, - privateKeyHex: privateKey - ) else { + guard + let auth = try? NostrHTTPAuth.authorizationHeader( + url: url, + method: "POST", + body: body, + privateKeyHex: privateKey + ) + else { completion(nil, nil, nil) return } @@ -364,23 +381,32 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { return } let verified = events.filter { $0.hasValidIDAndSignature() } - let profile = refreshProfile ? Self.newest(verified.filter { - $0.kind == 0 && $0.pubkey.lowercased() == event.pubkey.lowercased() - }) : nil - let channel = refreshChannel ? channelID.flatMap { channelID in - Self.newest(verified.filter { - $0.kind == 39_000 - && $0.pubkey.lowercased() == relayMetadataPubkey - && Self.tagValue("d", in: $0) == channelID - }) - } : nil - let membership = refreshChannel ? channelID.flatMap { channelID in - Self.newest(verified.filter { - $0.kind == 39_002 - && $0.pubkey.lowercased() == relayMetadataPubkey - && Self.tagValue("d", in: $0) == channelID - }) - } : nil + let profile = + refreshProfile + ? Self.newest( + verified.filter { + $0.kind == 0 && $0.pubkey.lowercased() == event.pubkey.lowercased() + }) : nil + let channel = + refreshChannel + ? channelID.flatMap { channelID in + Self.newest( + verified.filter { + $0.kind == 39_000 + && $0.pubkey.lowercased() == relayMetadataPubkey + && Self.tagValue("d", in: $0) == channelID + }) + } : nil + let membership = + refreshChannel + ? channelID.flatMap { channelID in + Self.newest( + verified.filter { + $0.kind == 39_002 + && $0.pubkey.lowercased() == relayMetadataPubkey + && Self.tagValue("d", in: $0) == channelID + }) + } : nil completion(profile, channel, membership) }.resume() } @@ -390,12 +416,14 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { ) -> (BuzzPushResolution, VerifiedNostrEvent)? { let event = newestMessage(events: events, community: community) guard let event else { return nil } - guard let resolution = makeResolution( - event: event, - community: community, - profile: nil, - channel: nil - ) else { return nil } + guard + let resolution = makeResolution( + event: event, + community: community, + profile: nil, + channel: nil + ) + else { return nil } return (resolution, event) } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift index df0a7360d9..56eb7703a1 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift @@ -164,6 +164,7 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { public static let maximumAvatarBytes = 64 * 1024 public static let maximumTotalAvatarBytes = 4 * 1024 * 1024 public static let maximumSnapshotBytes = 8 * 1024 * 1024 + static let maximumProfileMetadataBytes = 256 * 1024 private let fileURL: URL private let now: () -> Date @@ -203,15 +204,18 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { && $0.pubkey == pubkey } let existing = index.map { snapshot.profiles[$0] } - guard Self.shouldReplace( - existingCreatedAt: existing?.eventCreatedAt, - existingID: existing?.eventID, - candidateCreatedAt: event.createdAt, - candidateID: event.id - ) else { continue } + guard + Self.shouldReplace( + existingCreatedAt: existing?.eventCreatedAt, + existingID: existing?.eventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { continue } let suppliedAvatar = Self.normalizedAvatarPNG(update.avatarPNG) - let preservedAvatar = existing?.pictureHash == metadata.pictureHash + let preservedAvatar = + existing?.pictureHash == metadata.pictureHash ? existing?.avatarPNG : nil let entry = BuzzPushCachedProfile( communityID: communityID, @@ -234,15 +238,16 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { Self.enforceBounds(&snapshot) try writeLocked(snapshot) - return Set(snapshot.profiles.compactMap { profile in - guard profile.communityID == communityID, - profile.relayOrigin == canonicalRelayOrigin, - acceptedEventIDs.contains(profile.eventID), - profile.pictureHash != nil, - profile.avatarPNG == nil - else { return nil } - return profile.eventID - }) + return Set( + snapshot.profiles.compactMap { profile in + guard profile.communityID == communityID, + profile.relayOrigin == canonicalRelayOrigin, + acceptedEventIDs.contains(profile.eventID), + profile.pictureHash != nil, + profile.avatarPNG == nil + else { return nil } + return profile.eventID + }) } /// Saves bounded relay-authorized kind-39000 metadata and kind-39002 membership snapshots. @@ -279,12 +284,15 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { } let existing = index.map { snapshot.channels[$0] } let hasCurrentAuthority = existing?.relayMetadataPubkey == normalizedRelayPubkey - guard !hasCurrentAuthority || Self.shouldReplace( - existingCreatedAt: existing?.eventCreatedAt, - existingID: existing?.eventID, - candidateCreatedAt: event.createdAt, - candidateID: event.id - ) else { continue } + guard + !hasCurrentAuthority + || Self.shouldReplace( + existingCreatedAt: existing?.eventCreatedAt, + existingID: existing?.eventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { continue } let entry = BuzzPushCachedChannel( communityID: communityID, @@ -328,12 +336,14 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { }) else { continue } let existing = snapshot.channels[index] - guard Self.shouldReplace( - existingCreatedAt: existing.membershipEventCreatedAt, - existingID: existing.membershipEventID, - candidateCreatedAt: event.createdAt, - candidateID: event.id - ) else { continue } + guard + Self.shouldReplace( + existingCreatedAt: existing.membershipEventCreatedAt, + existingID: existing.membershipEventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { continue } snapshot.channels[index] = BuzzPushCachedChannel( communityID: existing.communityID, @@ -379,11 +389,12 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { var snapshot = loadLocked() var changed = false - for index in snapshot.profiles.indices where + for index in snapshot.profiles.indices + where snapshot.profiles[index].communityID == communityID - && snapshot.profiles[index].relayOrigin == canonicalRelayOrigin - && snapshot.profiles[index].pictureHash == pictureHash - && snapshot.profiles[index].avatarPNG != normalizedPNG + && snapshot.profiles[index].relayOrigin == canonicalRelayOrigin + && snapshot.profiles[index].pictureHash == pictureHash + && snapshot.profiles[index].avatarPNG != normalizedPNG { let profile = snapshot.profiles[index] snapshot.profiles[index] = BuzzPushCachedProfile( @@ -478,11 +489,12 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { static func profileMetadata( _ event: VerifiedNostrEvent ) -> (displayName: String?, pictureHash: String?) { - guard event.content.utf8.count <= 32 * 1024, + guard event.content.utf8.count <= maximumProfileMetadataBytes, let data = event.content.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return (nil, nil) } - let displayName = normalizedDisplayName(object["display_name"] as? String) + let displayName = + normalizedDisplayName(object["display_name"] as? String) ?? normalizedDisplayName(object["name"] as? String) let pictureHash = normalizedAvatarURL(object["picture"] as? String).map { VerifiedNostrEvent.hex(SHA256.hash(data: Data($0.utf8))) @@ -525,13 +537,16 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { exceededMemberBound = pubkeys.count > maximumMembersPerChannel } } - let digests = exceededMemberBound ? nil : pubkeys.map { - BuzzPushPresentationIdentity.channelMember( - communityID: communityID, - channelID: channelID, - pubkey: $0 - ) - }.sorted() + let digests = + exceededMemberBound + ? nil + : pubkeys.map { + BuzzPushPresentationIdentity.channelMember( + communityID: communityID, + channelID: channelID, + pubkey: $0 + ) + }.sorted() return ( exceededMemberBound ? maximumMembersPerChannel + 1 : pubkeys.count, digests @@ -541,7 +556,20 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { static func normalizedAvatarURL(_ value: String?) -> String? { guard let value else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, trimmed.utf8.count <= 2_048, + guard !trimmed.isEmpty else { return nil } + if trimmed.hasPrefix("data:image/") { + guard trimmed.utf8.count <= maximumProfileMetadataBytes, + let separator = trimmed.firstIndex(of: ","), + separator < trimmed.index(before: trimmed.endIndex) + else { return nil } + let metadata = trimmed[.. cacheBuzzPushAvatarFromLoadedBytes( communityID.isEmpty || sourceBytes.isEmpty || sourceBytes.length > _maximumAvatarSourceBytes || - !_isRemoteImageURL(sourceURL)) { + !isCacheablePushAvatarSource(sourceURL)) { return; } final previous = _avatarEncodeTail; @@ -209,7 +209,19 @@ Future _invokeBestEffort( } } -bool _isRemoteImageURL(String value) { +@visibleForTesting +bool isCacheablePushAvatarSource(String value) { + final trimmed = value.trim(); + if (trimmed.startsWith('data:image/')) { + try { + final data = UriData.parse(trimmed); + return data.mimeType.startsWith('image/') && + data.mimeType != 'image/svg+xml' && + data.contentAsBytes().isNotEmpty; + } on FormatException { + return false; + } + } final uri = Uri.tryParse(value.trim()); return uri != null && (uri.scheme == 'http' || uri.scheme == 'https') && diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 5165c2a383..b869bf8fbc 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -74,6 +74,7 @@ class AvatarImageContent extends ConsumerStatefulWidget { class _AvatarImageContentState extends ConsumerState { late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl); + String? _scheduledPushAvatar; @override void didUpdateWidget(AvatarImageContent oldWidget) { @@ -110,10 +111,11 @@ class _AvatarImageContentState extends ConsumerState { placeholderBuilder: (_) => centeredFallback, errorBuilder: (_, _, _) => centeredFallback, ), - _RasterDataAvatarSource(:final bytes) => Image.memory( - bytes, - fit: widget.fit, - errorBuilder: (_, _, _) => centeredFallback, + _RasterDataAvatarSource(:final bytes) => _rasterImage( + communityID: communityID, + sourceURL: widget.imageUrl, + bytes: bytes, + fallback: centeredFallback, ), _NetworkAvatarSource(:final url) => MediaImage( url: url, @@ -128,6 +130,31 @@ class _AvatarImageContentState extends ConsumerState { null => centeredFallback, }; } + + Widget _rasterImage({ + required String? communityID, + required String? sourceURL, + required Uint8List bytes, + required Widget fallback, + }) { + if (communityID != null && sourceURL != null) { + final identity = '$communityID\u0000$sourceURL'; + if (_scheduledPushAvatar != identity) { + _scheduledPushAvatar = identity; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _scheduledPushAvatar != identity) return; + unawaited( + cacheBuzzPushAvatarFromLoadedBytes(communityID, sourceURL, bytes), + ); + }); + } + } + return Image.memory( + bytes, + fit: widget.fit, + errorBuilder: (_, _, _) => fallback, + ); + } } sealed class _AvatarSource { diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index 4631cf58da..821de63a48 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; +import 'package:buzz/shared/push/push_presentation_cache.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -23,6 +24,15 @@ void main() { ), ); + test('accepts bounded raster data avatars for the push cache', () { + expect(isCacheablePushAvatarSource('data:image/png;base64,AA=='), isTrue); + expect( + isCacheablePushAvatarSource('data:image/svg+xml;base64,AA=='), + isFalse, + ); + expect(isCacheablePushAvatarSource('data:image/png;base64,%%%'), isFalse); + }); + testWidgets('renders raccoon percent-encoded SVG data avatar', ( tester, ) async {