mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
fix(mobile): resolve inline push profile avatars
Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Codex <noreply@openai.com> Ai-assisted: true
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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[..<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,
|
||||
|
||||
@@ -142,7 +142,7 @@ extension BuzzPushNotificationResolverTests {
|
||||
if URLProtocolStub.requests.count == 1 {
|
||||
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
||||
}
|
||||
XCTAssertEqual(request.timeoutInterval, 1)
|
||||
XCTAssertEqual(request.timeoutInterval, 3)
|
||||
return Self.response(
|
||||
request,
|
||||
status: 200,
|
||||
|
||||
@@ -278,7 +278,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
if URLProtocolStub.requests.count == 1 {
|
||||
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
||||
}
|
||||
XCTAssertEqual(request.timeoutInterval, 1)
|
||||
XCTAssertEqual(request.timeoutInterval, 3)
|
||||
return Self.response(request, status: 503, data: Data())
|
||||
}
|
||||
|
||||
@@ -608,6 +608,59 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 2)
|
||||
}
|
||||
|
||||
func testBoundedInlineAvatarProfileRefreshStillResolvesDisplayName() throws {
|
||||
let message = try Self.signedEvent(
|
||||
privateKey: Self.profilePrivateKey,
|
||||
createdAt: Self.now,
|
||||
kind: 9,
|
||||
tags: [["h", Self.channelID]],
|
||||
content: "Inline avatar profile"
|
||||
)
|
||||
let picture = "data:image/png;base64," + String(repeating: "A", count: 170_000)
|
||||
let profileContent = try XCTUnwrap(
|
||||
String(
|
||||
data: JSONSerialization.data(withJSONObject: [
|
||||
"display_name": "Fizz",
|
||||
"picture": picture,
|
||||
]),
|
||||
encoding: .utf8
|
||||
)
|
||||
)
|
||||
let profile = try Self.signedEvent(
|
||||
privateKey: Self.profilePrivateKey,
|
||||
createdAt: Self.now,
|
||||
kind: 0,
|
||||
content: profileContent
|
||||
)
|
||||
let presentationData = try JSONEncoder().encode([profile])
|
||||
XCTAssertLessThan(
|
||||
presentationData.count,
|
||||
BuzzPushNotificationResolver.maximumPresentationResponseBytes
|
||||
)
|
||||
URLProtocolStub.handler = { request in
|
||||
Self.response(
|
||||
request,
|
||||
status: 200,
|
||||
data: URLProtocolStub.requests.count == 1
|
||||
? try JSONEncoder().encode([message]) : presentationData
|
||||
)
|
||||
}
|
||||
|
||||
let result = try XCTUnwrap(
|
||||
resolve(
|
||||
makeResolver(
|
||||
communitiesData: try snapshotData([community()]),
|
||||
now: Date(timeIntervalSince1970: TimeInterval(Self.now))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.title, "Fizz")
|
||||
XCTAssertNil(result.senderAvatarPNG)
|
||||
XCTAssertEqual(result.body, "Inline avatar profile")
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 2)
|
||||
}
|
||||
|
||||
func testResolveCanonicalizesWebSocketRelayOriginForQuery() throws {
|
||||
URLProtocolStub.handler = { request in
|
||||
XCTAssertEqual(request.url?.absoluteString, "https://relay.example/query")
|
||||
|
||||
@@ -23,7 +23,8 @@ struct BuzzPushPresentationCacheTests {
|
||||
privateKey: profileKey,
|
||||
createdAt: 1_700_000_000,
|
||||
kind: 0,
|
||||
content: #"{"display_name":" Alice Example ","name":"alice","picture":"https://images.example/alice.png"}"#
|
||||
content:
|
||||
#"{"display_name":" Alice Example ","name":"alice","picture":"https://images.example/alice.png"}"#
|
||||
)
|
||||
|
||||
let needsAvatar = try store.updateProfiles(
|
||||
@@ -83,6 +84,55 @@ struct BuzzPushPresentationCacheTests {
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Verified inline raster profile retains its name and accepts a local thumbnail")
|
||||
func verifiedInlineRasterProfileAndAvatar() throws {
|
||||
let directory = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = BuzzPushPresentationCacheStore(containerURL: directory)
|
||||
let picture = "data:image/png;base64," + String(repeating: "A", count: 170_000)
|
||||
let content = try #require(
|
||||
String(
|
||||
data: JSONSerialization.data(withJSONObject: [
|
||||
"display_name": "Fizz",
|
||||
"picture": picture,
|
||||
]),
|
||||
encoding: .utf8
|
||||
)
|
||||
)
|
||||
let event = try signedEvent(privateKey: profileKey, kind: 0, content: content)
|
||||
|
||||
let needsAvatar = try store.updateProfiles(
|
||||
communityID: "community-a",
|
||||
relayOrigin: "https://relay.example",
|
||||
updates: [BuzzPushProfileCacheUpdate(event: event)]
|
||||
)
|
||||
#expect(needsAvatar == Set([event.id]))
|
||||
#expect(
|
||||
try loadSnapshot(directory).profile(
|
||||
communityID: "community-a",
|
||||
relayOrigin: "https://relay.example",
|
||||
pubkey: event.pubkey
|
||||
)?.displayName == "Fizz"
|
||||
)
|
||||
|
||||
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||||
#expect(
|
||||
try store.updateAvatar(
|
||||
communityID: "community-a",
|
||||
relayOrigin: "https://relay.example",
|
||||
sourceURL: picture,
|
||||
avatarPNG: png
|
||||
)
|
||||
)
|
||||
#expect(
|
||||
try loadSnapshot(directory).profile(
|
||||
communityID: "community-a",
|
||||
relayOrigin: "https://relay.example",
|
||||
pubkey: event.pubkey
|
||||
)?.avatarPNG == png
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Verified profile falls back from blank display_name to name")
|
||||
func verifiedProfileNameFallback() throws {
|
||||
let directory = try temporaryDirectory()
|
||||
@@ -266,13 +316,14 @@ struct BuzzPushPresentationCacheTests {
|
||||
#expect(cached.memberCount == 2)
|
||||
#expect(cached.membershipEventID == newestMembership.id)
|
||||
#expect(
|
||||
cached.memberDigests == [firstMember, secondMember].map {
|
||||
BuzzPushPresentationIdentity.channelMember(
|
||||
communityID: "community-a",
|
||||
channelID: "opaque-channel",
|
||||
pubkey: $0
|
||||
)
|
||||
}.sorted()
|
||||
cached.memberDigests
|
||||
== [firstMember, secondMember].map {
|
||||
BuzzPushPresentationIdentity.channelMember(
|
||||
communityID: "community-a",
|
||||
channelID: "opaque-channel",
|
||||
pubkey: $0
|
||||
)
|
||||
}.sorted()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -413,7 +464,9 @@ struct BuzzPushPresentationCacheTests {
|
||||
|
||||
#expect(totalDigests == BuzzPushPresentationCacheStore.maximumTotalMemberDigests)
|
||||
#expect(decoded.channels.first { $0.channelID == "channel-0" }?.memberDigests == nil)
|
||||
#expect(decoded.channels.first { $0.channelID == "channel-\(channelCount - 1)" }?.memberDigests != nil)
|
||||
#expect(
|
||||
decoded.channels.first { $0.channelID == "channel-\(channelCount - 1)" }?.memberDigests != nil
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Oversized membership keeps provenance but omits an incomplete digest set")
|
||||
@@ -431,9 +484,10 @@ struct BuzzPushPresentationCacheTests {
|
||||
let membership = try signedEvent(
|
||||
privateKey: relayKey,
|
||||
kind: 39_002,
|
||||
tags: [["d", "large-channel"]] + (0..<memberCount).map {
|
||||
["p", String(format: "%064x", $0 + 1)]
|
||||
}
|
||||
tags: [["d", "large-channel"]]
|
||||
+ (0..<memberCount).map {
|
||||
["p", String(format: "%064x", $0 + 1)]
|
||||
}
|
||||
)
|
||||
|
||||
try store.updateChannels(
|
||||
@@ -461,7 +515,8 @@ struct BuzzPushPresentationCacheTests {
|
||||
@Test("Legacy cache decodes with channel membership unavailable")
|
||||
func legacyCacheCompatibility() throws {
|
||||
let legacy = try #require(
|
||||
#"{"version":1,"profiles":[],"channels":[{"communityID":"community-a","relayOrigin":"https://relay.example","channelID":"opaque","relayMetadataPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","displayName":"General","eventID":"event","eventCreatedAt":1,"cachedAt":2}]}"#.data(using: .utf8)
|
||||
#"{"version":1,"profiles":[],"channels":[{"communityID":"community-a","relayOrigin":"https://relay.example","channelID":"opaque","relayMetadataPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","displayName":"General","eventID":"event","eventCreatedAt":1,"cachedAt":2}]}"#
|
||||
.data(using: .utf8)
|
||||
)
|
||||
|
||||
let channel = try #require(BuzzPushPresentationCacheSnapshot.decode(legacy).channels.first)
|
||||
@@ -582,7 +637,8 @@ struct BuzzPushPresentationCacheTests {
|
||||
@Test("Encoded cache remains bounded with adversarial strings and maximum avatars")
|
||||
func encodedCacheByteBound() throws {
|
||||
let controlText = String(repeating: "\u{0001}", count: 1_024)
|
||||
let avatar = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||||
let avatar =
|
||||
Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||||
+ Data(
|
||||
repeating: 0,
|
||||
count: BuzzPushPresentationCacheStore.maximumAvatarBytes - 8
|
||||
|
||||
@@ -173,7 +173,7 @@ Future<void> cacheBuzzPushAvatarFromLoadedBytes(
|
||||
communityID.isEmpty ||
|
||||
sourceBytes.isEmpty ||
|
||||
sourceBytes.length > _maximumAvatarSourceBytes ||
|
||||
!_isRemoteImageURL(sourceURL)) {
|
||||
!isCacheablePushAvatarSource(sourceURL)) {
|
||||
return;
|
||||
}
|
||||
final previous = _avatarEncodeTail;
|
||||
@@ -209,7 +209,19 @@ Future<void> _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') &&
|
||||
|
||||
@@ -74,6 +74,7 @@ class AvatarImageContent extends ConsumerStatefulWidget {
|
||||
|
||||
class _AvatarImageContentState extends ConsumerState<AvatarImageContent> {
|
||||
late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl);
|
||||
String? _scheduledPushAvatar;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AvatarImageContent oldWidget) {
|
||||
@@ -110,10 +111,11 @@ class _AvatarImageContentState extends ConsumerState<AvatarImageContent> {
|
||||
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<AvatarImageContent> {
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user