fix(blobs): re-mint expired download URLs before loading them - #1404
Merged
Conversation
Profile photos showed the grey placeholder in the chat header and on the user profile screen while the same photo still rendered in the chats list. The list had just been refreshed from GetDmChatFeed; the other two surfaces read a profile persisted earlier, whose CloudFront download URL — signed for about 15 minutes — had long since expired. Coil draws the error drawable on a failed load, so the placeholder was the 403. BlobMetadata now carries download_url.expires_at through the proto mapping and into the persisted JSON, so a stored copy can tell that its URL is dead. MediaUrlResolver checks that before handing a URL to Coil and calls GetBlobs to re-mint the id, memoising the result for the process so every surface showing the same avatar re-mints once. A load that fails anyway gets one further re-mint from the error callback before giving up. Re-minting a blob the caller does not own needs GetBlobsRequest.context to name what authorizes the read, so BlobAccessContext threads a scope (owned, a profile id, or a chat id) from each avatar call site down to the API. Several call sites had no user id to pass: the chat member -> UserProfile mapping dropped it in both mapping paths, leaving the nested profile's userId null even though the server sets it on the member. Avatars draw the BlurHash while a re-mint is in flight and when a load fails outright, rather than the grey person glyph. Coil requests are keyed on the durable blob id instead of the per-fetch URL, so a re-mint reuses bytes that are already cached; notification avatars use the same key and now go through the resolver too.
flipcash2-client-protocol 0.4.1 regenerates the validators with protovalidate-kt 0.1.2, which checks a oneof arm's required rule only when that arm is selected. AccessContext can pass client-side validation now, so the request no longer has to be validated before the context is attached to avoid it. The test pins that: a profile scope and a chat scope each validate, and an unset scope does not. All three fail against 0.4.0.
Gating the actions on the edit and delete windows (#1403) made the ToggleMessageSelection reducer re-narrow the bubble it selects, so the selection is a copy of the bubble the event carried. Three assertSame checks were pinning the old identity and have failed on code/cash since that merge, taking down the messenger module's test task on every PR. The narrowing is a no-op under MessagePolicy.Default, whose windows are both null, so the copy is value-equal and assertEquals asserts what the tests meant.
bmc08gt
force-pushed
the
fix/blob-url-expiry
branch
from
September 4, 2026 02:25
953b526 to
fd48d4d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Profile photos showed the grey placeholder in the chat header and on the user profile screen while the same photo still rendered in the chats list. The chats list had just been refreshed from
GetDmChatFeed; the other two surfaces read a profile persisted earlier, whose CloudFront download URL — signed for about 15 minutes — had long since expired. Coil draws the error drawable on a failed load, not the placeholder, so what looked like "no photo" was a 403.What changed
Expiry is modelled and persisted.
BlobMetadatacarriesdownload_url.expires_atasexpiresAtMillisthrough the proto mapping and into the persisted JSON. Every other field on it is intrinsic to the immutable bytes; this one is not, and dropping it left a stored copy with no way to know its URL was dead. Metadata with no expiry is treated as usable, so already-persisted rows don't force a re-resolve on every load.MediaUrlResolverre-mints before the URL reaches Coil. A singleton that checks the rendition's expiry, callsGetBlobsfor a fresh URL, and memoises the result for the process, so several surfaces showing the same avatar re-mint once between them.rememberMediaUrlwires it into composition: a load that fails anyway gets one further re-mint from Coil's error callback, and only a second failure falls back.BlobAccessContextcarries the authorization scope. Re-minting a blob the caller doesn't own needsGetBlobsRequest.contextnaming the surface that authorizes the read — a profile id for another user's avatar, a chat id for chat media. Without it the server omits those ids from the response rather than failing, which is indistinguishable from a blob that isn't ready. The scope is a required parameter onContactAvatar'sMediaItemoverload, so every call site has to state which one it is.The chat member →
UserProfilemapping dropped the user id. The server sets the id on the chat member and not again inside the nested profile, souserIdcame out null and callers had nothing to name the profile with. Fixed in both mapping paths —toChatMetadata()and the injectedChatMetadataMapper— each with a test that fails when the fallback is removed.BlurHash instead of the grey glyph. Avatars decode the BlurHash once and use it as both Coil's placeholder and its error drawable, so a re-mint in flight, or a load that fails outright, shows the blurred photo rather than the person icon.
Cache keys are the blob id, not the URL.
download_urlis minted per fetch, so a URL-keyed cache misses every time even when the bytes are held. Requests key memory and disk on the base58 blob id; notification avatars use the same key and now go through the resolver too.Notes
BlobStorageApivalidates the request before attaching the context.dev.bmcreations:protovalidate0.1.1 generates anAccessContextvalidator that emitscheckRequired(scopeCase == CHAT, "chat")andcheckRequired(scopeCase == PROFILE, "profile")unconditionally, so nooneofarm can validate. The comment at the split says to drop it once the generator handles oneof members.MediaUrlResolveris reset on sign-out — download URLs are minted for the signed-in owner.ic_placeholder_user. They load a plaintoken.imageUrlstring throughTokenIcon, a different pipeline this branch doesn't touch.