mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
A notification can arrive from someone whose profile the main app has never loaded. The iOS notification extension now fetches and caches sender profiles, channel details, and avatars itself. The main app supplies account configuration and Keychain credentials only. After resolving the message, the extension allows 1.5 seconds for sender and channel details. It uses available stale data when a refresh is slow or fails, delivers once, and lets late results update its private cache. Profiles, channel details, and avatar refresh checks share a one-hour freshness threshold. A verified avatar removal clears the previous image. Account removal prevents further use of that sign-in's cached data. Profile, channel metadata, and membership use separate bounded downloads, so an oversized roster cannot discard the smaller records. Sender enrichment can finish while channel requests are pending. A successful partial channel refresh atomically removes its old counterpart, preventing a mixed group identity or recipient count. Group-specific notification presentation still requires a verified recipient count. The [specification](https://github.com/block/buzz/blob/native-notification-enrichment/docs/native-notification-enrichment.md) describes the display, caching, and account-removal behavior. General persistent image caching in Flutter remains separate. ### Validation [Hosted CI](https://github.com/block/buzz/actions/runs/38780108487) passed on the final commit, including all Flutter shards, Android, the full iOS release and simulator builds, and native controls/push-navigation tests. The [current-commit security review](https://github.com/block/buzz/pull/8369#issuecomment-6197508385) found no issues. The native package passed 159 tests, including new coverage for cache freshness, avatar replacement and removal, sign-in isolation, concurrent storage, late results, single delivery, bounded downloads, and SVG validation. A regression test exposed identical HTTP authentication event IDs for requests in the same second; each request now includes a unique nonce, matching the Dart and CLI clients. Full local CI passed before that small authentication fix. The complete native suite and signed device build passed again on the latest commit, including response-size limits, overlapping avatar downloads, canonical relay-origin matching, independent profile/channel refreshes, account-key changes before and during requests, and a single initial snapshot read across 64 accounts, signed IPv6 avatar authorities, and partial channel refreshes. Focused Flutter regression tests passed 198 tests with three configuration-specific skips; seven avatar widget tests also passed. Flutter analysis is clean, and the notification extension builds for the simulator. On an iPhone SE, the notification displayed the sender name, avatar, channel, and message before the updated main app was opened. Repeat notifications used a byte-identical private cache. Three notifications delivered together displayed the avatar after the authentication fix. The next notification also displayed correctly after terminating the extension process. The existing generic-push design can select the same newest unread message for several pushes; this change does not alter message selection.  Authenticated HTTP avatar fetching and persistent HTTP cache reuse also passed on the SE. The first notification downloaded a relay thumbnail. After terminating the extension and expiring its separate avatar entry, the next notification displayed the avatar and refreshed that entry without another media request in the relay logs. The main app remained unopened throughout. The original app build and test profile were restored afterward. Automated tests cover slow and failed requests, stale images, removal, and account revocation; those cases have not all been exercised on the phone. ### Before merge - [x] Verify authenticated HTTP avatar fetching and cache reuse on iOS against a working relay media service. <!-- buzz-review-completed --> --------- Signed-off-by: Tom Brow <tomb@block.xyz>
798 lines
28 KiB
Swift
798 lines
28 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
|
|
/// A verified sender profile retained for notification presentation.
|
|
public struct BuzzPushCachedProfile: Codable, Equatable, Sendable {
|
|
public let communityID: String
|
|
public let relayOrigin: String
|
|
public let pubkey: String
|
|
public let displayName: String?
|
|
public let pictureHash: String?
|
|
public let avatarPNG: Data?
|
|
public let eventID: String
|
|
public let eventCreatedAt: Int
|
|
public let cachedAt: Int
|
|
|
|
public init(
|
|
communityID: String,
|
|
relayOrigin: String,
|
|
pubkey: String,
|
|
displayName: String?,
|
|
pictureHash: String?,
|
|
avatarPNG: Data?,
|
|
eventID: String,
|
|
eventCreatedAt: Int,
|
|
cachedAt: Int
|
|
) {
|
|
self.communityID = communityID
|
|
self.relayOrigin = relayOrigin
|
|
self.pubkey = pubkey
|
|
self.displayName = displayName
|
|
self.pictureHash = pictureHash
|
|
self.avatarPNG = avatarPNG
|
|
self.eventID = eventID
|
|
self.eventCreatedAt = eventCreatedAt
|
|
self.cachedAt = cachedAt
|
|
}
|
|
}
|
|
|
|
/// Verified channel metadata retained for notification presentation.
|
|
public struct BuzzPushCachedChannel: Codable, Equatable, Sendable {
|
|
public let communityID: String
|
|
public let relayOrigin: String
|
|
public let channelID: String
|
|
public let relayMetadataPubkey: String
|
|
public let displayName: String?
|
|
/// Relay-verified Buzz channel type, when recognized.
|
|
public let channelType: String?
|
|
/// Exact unique-member count when bounded, or a value above the bound when oversized.
|
|
public let memberCount: Int?
|
|
/// Complete community-and-channel-scoped member digests, when within bounds.
|
|
public let memberDigests: [String]?
|
|
/// Event ID that established the cached membership snapshot.
|
|
public let membershipEventID: String?
|
|
/// Creation time of the cached membership replacement event.
|
|
public let membershipEventCreatedAt: Int?
|
|
/// Device time when the membership snapshot was cached.
|
|
public let membershipCachedAt: Int?
|
|
public let eventID: String
|
|
public let eventCreatedAt: Int
|
|
public let cachedAt: Int
|
|
|
|
public init(
|
|
communityID: String,
|
|
relayOrigin: String,
|
|
channelID: String,
|
|
relayMetadataPubkey: String,
|
|
displayName: String?,
|
|
channelType: String? = nil,
|
|
memberCount: Int? = nil,
|
|
memberDigests: [String]? = nil,
|
|
membershipEventID: String? = nil,
|
|
membershipEventCreatedAt: Int? = nil,
|
|
membershipCachedAt: Int? = nil,
|
|
eventID: String,
|
|
eventCreatedAt: Int,
|
|
cachedAt: Int
|
|
) {
|
|
self.communityID = communityID
|
|
self.relayOrigin = relayOrigin
|
|
self.channelID = channelID
|
|
self.relayMetadataPubkey = relayMetadataPubkey
|
|
self.displayName = displayName
|
|
self.channelType = channelType
|
|
self.memberCount = memberCount
|
|
self.memberDigests = memberDigests
|
|
self.membershipEventID = membershipEventID
|
|
self.membershipEventCreatedAt = membershipEventCreatedAt
|
|
self.membershipCachedAt = membershipCachedAt
|
|
self.eventID = eventID
|
|
self.eventCreatedAt = eventCreatedAt
|
|
self.cachedAt = cachedAt
|
|
}
|
|
}
|
|
|
|
/// Atomic App Group snapshot shared by the app and its notification extension.
|
|
public struct BuzzPushPresentationCacheSnapshot: Codable, Equatable, Sendable {
|
|
public static let currentVersion = 1
|
|
|
|
public let version: Int
|
|
public var communities: [PushLeaseCommunity]
|
|
public var profiles: [BuzzPushCachedProfile]
|
|
public var channels: [BuzzPushCachedChannel]
|
|
|
|
public init(
|
|
version: Int = currentVersion,
|
|
communities: [PushLeaseCommunity] = [],
|
|
profiles: [BuzzPushCachedProfile] = [],
|
|
channels: [BuzzPushCachedChannel] = []
|
|
) {
|
|
self.version = version
|
|
self.communities = communities
|
|
self.profiles = profiles
|
|
self.channels = channels
|
|
}
|
|
|
|
public static func decode(_ data: Data?) -> Self {
|
|
guard let data,
|
|
let snapshot = try? JSONDecoder().decode(Self.self, from: data),
|
|
snapshot.version == currentVersion
|
|
else { return Self() }
|
|
return snapshot
|
|
}
|
|
|
|
public func profile(
|
|
communityID: String,
|
|
relayOrigin: String,
|
|
pubkey: String
|
|
) -> BuzzPushCachedProfile? {
|
|
let normalizedPubkey = pubkey.lowercased()
|
|
return profiles.first {
|
|
$0.communityID == communityID && $0.relayOrigin == relayOrigin
|
|
&& $0.pubkey == normalizedPubkey
|
|
}
|
|
}
|
|
|
|
public func channel(
|
|
communityID: String,
|
|
relayOrigin: String,
|
|
channelID: String
|
|
) -> BuzzPushCachedChannel? {
|
|
channels.first {
|
|
$0.communityID == communityID && $0.relayOrigin == relayOrigin
|
|
&& $0.channelID == channelID
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One app-provided profile update, optionally carrying a sanitized local thumbnail.
|
|
public struct BuzzPushProfileCacheUpdate: Sendable {
|
|
public let event: VerifiedNostrEvent
|
|
public let avatarPNG: Data?
|
|
|
|
public init(event: VerifiedNostrEvent, avatarPNG: Data? = nil) {
|
|
self.event = event
|
|
self.avatarPNG = avatarPNG
|
|
}
|
|
}
|
|
|
|
/// Maintains the bounded presentation snapshot. The app is the sole writer.
|
|
public final class BuzzPushPresentationCacheStore: @unchecked Sendable {
|
|
public static let fileName = "push-snapshot.json"
|
|
public static let freshnessLifetime: TimeInterval = 24 * 60 * 60
|
|
public static let maximumProfiles = 256
|
|
public static let maximumChannels = 512
|
|
public static let maximumCommunities = 64
|
|
public static let maximumMembersPerChannel = 512
|
|
public static let maximumTotalMemberDigests = 8_192
|
|
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
|
|
private let lock = NSLock()
|
|
|
|
public init(containerURL: URL, now: @escaping () -> Date = Date.init) {
|
|
fileURL = containerURL.appendingPathComponent(Self.fileName)
|
|
self.now = now
|
|
}
|
|
|
|
/// Replaces the app's flattened, relay-accepted community query policy.
|
|
public func replaceCommunities(_ communities: [PushLeaseCommunity]) throws {
|
|
guard communities.count <= Self.maximumCommunities else { return }
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
var snapshot = loadLocked()
|
|
snapshot.communities = communities.map { community in
|
|
var community = community
|
|
let previous = snapshot.communities.first {
|
|
$0.id == community.id && $0.pubkey == community.pubkey
|
|
&& Self.canonicalRelayOrigin($0.relayUrl) == Self.canonicalRelayOrigin(community.relayUrl)
|
|
}
|
|
community.notificationSessionID = previous?.notificationSessionID ?? UUID().uuidString
|
|
return community
|
|
}
|
|
// Account configuration is the only app-owned notification data. Discard
|
|
// presentation exports left by older app versions, including retained accounts.
|
|
snapshot.profiles.removeAll()
|
|
snapshot.channels.removeAll()
|
|
try writeLocked(snapshot)
|
|
}
|
|
|
|
/// Saves verified kind-0 events and returns the event IDs still needing thumbnails.
|
|
@discardableResult
|
|
public func updateProfiles(
|
|
communityID: String,
|
|
relayOrigin: String,
|
|
updates: [BuzzPushProfileCacheUpdate]
|
|
) throws -> Set<String> {
|
|
guard Self.isBoundedOpaqueID(communityID),
|
|
let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin),
|
|
updates.count <= Self.maximumProfiles
|
|
else { return [] }
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
|
|
var snapshot = loadLocked()
|
|
let cachedAt = Int(now().timeIntervalSince1970)
|
|
var acceptedEventIDs = Set<String>()
|
|
for update in updates {
|
|
let event = update.event
|
|
guard event.kind == 0, event.hasValidIDAndSignature() else { continue }
|
|
let pubkey = event.pubkey.lowercased()
|
|
guard Self.isHexPubkey(pubkey) else { continue }
|
|
|
|
let metadata = Self.profileMetadata(event)
|
|
let index = snapshot.profiles.firstIndex {
|
|
$0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin
|
|
&& $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 }
|
|
|
|
let suppliedAvatar = Self.normalizedAvatarPNG(update.avatarPNG)
|
|
let preservedAvatar =
|
|
existing?.pictureHash == metadata.pictureHash
|
|
? existing?.avatarPNG : nil
|
|
let entry = BuzzPushCachedProfile(
|
|
communityID: communityID,
|
|
relayOrigin: canonicalRelayOrigin,
|
|
pubkey: pubkey,
|
|
displayName: metadata.displayName,
|
|
pictureHash: metadata.pictureHash,
|
|
avatarPNG: metadata.pictureHash == nil ? nil : suppliedAvatar ?? preservedAvatar,
|
|
eventID: event.id,
|
|
eventCreatedAt: event.createdAt,
|
|
cachedAt: cachedAt
|
|
)
|
|
if let index {
|
|
snapshot.profiles[index] = entry
|
|
} else {
|
|
snapshot.profiles.append(entry)
|
|
}
|
|
acceptedEventIDs.insert(event.id)
|
|
}
|
|
|
|
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
|
|
})
|
|
}
|
|
|
|
/// Saves bounded relay-authorized kind-39000 metadata and kind-39002 membership snapshots.
|
|
public func updateChannels(
|
|
communityID: String,
|
|
relayOrigin: String,
|
|
relayMetadataPubkey: String,
|
|
metadataEvents: [VerifiedNostrEvent],
|
|
membershipEvents: [VerifiedNostrEvent]
|
|
) throws {
|
|
let normalizedRelayPubkey = relayMetadataPubkey.lowercased()
|
|
guard Self.isBoundedOpaqueID(communityID),
|
|
let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin),
|
|
Self.isHexPubkey(normalizedRelayPubkey),
|
|
metadataEvents.count <= Self.maximumChannels,
|
|
membershipEvents.count <= Self.maximumChannels
|
|
else {
|
|
return
|
|
}
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
|
|
var snapshot = loadLocked()
|
|
let cachedAt = Int(now().timeIntervalSince1970)
|
|
for event in metadataEvents {
|
|
guard event.kind == 39_000, event.hasValidIDAndSignature(),
|
|
event.pubkey.lowercased() == normalizedRelayPubkey,
|
|
let channelID = Self.tagValue("d", in: event),
|
|
Self.isBoundedOpaqueID(channelID)
|
|
else { continue }
|
|
let index = snapshot.channels.firstIndex {
|
|
$0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin
|
|
&& $0.channelID == channelID
|
|
}
|
|
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 }
|
|
|
|
let entry = BuzzPushCachedChannel(
|
|
communityID: communityID,
|
|
relayOrigin: canonicalRelayOrigin,
|
|
channelID: channelID,
|
|
relayMetadataPubkey: normalizedRelayPubkey,
|
|
displayName: Self.normalizedDisplayName(Self.tagValue("name", in: event)),
|
|
channelType: Self.normalizedChannelType(Self.tagValue("t", in: event)),
|
|
memberCount: hasCurrentAuthority ? existing?.memberCount : nil,
|
|
memberDigests: hasCurrentAuthority ? existing?.memberDigests : nil,
|
|
membershipEventID: hasCurrentAuthority ? existing?.membershipEventID : nil,
|
|
membershipEventCreatedAt: hasCurrentAuthority
|
|
? existing?.membershipEventCreatedAt : nil,
|
|
membershipCachedAt: hasCurrentAuthority ? existing?.membershipCachedAt : nil,
|
|
eventID: event.id,
|
|
eventCreatedAt: event.createdAt,
|
|
cachedAt: cachedAt
|
|
)
|
|
if let index {
|
|
snapshot.channels[index] = entry
|
|
} else {
|
|
snapshot.channels.append(entry)
|
|
}
|
|
}
|
|
Self.enforceChannelCountBound(&snapshot)
|
|
|
|
for event in membershipEvents {
|
|
guard event.kind == 39_002, event.hasValidIDAndSignature(),
|
|
event.pubkey.lowercased() == normalizedRelayPubkey,
|
|
let channelID = Self.tagValue("d", in: event),
|
|
Self.isBoundedOpaqueID(channelID),
|
|
let membership = Self.normalizedChannelMembership(
|
|
event,
|
|
communityID: communityID,
|
|
channelID: channelID
|
|
),
|
|
let index = snapshot.channels.firstIndex(where: {
|
|
$0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin
|
|
&& $0.channelID == channelID
|
|
&& $0.relayMetadataPubkey == normalizedRelayPubkey
|
|
})
|
|
else { continue }
|
|
let existing = snapshot.channels[index]
|
|
guard
|
|
Self.shouldReplace(
|
|
existingCreatedAt: existing.membershipEventCreatedAt,
|
|
existingID: existing.membershipEventID,
|
|
candidateCreatedAt: event.createdAt,
|
|
candidateID: event.id
|
|
)
|
|
else { continue }
|
|
|
|
snapshot.channels[index] = BuzzPushCachedChannel(
|
|
communityID: existing.communityID,
|
|
relayOrigin: existing.relayOrigin,
|
|
channelID: existing.channelID,
|
|
relayMetadataPubkey: existing.relayMetadataPubkey,
|
|
displayName: existing.displayName,
|
|
channelType: existing.channelType,
|
|
memberCount: membership.count,
|
|
memberDigests: membership.digests,
|
|
membershipEventID: event.id,
|
|
membershipEventCreatedAt: event.createdAt,
|
|
membershipCachedAt: cachedAt,
|
|
eventID: existing.eventID,
|
|
eventCreatedAt: existing.eventCreatedAt,
|
|
cachedAt: existing.cachedAt
|
|
)
|
|
Self.enforceMemberDigestBound(&snapshot)
|
|
}
|
|
|
|
Self.enforceBounds(&snapshot)
|
|
try writeLocked(snapshot)
|
|
}
|
|
|
|
/// Attaches an app-rendered thumbnail to every verified profile with this source digest.
|
|
@discardableResult
|
|
public func updateAvatar(
|
|
communityID: String,
|
|
relayOrigin: String,
|
|
sourceURL: String,
|
|
avatarPNG: Data
|
|
) throws -> Bool {
|
|
guard Self.isBoundedOpaqueID(communityID),
|
|
let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin),
|
|
let normalizedURL = Self.normalizedAvatarURL(sourceURL),
|
|
let normalizedPNG = Self.normalizedAvatarPNG(avatarPNG)
|
|
else { return false }
|
|
let pictureHash = VerifiedNostrEvent.hex(
|
|
SHA256.hash(data: Data(normalizedURL.utf8))
|
|
)
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
|
|
var snapshot = loadLocked()
|
|
var changed = false
|
|
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
|
|
{
|
|
let profile = snapshot.profiles[index]
|
|
snapshot.profiles[index] = BuzzPushCachedProfile(
|
|
communityID: profile.communityID,
|
|
relayOrigin: profile.relayOrigin,
|
|
pubkey: profile.pubkey,
|
|
displayName: profile.displayName,
|
|
pictureHash: profile.pictureHash,
|
|
avatarPNG: normalizedPNG,
|
|
eventID: profile.eventID,
|
|
eventCreatedAt: profile.eventCreatedAt,
|
|
cachedAt: profile.cachedAt
|
|
)
|
|
changed = true
|
|
}
|
|
guard changed else { return false }
|
|
Self.enforceBounds(&snapshot)
|
|
try writeLocked(snapshot)
|
|
return true
|
|
}
|
|
|
|
private func loadLocked() -> BuzzPushPresentationCacheSnapshot {
|
|
guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
|
|
let fileSize = values.fileSize,
|
|
fileSize <= Self.maximumSnapshotBytes
|
|
else { return BuzzPushPresentationCacheSnapshot() }
|
|
return BuzzPushPresentationCacheSnapshot.decode(try? Data(contentsOf: fileURL))
|
|
}
|
|
|
|
private func writeLocked(_ snapshot: BuzzPushPresentationCacheSnapshot) throws {
|
|
let data = try Self.encodedBoundedSnapshot(snapshot)
|
|
try data.write(to: fileURL, options: [.atomic])
|
|
#if os(iOS)
|
|
try FileManager.default.setAttributes(
|
|
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
|
ofItemAtPath: fileURL.path
|
|
)
|
|
#endif
|
|
}
|
|
|
|
static func encodedBoundedSnapshot(
|
|
_ snapshot: BuzzPushPresentationCacheSnapshot
|
|
) throws -> Data {
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.sortedKeys]
|
|
var bounded = snapshot
|
|
Self.enforceBounds(&bounded)
|
|
var data = try encoder.encode(bounded)
|
|
|
|
if data.count > Self.maximumSnapshotBytes {
|
|
var estimatedExcess = data.count - Self.maximumSnapshotBytes + 1_024
|
|
for index in bounded.profiles.indices.reversed() {
|
|
guard let avatar = bounded.profiles[index].avatarPNG else { continue }
|
|
estimatedExcess -= min(estimatedExcess, 4 * ((avatar.count + 2) / 3))
|
|
bounded.profiles[index] = Self.removingAvatar(from: bounded.profiles[index])
|
|
if estimatedExcess == 0 { break }
|
|
}
|
|
data = try encoder.encode(bounded)
|
|
}
|
|
|
|
if data.count > Self.maximumSnapshotBytes {
|
|
for index in bounded.channels.indices.reversed() {
|
|
guard bounded.channels[index].memberDigests != nil else { continue }
|
|
bounded.channels[index] = Self.removingMemberDigests(from: bounded.channels[index])
|
|
data = try encoder.encode(bounded)
|
|
if data.count <= Self.maximumSnapshotBytes { break }
|
|
}
|
|
}
|
|
|
|
while data.count > Self.maximumSnapshotBytes,
|
|
!bounded.profiles.isEmpty || !bounded.channels.isEmpty
|
|
{
|
|
let entryCount = bounded.profiles.count + bounded.channels.count
|
|
let ratio = Double(Self.maximumSnapshotBytes) / Double(data.count)
|
|
let targetCount = max(0, min(entryCount - 1, Int(Double(entryCount) * ratio * 0.95)))
|
|
Self.removeOldestEntries(entryCount - targetCount, from: &bounded)
|
|
data = try encoder.encode(bounded)
|
|
}
|
|
return data
|
|
}
|
|
|
|
static func profileMetadata(
|
|
_ event: VerifiedNostrEvent
|
|
) -> (displayName: String?, pictureHash: String?) {
|
|
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)
|
|
?? normalizedDisplayName(object["name"] as? String)
|
|
let pictureHash = normalizedAvatarURL(object["picture"] as? String).map {
|
|
VerifiedNostrEvent.hex(SHA256.hash(data: Data($0.utf8)))
|
|
}
|
|
return (displayName, pictureHash)
|
|
}
|
|
|
|
static func normalizedDisplayName(_ value: String?) -> String? {
|
|
guard let value else { return nil }
|
|
let collapsed = value.precomposedStringWithCanonicalMapping
|
|
.components(separatedBy: .whitespacesAndNewlines)
|
|
.filter { !$0.isEmpty }
|
|
.joined(separator: " ")
|
|
guard !collapsed.isEmpty else { return nil }
|
|
var bounded = String(collapsed.prefix(128))
|
|
while bounded.utf8.count > 512, !bounded.isEmpty {
|
|
bounded.removeLast()
|
|
}
|
|
return bounded.isEmpty ? nil : bounded
|
|
}
|
|
|
|
static func normalizedChannelType(_ value: String?) -> String? {
|
|
guard let value, ["stream", "forum", "dm"].contains(value) else { return nil }
|
|
return value
|
|
}
|
|
|
|
static func normalizedChannelMembership(
|
|
_ event: VerifiedNostrEvent,
|
|
communityID: String,
|
|
channelID: String
|
|
) -> (count: Int, digests: [String]?)? {
|
|
var pubkeys = Set<String>()
|
|
var exceededMemberBound = false
|
|
for tag in event.tags where tag.first == "p" {
|
|
guard tag.count >= 2 else { return nil }
|
|
let pubkey = tag[1].lowercased()
|
|
guard isHexPubkey(pubkey) else { return nil }
|
|
if !exceededMemberBound {
|
|
pubkeys.insert(pubkey)
|
|
exceededMemberBound = pubkeys.count > maximumMembersPerChannel
|
|
}
|
|
}
|
|
let digests =
|
|
exceededMemberBound
|
|
? nil
|
|
: pubkeys.map {
|
|
BuzzPushPresentationIdentity.channelMember(
|
|
communityID: communityID,
|
|
channelID: channelID,
|
|
pubkey: $0
|
|
)
|
|
}.sorted()
|
|
return (
|
|
exceededMemberBound ? maximumMembersPerChannel + 1 : pubkeys.count,
|
|
digests
|
|
)
|
|
}
|
|
|
|
static func normalizedAvatarURL(_ value: String?) -> String? {
|
|
guard let value else { return nil }
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
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[..<separator].lowercased()
|
|
guard metadata.hasSuffix(";base64"),
|
|
["data:image/png;base64", "data:image/jpeg;base64", "data:image/webp;base64"]
|
|
.contains(String(metadata))
|
|
else { return nil }
|
|
return trimmed
|
|
}
|
|
guard trimmed.utf8.count <= 2_048,
|
|
let components = URLComponents(string: trimmed),
|
|
components.user == nil, components.password == nil,
|
|
components.host?.isEmpty == false,
|
|
["http", "https"].contains(components.scheme?.lowercased() ?? "")
|
|
else { return nil }
|
|
return components.url?.absoluteString
|
|
}
|
|
|
|
public static func canonicalRelayOrigin(_ value: String) -> String? {
|
|
guard value.utf8.count <= 2_048,
|
|
var components = URLComponents(string: value),
|
|
components.host?.isEmpty == false,
|
|
components.user == nil,
|
|
components.password == nil,
|
|
components.query == nil,
|
|
components.fragment == nil,
|
|
components.path.isEmpty || components.path == "/"
|
|
else { return nil }
|
|
switch components.scheme?.lowercased() {
|
|
case "wss": components.scheme = "https"
|
|
case "ws": components.scheme = "http"
|
|
case "https", "http": break
|
|
default: return nil
|
|
}
|
|
components.path = ""
|
|
guard let result = components.string, result.utf8.count <= 2_048 else { return nil }
|
|
return result
|
|
}
|
|
|
|
static func tagValue(_ name: String, in event: VerifiedNostrEvent) -> String? {
|
|
event.tags.first { $0.count >= 2 && $0[0] == name }?[1]
|
|
}
|
|
|
|
private static func isBoundedOpaqueID(_ value: String) -> Bool {
|
|
!value.isEmpty && value.utf8.count <= 1_024
|
|
}
|
|
|
|
private static func isHexPubkey(_ value: String) -> Bool {
|
|
value.count == 64 && VerifiedNostrEvent.hexBytes(value)?.count == 32
|
|
}
|
|
|
|
private static func normalizedAvatarPNG(_ data: Data?) -> Data? {
|
|
guard let data, !data.isEmpty, data.count <= maximumAvatarBytes,
|
|
data.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
|
else { return nil }
|
|
return data
|
|
}
|
|
|
|
static func shouldReplace(
|
|
existingCreatedAt: Int?,
|
|
existingID: String?,
|
|
candidateCreatedAt: Int,
|
|
candidateID: String
|
|
) -> Bool {
|
|
guard let existingCreatedAt, let existingID else { return true }
|
|
return candidateCreatedAt > existingCreatedAt
|
|
|| (candidateCreatedAt == existingCreatedAt && candidateID <= existingID)
|
|
}
|
|
|
|
static func enforceBounds(_ snapshot: inout BuzzPushPresentationCacheSnapshot) {
|
|
snapshot.communities = Array(snapshot.communities.prefix(maximumCommunities))
|
|
snapshot.profiles = Array(
|
|
snapshot.profiles.sorted(by: profileNewestFirst).prefix(maximumProfiles)
|
|
)
|
|
enforceChannelCountBound(&snapshot)
|
|
enforceMemberDigestBound(&snapshot)
|
|
|
|
var avatarBytes = snapshot.profiles.reduce(0) { $0 + ($1.avatarPNG?.count ?? 0) }
|
|
guard avatarBytes > maximumTotalAvatarBytes else { return }
|
|
for index in snapshot.profiles.indices.reversed() {
|
|
guard let avatar = snapshot.profiles[index].avatarPNG else { continue }
|
|
avatarBytes -= avatar.count
|
|
let profile = snapshot.profiles[index]
|
|
snapshot.profiles[index] = removingAvatar(from: profile)
|
|
if avatarBytes <= maximumTotalAvatarBytes { break }
|
|
}
|
|
}
|
|
|
|
private static func enforceChannelCountBound(
|
|
_ snapshot: inout BuzzPushPresentationCacheSnapshot
|
|
) {
|
|
snapshot.channels = Array(
|
|
snapshot.channels.sorted(by: channelNewestFirst).prefix(maximumChannels)
|
|
)
|
|
}
|
|
|
|
private static func enforceMemberDigestBound(
|
|
_ snapshot: inout BuzzPushPresentationCacheSnapshot
|
|
) {
|
|
snapshot.channels.sort(by: channelNewestFirst)
|
|
var memberDigestCount = snapshot.channels.reduce(0) {
|
|
$0 + ($1.memberDigests?.count ?? 0)
|
|
}
|
|
guard memberDigestCount > maximumTotalMemberDigests else { return }
|
|
for index in snapshot.channels.indices.reversed() {
|
|
guard let memberDigests = snapshot.channels[index].memberDigests else { continue }
|
|
memberDigestCount -= memberDigests.count
|
|
snapshot.channels[index] = removingMemberDigests(from: snapshot.channels[index])
|
|
if memberDigestCount <= maximumTotalMemberDigests { break }
|
|
}
|
|
}
|
|
|
|
private static func removingAvatar(
|
|
from profile: BuzzPushCachedProfile
|
|
) -> BuzzPushCachedProfile {
|
|
BuzzPushCachedProfile(
|
|
communityID: profile.communityID,
|
|
relayOrigin: profile.relayOrigin,
|
|
pubkey: profile.pubkey,
|
|
displayName: profile.displayName,
|
|
pictureHash: profile.pictureHash,
|
|
avatarPNG: nil,
|
|
eventID: profile.eventID,
|
|
eventCreatedAt: profile.eventCreatedAt,
|
|
cachedAt: profile.cachedAt
|
|
)
|
|
}
|
|
|
|
private static func removingMemberDigests(
|
|
from channel: BuzzPushCachedChannel
|
|
) -> BuzzPushCachedChannel {
|
|
BuzzPushCachedChannel(
|
|
communityID: channel.communityID,
|
|
relayOrigin: channel.relayOrigin,
|
|
channelID: channel.channelID,
|
|
relayMetadataPubkey: channel.relayMetadataPubkey,
|
|
displayName: channel.displayName,
|
|
channelType: channel.channelType,
|
|
memberCount: channel.memberCount,
|
|
memberDigests: nil,
|
|
membershipEventID: channel.membershipEventID,
|
|
membershipEventCreatedAt: channel.membershipEventCreatedAt,
|
|
membershipCachedAt: channel.membershipCachedAt,
|
|
eventID: channel.eventID,
|
|
eventCreatedAt: channel.eventCreatedAt,
|
|
cachedAt: channel.cachedAt
|
|
)
|
|
}
|
|
|
|
private static func removeOldestEntries(
|
|
_ count: Int,
|
|
from snapshot: inout BuzzPushPresentationCacheSnapshot
|
|
) {
|
|
for _ in 0..<count {
|
|
switch (snapshot.profiles.last, snapshot.channels.last) {
|
|
case (nil, nil): return
|
|
case (.some, nil): snapshot.profiles.removeLast()
|
|
case (nil, .some): snapshot.channels.removeLast()
|
|
case (let profile?, let channel?):
|
|
let channelCachedAt = channelLastCachedAt(channel)
|
|
if profile.cachedAt < channelCachedAt
|
|
|| (profile.cachedAt == channelCachedAt && profile.eventID <= channel.eventID)
|
|
{
|
|
snapshot.profiles.removeLast()
|
|
} else {
|
|
snapshot.channels.removeLast()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func profileNewestFirst(
|
|
_ lhs: BuzzPushCachedProfile,
|
|
_ rhs: BuzzPushCachedProfile
|
|
) -> Bool {
|
|
lhs.cachedAt == rhs.cachedAt ? lhs.eventID > rhs.eventID : lhs.cachedAt > rhs.cachedAt
|
|
}
|
|
|
|
private static func channelNewestFirst(
|
|
_ lhs: BuzzPushCachedChannel,
|
|
_ rhs: BuzzPushCachedChannel
|
|
) -> Bool {
|
|
let lhsCachedAt = channelLastCachedAt(lhs)
|
|
let rhsCachedAt = channelLastCachedAt(rhs)
|
|
return lhsCachedAt == rhsCachedAt ? lhs.eventID > rhs.eventID : lhsCachedAt > rhsCachedAt
|
|
}
|
|
|
|
private static func channelLastCachedAt(_ channel: BuzzPushCachedChannel) -> Int {
|
|
max(channel.cachedAt, channel.membershipCachedAt ?? 0)
|
|
}
|
|
}
|
|
|
|
/// Stable, privacy-preserving identifiers used only after the NSE resolves an event.
|
|
public enum BuzzPushPresentationIdentity {
|
|
public static func conversation(communityID: String, channelID: String) -> String {
|
|
scoped(namespace: "conversation", values: [communityID, channelID])
|
|
}
|
|
|
|
public static func sender(communityID: String, pubkey: String) -> String {
|
|
scoped(namespace: "sender", values: [communityID, pubkey.lowercased()])
|
|
}
|
|
|
|
/// Returns a stable, channel-scoped digest used for exact local membership checks.
|
|
public static func channelMember(
|
|
communityID: String,
|
|
channelID: String,
|
|
pubkey: String
|
|
) -> String {
|
|
scoped(
|
|
namespace: "channel-member",
|
|
values: [communityID, channelID, pubkey.lowercased()]
|
|
)
|
|
}
|
|
|
|
private static func scoped(namespace: String, values: [String]) -> String {
|
|
let encoded = (try? JSONEncoder().encode([namespace] + values)) ?? Data()
|
|
return "buzz.\(namespace).\(VerifiedNostrEvent.hex(SHA256.hash(data: encoded)))"
|
|
}
|
|
}
|