Skip to content

fix(blobs): recover a group-chat avatar whose download URL expired - #1478

Merged
bmc08gt merged 1 commit into
code/cashfrom
fix/avatar-blob-url-recovery
Sep 17, 2026
Merged

bmc08gt merged 1 commit into
code/cashfrom
fix/avatar-blob-url-recovery

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

A sender's avatar in a group transcript stays on its BlurHash. A blob's download_url is a short-lived signed URL, so a persisted profile picture routinely carries a dead one by the time a row is read, and all three seams meant to re-mint it were broken.

The access context was signed out

getBlobs set the access context after setAuth(authenticate(owner)). authenticate signs buildPartial(), so the signature covered {blobIds} while the server verified {blobIds, context}, and every re-mint came back UNAUTHENTICATED. BlobAccessContext.profile(userId) is what authorizes re-minting a blob the caller doesn't own, and group chat is the only surface that passes it — owned blobs fall back to Owned and kept working, which is why this looked like a group-chat bug. Introduced by 36e7cef.

The request is now built through a helper that sets every field before authenticate runs.

A cached 403 was served forever

NetworkFetcher writes a response to the disk cache at line 110 and only throws on its status code at line 112, so one fetch through an expired URL leaves a 403 stored under the blob's key. ImmutableBlobCacheStrategy served any cached response without a request, so that 403 replayed on every later load and no re-mint could reach it.

A response NetworkFetcher would throw on is now neither served nor stored. Declining the write also leaves an entry an older build already poisoned intact rather than removed, which costs one doomed request per blob and then heals — Coil's CacheStrategy has no eviction hook.

The re-mint was cancelled before it landed

BlobId was a @JvmInline value class over ByteArray, so it compared by array identity. That propagates: MediaItemRendition and MediaItem are data classes, so every read of a persisted MediaItem decoded fresh arrays and produced a value unequal to the last one. remember(media, targetLongestSidePx) in rememberMediaUrl re-keyed on each Room emission, allocating a new MediaUrlState and cancelling the LaunchedEffect holding the re-mint. On device the GetBlobs RPC was sent and its result never applied.

BlobId is now a data class comparing bytes by content.

Why BlobId carries a serializer

Dropping the value class changes how BlobId encodes, and that encoding is a storage format: MediaItem is persisted as JSON in user_profiles.profile_picture_json and in chat message contents. Rows written by earlier builds hold the id inlined as \"blobId\":[1,2,…]; the class-shaped default writes \"blobId\":{\"bytes\":[1,2,…]} and fails to decode them, which drops the whole MediaItem and leaves the avatar with no image at all — worse than the bug being fixed. BlobIdSerializer pins the bare-bytes encoding, and BlobIdSerializationTest decodes a row in the old shape so this can't regress quietly.

Tests

  • GetBlobsRequestSigningTest — 3 of 6 fail against the old field ordering.
  • ImmutableBlobCacheStrategyTest — 2 of 5 fail against the old strategy.
  • BlobIdEqualityTest — 3 of 5 fail against the value class.
  • BlobIdSerializationTest — pins the persisted encoding.

A sender's avatar in a group transcript stays on its BlurHash. The blob's
download_url is a short-lived signed URL, so a persisted profile picture
routinely carries a dead one, and the three seams that are meant to re-mint it
were all broken:

- GetBlobs set the access context after signing, so the signature covered
  {blobIds} while the server verified {blobIds, context}. Every re-mint of a
  blob the caller doesn't own — which is every other member's avatar — came
  back UNAUTHENTICATED. Owned blobs were unaffected, so this only showed up in
  group chat, the one surface that passes BlobAccessContext.profile.
- NetworkFetcher writes a response to the disk cache before it throws on the
  status code, and ImmutableBlobCacheStrategy served any cached response
  without a request. One fetch through an expired URL pinned the blob's key to
  403 for good; no re-mint could reach it. A response NetworkFetcher would
  throw on is now neither served nor stored, which also un-poisons an entry an
  older build wrote.
- BlobId was a value class over ByteArray, so it compared by array identity.
  Each read of a persisted MediaItem decodes fresh arrays, making every
  emission a different MediaItem; remember(media) in rememberMediaUrl then
  dropped its state and cancelled the in-flight re-mint. On device this showed
  as GetBlobs being sent and its result never applied.

BlobId carries a serializer that keeps encoding it as bare bytes. MediaItems
persisted while it was a value class hold the id inlined, and the class-shaped
default silently failed to decode them — which drops the whole MediaItem and
leaves the avatar with no image at all.
@bmc08gt bmc08gt self-assigned this Sep 17, 2026
@github-actions github-actions Bot added type: fix Bug fix area: network gRPC, connectivity, API, exchange rates labels Sep 17, 2026
@bmc08gt
bmc08gt merged commit 12a4ffb into code/cash Sep 17, 2026
3 checks passed
@bmc08gt
bmc08gt deleted the fix/avatar-blob-url-recovery branch September 23, 2026 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: network gRPC, connectivity, API, exchange rates type: fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant