Skip to content

fix(github): show private-repo body images from signed links - #3252

Merged
allensquare merged 7 commits into
mainfrom
allent/github-private-images
Nov 11, 2026
Merged

allensquare merged 7 commits into
mainfrom
allent/github-private-images

Conversation

@allensquare

@allensquare allensquare commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Problem

In the GitHub panel, images and videos in a private repository's PR or issue body show as "Image unavailable".

The panel loads each one from its markdown link, https://github.com/user-attachments/assets/<id>. For a private repository, that link needs a signed-in GitHub browser session. Buzz has none, so GitHub returns 404. Public repositories are not affected.

The rendered body HTML that the panel already reads (body_html / bodyHTML) has a signed link for each asset on private-user-images.githubusercontent.com. Signed links load without a sign-in, but they expire after about 5 minutes and change on every read. Each reader gets them from their own gh sign-in, so only people who can read the repository see the media. Nothing goes through the relay.

Fix

Media loads its own markdown link first, exactly as on main. The signed link is used only when that fails.

  • attachments.ts: attachmentMetadata() also keeps each asset's signed link, under the same asset key. A new signedUrl() looks it up. attachmentFor() still returns the markdown link.

  • GitHubBody.tsx, per media element:

    1. The own link loads: nothing else happens. Public repositories make no extra request or read.
    2. The own link fails and the asset has a signed link: that element switches to it. No GitHub read.
    3. The signed link fails (for example, it expired): the body asks the panel to read again. One read runs at a time. A source that failed and has not loaded since does not read again, so a broken asset cannot loop. When the new body arrives, only failed elements take their new signed link. Working media keeps its element and link, so nothing reloads or restarts on polls.
    4. No signed link: it shows the original link on failure, as on main.

    The markdown components object moved to module scope (with a small context for the media renderer), so a new read no longer remounts every media element.

  • index.tsx: passes the panel's existing load to GitHubBody as refresh. No new fetch code. A failed reload of an issue panel now keeps the issue that is shown, the same as pull request panels. A failed first load still shows the error.

The app's CSP already allows private-user-images.githubusercontent.com (media-src) and https: images (img-src). No config change.

Screenshots

Annotated guide: before, the image and video are unavailable; after, both load from GitHub's signed links

Explanatory guide; unmodified screenshots below. Captured from the real GitHubPanel (opened by clicking a PR link in a message) with Playwright, under the packaged img-src/media-src policy. GitHub responses are mocked: each asset's own link returns 404, as for a private repository, and its signed link returns 200. All data is fictional. 1440×950 CSS px viewport, 2x. Chromium shown; WebKit passed the same assertions.

Before — main at 7f35bb1 After — this PR at f4de506
Before, dark: Image unavailable and Video unavailable After, dark: the image and video load
Light theme, wide panel, larger text, keyboard
Before, light After, light
Before, light: Image unavailable After, light: the image and video load

Wide panel (774 px). Media keeps the shared MediaAttachment maximum sizes.

After, dark, wide panel

Root text at 20px. The title wraps cleanly; the video is reachable by scrolling the panel.

Top Scrolled
After, 20px text, top After, 20px text, scrolled to the video

Keyboard: one Tab from the title reaches "Open image fullscreen" with a visible focus ring. Enter opens the viewer with the signed image; Escape closes it and returns focus to the preview.

Focus on the preview Viewer open
After, keyboard focus on the image preview After, fullscreen viewer showing the signed image

Tests

  • RTL (GitHubBody.test.tsx, attachments.test.ts):

    • own link first for images and videos;

    • own-link failure switches to the signed link with no read;

    • signed-link failure reads once, with the original link as the fallback link;

    • only the failed image takes the renewed link, and the loaded video keeps its DOM node and link;

    • a renewed link that fails before loading does not read again;

    • one read at a time, and a later expiry reads again after a failed read;

    • public and raw media keep their own links;

    • GitHubPanel reads the PR again when its signed image fails;

    • an issue panel stays shown when that read fails;

    • signedUrl() finds private assets and returns nothing for public and raw ones.

    • with fake timers: after a failed renewal, reads again at 30s, at most 3 times; a renewed link that loads stops retrying without refilling them; loads of other media do not refill them; media that loads then fails on each link stays within the cap; unmount clears the timer; no timer for public media or media whose own link loads; a link a poll delivers is tried before reading again; a network failure (code 2) on a signed video neither reads again nor remounts the player, and shows the original link until the video loads again; loaded audio that fails on a network error shows its original link (both fail on 2220d8e, pass on d057182).

    Run against the previous signed-first head, 9 of these fail; all pass on this change. The retry tests fail on f4de506 (no retry) and pass on db501c7.

  • Browser journey, 1 added, 0 removed (tests/browser/github-body.spec.mjs, "private repository media › private PR media falls back to its signed links under the packaged media policy"). jsdom loads no media and enforces no CSP, so only a real engine can show that a real 404 on <img> and <video> triggers the switch, and that the signed link decodes under the packaged policy. It reuses the existing spec's fixtures. The deliberate 404s use the fixture's existing expectedStartupLoadFailure option, scoped to this case.

    • Fails on main in Chromium and WebKit (the image becomes "Image unavailable").
    • Passes on this change in both. The existing journey also passes unchanged in both.
  • bin/pnpm exec vitest run src/bundled/github: 22 files, 374 passed. tsc --noEmit and biome check --error-on-warnings pass. bin/pnpm test:browser tests/browser/github-body.spec.mjs --project chromium --project webkit: 4 passed.

  • Hooks: committed and pushed through lhm with lefthook 2.2.2. Pre-commit (literal names, icons, biome, sadscan) and pre-push (types and related unit tests, design system) passed.

  • Real data: for a private squareup PR read with gh, the asset's own link returns 404 without cookies and its signed link from body_html returns 200 image/png. This is the shape the mocks use.

  • Human test (Allen): in a dev build of this branch (bin/just desktop me) signed in as himself, the images in a private squareup PR body load in the GitHub panel; the same PR in the released app shows "Image unavailable". Tested at db501c7; the later commits change only failure handling, and Allen confirmed the test covers 2220d8e.

  • Agent app run: skipped by Allen; his test in the native app covers it. The browser journey exercises the fallback in Chromium and WebKit.

Review

  • Round 1 (3 findings): fixed.
  • After a renewal read failed, failed media stayed on its original link until it loaded, so an issue panel (which does not poll) stayed broken until reopened. Fixed in db501c7 at the author's request: media still failed with no newer link reads again after 30 seconds, at most 3 times. One read and one timer at most; a link a poll delivers is tried first; unmount clears the timer.
  • BuilderBot on db501c7: a load refilled the retries, and media that loaded then failed read again at once, so a partly loading video could read in a loop. Fixed in 15c823b: each source reads at once only on its first failure and the 3 retries never refill, so a panel reads at most once per signed source plus 3 times.
  • BuilderBot on 15c823b: a network blip on a signed video renewed its link and remounted the player, which aborted the player's own quiet reload. Fixed in 2220d8e: a media network error (code 2) is left to the player. An expired or missing link fails as code 4, checked in Playwright Chromium and WebKit against 403 and 404 responses, so it still takes another link.
  • Kyle (review on 2220d8e): the code-2 bypass also skipped audio, which has no quiet reload, and a loaded video kept hiding its original link after its reloads gave up. Fixed in d057182: only video skips a network error, and it shows the original link until it loads again. BuilderBot on d057182: no findings.
  • Declined by the author (Allen), from BuilderBot on 2220d8e: after a long network outage exhausts the player's own reloads, a signed video stays "Video unavailable" until the panel is reopened. All Buzz videos behave this way; a fix needs a shared player change.
  • Declined by the author (Allen): a renewal remount closes an open full-screen viewer. It needs the viewer's own load to fail on a link over 5 minutes old; clicking the image again opens it with the new link.
  • Declined by the author (Allen): signed-link pairing does not cover user-attachments/files. Those are downloads, not inline media. Legacy user-images links are already paired by the CDN path.

Limits

  • A failed asset that never loads (for example, deleted) costs at most 1 read plus 3 retries 30s apart per panel. On a PR panel it also takes each newer signed link that polling brings: one image request per 30s poll, with no extra GitHub API reads.

buzz-review-completed

🤖 Generated with Claude Code

allensquare and others added 2 commits November 10, 2026 16:26
Private repositories' attachment links (github.com/user-attachments/assets/...)
need a GitHub browser sign-in, so the panel showed "Image unavailable". The
rendered body HTML GitHub already returns carries signed
private-user-images.githubusercontent.com links that load without one.

Use that signed link as the media source for each asset, keeping the
original link as the fallback. Signed links expire within minutes and
change on every read, so the body keeps the links it shows until shown
media fails, then reads the panel's object again once and takes the new
links. Media that fails again before loading does not read again.

Co-authored-by: Squirtle Squad <ec57954349ef49ce554b9cda062c34933dd3a085cf82278bb0e955efea492c7e@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Allen Tsai <[email protected]>
…ed reads

Review follow-ups for private-repo body media:

- A renewal read that failed or changed nothing blocked every later
  renewal. Allow one read in flight and clear it when the read settles;
  media that failed and has not loaded since still does not read again.
- Adopting renewed HTML remounted every media element, because
  react-markdown got new component types on every render, restarting
  playing videos and reloading loaded images. Make the components stable
  (they read the media renderer from context) and have each element keep
  the signed link it shows until that link fails, so a renewal swaps only
  the media that failed. This replaces the body-wide HTML pinning.
- Reading an issue panel again for an expired image replaced the shown
  issue with an error when the read failed. A failed read now keeps a
  shown object; a first read's error is unchanged.

Co-authored-by: Squirtle Squad <ec57954349ef49ce554b9cda062c34933dd3a085cf82278bb0e955efea492c7e@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Allen Tsai <[email protected]>
@allensquare

Copy link
Copy Markdown
Contributor Author

🤖 Addressed the 3 findings from an automated review in 0de78e3:

  1. A failed renewal read blocked later renewals. Valid. Now one read runs at a time, and the flag clears when the read settles.
  2. Renewal remounted all signed media. Valid. Each element keeps its own link and swaps only when it fails. The markdown components are now stable, so a new read keeps the media elements.
  3. A failed renewal read replaced a loaded issue panel with an error. Valid. A failed reload keeps the shown object. A failed first load is unchanged.

vitest run src/bundled/github: 365 passed. tsc and biome check pass.

@allensquare

Copy link
Copy Markdown
Contributor Author

🤖 Second automated review raised one P2: after a renewal read fails, a failed media element stays latched until it loads, so on issue panels (no polling) it shows the original link until the panel reopens. Not changing this. The latch is what stops a read loop for a deleted or broken asset. Retrying would need a timed backoff, which is more than this fix needs. Pull request panels recover on the next 30s poll. Issue panels recover when reopened. Noted under "Not checked yet" in the description.

@allensquare
allensquare marked this pull request as ready for review November 11, 2026 00:45
@allensquare
allensquare requested a review from a team as a code owner November 11, 2026 00:45
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-11T02:33:50.926615Z 2220d8e Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@allensquare
allensquare marked this pull request as draft November 11, 2026 00:50
The GitHub panel loaded a private-repository signed link before media's
own link. A stale or expired signed link (for example in a body read more
than five minutes earlier) then broke media that loads fine from its own
link, which failed the packaged media policy browser journey.

Media now loads its own link first, exactly as before this change. Only
when that fails and the rendered HTML has a signed link for the asset
does that element switch to it; when the signed link fails, the panel
reads GitHub again (one read at a time, no read again for media that
failed and has not loaded since) and that element takes the renewed
link. Public repositories make no extra requests.

Adds a browser case for a private repository's media: its own links 404,
its signed links load under the enforced packaged media policy, in both
engines.

Co-authored-by: Squirtle Squad <ec57954349ef49ce554b9cda062c34933dd3a085cf82278bb0e955efea492c7e@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Allen Tsai <[email protected]>
@allensquare

Copy link
Copy Markdown
Contributor Author

🤖 Agent review at f4de506 raised one P2: signed-link pairing misses the legacy user-images.githubusercontent.com and user-attachments/files forms. Not changing this, for two reasons:

  • user-images: already paired. assetKey() reads the uuid from any CDN path shaped /<n>/<n>-<uuid>.<ext> (cdnPath). Legacy user-images links and their private-user-images replacements share that shape, so they get the same asset:<uuid> key.
  • user-attachments/files/<n>/<name>: these are file downloads, which render as links rather than inline media. GitHub's HTML does not give them a signed media element to pair with.

Private-repo bodies I sampled render signed images with no data-canonical-src, so pairing by element would not help either.

allensquare and others added 3 commits November 10, 2026 17:34
When a renewal read fails, or the renewed signed link fails too, media
that still has no newer link to try now reads again after 30 seconds, at
most three times until a signed link loads. Issue panels do not poll, so
before this they stayed broken until reopened. One read and one timer at
most; a link a poll delivers is tried first; unmount clears the timer.
Public media and media whose own link loads are unchanged.

Co-authored-by: Squirtle Squad <ec57954349ef49ce554b9cda062c34933dd3a085cf82278bb0e955efea492c7e@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Allen Tsai <[email protected]>
A load reset the retry count, and media that loaded and then failed on
the same link read again at once, so a partly loading video could read
GitHub in a loop. Each source now reads at once only on its first
failure, and the 3 retries never refill, so a panel reads at most once
per signed source plus three times.

Co-authored-by: Squirtle Squad <ec57954349ef49ce554b9cda062c34933dd3a085cf82278bb0e955efea492c7e@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Allen Tsai <[email protected]>
A network blip on a signed video renewed its link and remounted the
player, which aborted the player's own quiet reload. Expired and missing
links fail as unsupported (code 4) in Chromium and WebKit, so only those
take another link.

Co-authored-by: Squirtle Squad <ec57954349ef49ce554b9cda062c34933dd3a085cf82278bb0e955efea492c7e@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Allen Tsai <[email protected]>
@allensquare
allensquare marked this pull request as ready for review November 11, 2026 02:31

@kazemek21 kazemek21 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kyle reviewed this PR and the Nexus review-tracker notes and approves, with the non-blocking comment below.

Posted by Codex (gpt-6.1-sol) on behalf of kylez.

Comment thread src/bundled/github/GitHubBody.tsx Resolved
Only video reloads quietly after a network error, so audio network
errors take the normal failure path again. A video network error now
shows the original link until the video loads again, so it stays beside
"Video unavailable" when the player's reloads give up.

Co-authored-by: Squirtle Squad <ec57954349ef49ce554b9cda062c34933dd3a085cf82278bb0e955efea492c7e@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Allen Tsai <[email protected]>
@allensquare
allensquare merged commit 128cd4c into main Nov 11, 2026
182 of 186 checks passed
@allensquare
allensquare deleted the allent/github-private-images branch November 11, 2026 17:49
bostonaholic added a commit that referenced this pull request Nov 11, 2026
…y-untagged-reactions

* origin/main: (324 commits)
  fix(github): show private-repo body images from signed links (#3252)
  NEXUS-4029: Preserve semantic chips in the core composer (#2303)
  Make plugin signature enforcement opt-in (#2212)
  NEXUS-4156: Add workspace Leads and choose a chat's workspace (#2788)
  feat(plugins): report conclusively retired update providers (#3263)
  feat(messages): let attachment renderers open files from the host file card (#2239)
  feat(charts): bundle a Charts code-block renderer (#3226)
  Expose native reply grouping through Conversation.ui (#3245)
  NEXUS-4163: Keep plugin-owned chat deletion in Solo (#2787)
  NEXUS-4156: Expose Solo Workspaces to plugin regions (#2786)
  NEXUS-4022: Start region chats in the New Chat editor (#2784)
  feat(link-previews): unfurl web links below messages by default (#3225)
  fix(messages): keep names in literal Markdown as written when editing (#2834)
  feat(plugins): fetch a packaged managed plugin policy from a URL (#2504)
  fix(goose): enable deferred tool loading for goose agents (#3227)
  fix(unread): preserve manual intent in captured explicit reads (#3228)
  NEXUS-4337: Expose retained plugin update reviews (#3075)
  feat(conversation): add a link preview slot below messages (#3219)
  feat(plugins): let link previews read a public page's preview fields (#3217)
  Let sidebar row plugins decorate ordinary Solo chats (#3222)
  ...
tlongwell-block added a commit that referenced this pull request Nov 11, 2026
* origin/main: (121 commits)
  Expose Solo region state before a component mounts (#3296)
  fix(builderlab): say a 401 was refused, not expired, and show each sign-in failure once (#3241)
  feat(agents): file new Solo chats in explicit personal workspaces (#2857)
  perf(css): limit broad style invalidation (#3267)
  Delay and fade in the Bestie badge celebration (#3292)
  fix(github): show private-repo body images from signed links (#3252)
  NEXUS-4029: Preserve semantic chips in the core composer (#2303)
  Make plugin signature enforcement opt-in (#2212)
  NEXUS-4156: Add workspace Leads and choose a chat's workspace (#2788)
  feat(plugins): report conclusively retired update providers (#3263)
  feat(messages): let attachment renderers open files from the host file card (#2239)
  feat(charts): bundle a Charts code-block renderer (#3226)
  Expose native reply grouping through Conversation.ui (#3245)
  NEXUS-4163: Keep plugin-owned chat deletion in Solo (#2787)
  NEXUS-4156: Expose Solo Workspaces to plugin regions (#2786)
  NEXUS-4022: Start region chats in the New Chat editor (#2784)
  feat(link-previews): unfurl web links below messages by default (#3225)
  fix(messages): keep names in literal Markdown as written when editing (#2834)
  feat(plugins): fetch a packaged managed plugin policy from a URL (#2504)
  fix(goose): enable deferred tool loading for goose agents (#3227)
  ...

Signed-off-by: Tyler Longwell <[email protected]>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
joahg added a commit to joahg/buzz-app that referenced this pull request Nov 11, 2026
* origin/main:
  NEXUS-4029: Offer folder chips in the composer (block#2789)
  perf(css): narrow effort button invalidation (block#3297)
  NEXUS-4433: Expose saved plugin installation source kind (block#3242)
  fix(identity): keep an unbacked key safe on Replace and unblock a corrupt saved key (block#3233)
  Expose Solo region state before a component mounts (block#3296)
  fix(builderlab): say a 401 was refused, not expired, and show each sign-in failure once (block#3241)
  feat(agents): file new Solo chats in explicit personal workspaces (block#2857)
  perf(css): limit broad style invalidation (block#3267)
  Delay and fade in the Bestie badge celebration (block#3292)
  fix(github): show private-repo body images from signed links (block#3252)
  NEXUS-4029: Preserve semantic chips in the core composer (block#2303)
  Make plugin signature enforcement opt-in (block#2212)
  NEXUS-4156: Add workspace Leads and choose a chat's workspace (block#2788)
  feat(plugins): report conclusively retired update providers (block#3263)
  feat(messages): let attachment renderers open files from the host file card (block#2239)
  feat(charts): bundle a Charts code-block renderer (block#3226)
  Expose native reply grouping through Conversation.ui (block#3245)

Signed-off-by: Joah Gerstenberg <[email protected]>

# Conflicts:
#	src/features/conversation/ComposerCompletions.test.tsx
#	src/features/conversation/ComposerCompletions.tsx
#	src/features/messages/MessageComposer.tsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants