Skip to content

[Due for payment 2026-11-14] [$175] Insights - Uploaded avatars are missing from PNG exports #106119

Open

Description

@luacmartins

Reproducible in staging?: Yes, reported November 4, 2026.
Reproducible in production?: Not confirmed.
Issue reported by: @luacmartins, @JmillsExpensify, and @dannymcclain.
Slack conversation: Avatar export investigation.

Action Performed

  1. Open Insights on staging with a dashboard showing members with uploaded avatars.
  2. Export the dashboard as a PNG.
  3. Open the downloaded image and check the browser console.

Expected Result

The PNG includes uploaded avatars and charts matching the dashboard, without avatar CSP violations.

Actual Result

Uploaded avatars are missing from the PNG, while default avatars render. The staging investigation identified a CSP error when fetching uploaded avatars.
Image

Fix

The exporter fetches avatar images to embed them in the PNG. Allow https://d1wpcgnaa73g0y.cloudfront.net in the App Worker's connect-src directive, scoped to this distribution without broadening script permissions.

The fix takes effect with the App Worker deployment. Verify exports on staging and production after their respective Worker deployments.

Workaround

No confirmed workaround for including uploaded avatars in the export. The dashboard remains usable.

Platforms

Web. Browser and OS were not specified in the staging report.

Screenshots/Videos

Original report with screenshot.

Related

PNG export feature: #105195

Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @truph01

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Nov 5, 2026
  2. self-assigned this
    on Nov 5, 2026
  3. MelvinBot commented on Nov 5, 2026

    @MelvinBot
    Contributor

    🤖 Web test steps verification results: ⚠️ partial

    Insights opened on dev NewDot web, but the required dashboard with uploaded member avatars was unavailable. No staging/production verification was performed; no data or source was modified.

    Local dev Insights empty state

    Step Status
    1. Open Insights with uploaded member avatarsInsights displayed its empty state. The required expense/member fixture was unavailable.
    Local dev Insights empty state
    ❌
    2. Export dashboard as PNGBlocked by the missing dashboard fixture; no export attempted.
    ❌
    3. Inspect PNG for avatars and chartsNot verified: no PNG exported; downloaded-image inspection was also unavailable through the tester.
    ❌
    4. Check console for avatar CSP violationsNot verified: browser console access was unavailable through the tester.
    ❌

    Proposal

    What is the root cause of that problem?

    Confidence: medium — code supports the reported staging diagnosis; end-to-end confirmation remains outstanding.

    The dashboard can display uploaded avatars, but PNG export separately fetches their bytes for embedding. The App Worker's connect-src omits the reported CloudFront distribution, even though img-src permits displaying it. Failed image fetches can become transparent placeholders, explaining a downloaded PNG with missing uploaded avatars while locally rendered default avatars remain visible.

    Code evidence and alternatives checked
    • The exporter captures the DOM through modern-screenshot: exportInsightsDashboard/index.ts:19–46.
    • The library fetches remote images for embedding and substitutes a placeholder on image-fetch failure: embed-image-element.ts:9–25, fetch.ts:120–131.
    • The inspected policy permits image display but lacks the avatar origin in connection permissions: csp.ts:70–135. Default/catalog avatars resolve locally: UserAvatarUtils.ts:402–422.
    • CORS or rendering problems could coexist, but do not explain the reported CSP refusal. Allowing the connection does not itself prove CDN responses permit cross-origin reads. Backend logs would not establish this browser-side failure.

    What changes do you think we should make in order to solve the problem?

    Use the existing open Allow avatar fetches for Insights PNG exports PR rather than creating a duplicate. It adds only https://d1wpcgnaa73g0y.cloudfront.net to connect-src, with staging/production policy regression tests; it does not broaden script permissions.

    Fix scope and acceptance checks
    • Proposed permission: csp.ts:94–95. Proposed header tests: csp.test.ts:7–41. These were inspected, not executed in this analysis.
    • Deploy the App Worker, reload the document, then verify a populated dashboard's exported PNG includes uploaded avatars and charts without avatar CSP violations. Repeat on staging and production after their respective deployments; production impact is currently unconfirmed.
    • If fetching still fails after CSP permits it, inspect CDN CORS headers and redirects.
    • Correct the PR's offline expectation: an uncached avatar-fetch failure alone can produce a transparent placeholder rather than the generic error dialog.
    • The issue's separately linked PR-preview policy change was not independently inspected.

    What alternative solutions did you explore? (Optional)

    Changing img-src, broadening CloudFront permissions, or allowing scripts would not address the narrowly identified connection restriction safely. No exporter rewrite is indicated by the evidence.

    Possibly related: Insight-new console errors appear after restore the encoder and export again concerns the same export flow, but its reported console errors are not established as the same cause or a duplicate.


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/106119#issuecomment-6662620193) to create a draft PR. An existing fix PR is already open; prefer reviewing it rather than requesting a duplicate.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal. · view recording

  4. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Nov 5, 2026
  5. melvin-bot commented on Nov 5, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @truph01 (External)

  6. changed the title [-]Insights - Uploaded avatars are missing from PNG exports[/-] [+][$175] Insights - Uploaded avatars are missing from PNG exports[/+] on Nov 5, 2026
  7. melvin-bot commented on Nov 5, 2026

    @melvin-bot
  8. camesenin commented on Nov 5, 2026

    @camesenin
  9. melvin-bot commented on Nov 5, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  10. vanshshah11 commented on Nov 7, 2026

    @vanshshah11

    Proposal

    Please re-state the problem that we are trying to solve in this issue

    When an Insights dashboard containing members with uploaded avatars is exported as a PNG, the uploaded avatars are missing from the downloaded image (default/letter avatars render fine). The staging investigation flagged a CSP error: the PNG exporter fetches the remote avatar bytes to embed them, but the avatar CDN origin is not permitted by the App Worker's connect-src.

    What is the root cause of that problem?

    Two layers, both in src/pages/Insights/exportInsightsDashboard/index.ts:

    Layer 1 — the exporter only pre-rasterizes <canvas> charts, never avatar <img>s (lines 30–38)

    Before calling domToCanvas, the export pre-converts chart canvases to inline data URLs so the clone is self-contained:

    // src/pages/Insights/exportInsightsDashboard/index.ts:30-38
    const chartImages = elements.map((element) =>
        Array.from(element.querySelectorAll('canvas, img'), (canvas) => {
            if (!(canvas instanceof HTMLCanvasElement)) {   // <-- only canvases handled
                return undefined;                           // <-- avatars (HTMLImageElement) fall through
            }
            const {width, height} = canvas.getBoundingClientRect();
            return {src: canvas.toDataURL('image/png'), width, height};
        }),
    );

    The querySelectorAll('canvas, img') does collect avatar <img> elements (the comment at line 50 even says "Count avatars too so chart indices stay aligned"), but the guard on line 32 maps every non-<canvas> node to undefined. So in onCloneNode (lines 50–67) the loop skips them (if (!chartImage) continue;, line 53) and the cloned avatar <img> keeps its original remote src.

    Layer 2 — domToCanvas (modern-screenshot) then tries to fetch that remote src and is blocked by connect-src

    Because the avatar <img> in the clone still points at its remote URL (e.g. https://d1wpcgnaa73g0y.cloudfront.net/…), the modern-screenshot rasterizer has to fetch() the bytes to embed them (embed-image-element.ts in the library fetches remote image srcs and substitutes a transparent placeholder on failure). That fetch() is governed by connect-src, not img-src. The App Worker CSP (web/cloudflare-worker/src/csp.ts:70–135) lists img-src: ['*', …] (so the avatar displays on screen) but connect-src does not include https://d1wpcgnaa73g0y.cloudfront.net — so the embed-time fetch fails, the avatar becomes a transparent placeholder, and the exported PNG is missing it.

    This is why uploaded avatars (remote CloudFront URL → AvatarImage renders <Image source={{uri: …}}> → web <img> with remote src, per src/components/Avatar/primitives/AvatarImage.tsx:38 and src/components/Avatar/UserAvatar.tsx:77) fail, while default avatars render: defaults are returned as local SVG components by getAvatar (src/libs/UserAvatarUtils.ts:412–423) and inline in the DOM, needing no fetch. The Insights dashboard uses exactly this <UserAvatar source={item.avatar} /> in src/pages/Insights/charts/InsightsDataTable.tsx:90–93.

    The existing fix PR #106018 addresses only Layer 2 (adding the CloudFront origin to connect-src). That unblocks the fetch for this one CDN, but leaves the exporter dependent on (a) the third-party library's fetch path and (b) every future avatar/CDN origin also being added to CSP. The robust fix addresses Layer 1: pre-rasterize the avatar <img> to a data URL the same way canvases already are, so no runtime fetch — and therefore no CSP dependency — is needed at all.

    What changes do you think we should make in order to solve the problem?

    Primary (Layer 1): pre-embed avatar <img> elements as data URLs before domToCanvas. In exportInsightsDashboard/index.ts, extend the chartImages capture so HTMLImageElement nodes that are remote (and already loaded) are rasterized to a data URL via a canvas, exactly parallel to the canvas path:

    // Replace the chartImages builder (lines 30-38) with one that also handles <img>:
    const chartImages = elements.map((element) =>
        Array.from(element.querySelectorAll('canvas, img'), (node) => {
            if (node instanceof HTMLCanvasElement) {
                const {width, height} = node.getBoundingClientRect();
                return {src: node.toDataURL('image/png'), width, height};
            }
            if (node instanceof HTMLImageElement) {
                const {width, height} = node.getBoundingClientRect();
                // Cross-origin <img> can't be drawn straight to a canvas without CORS taint;
                // re-fetch the bytes (same-origin-ish via the CDN) and convert to a data URL.
                // If anything fails, leave undefined and let the CSP/fetch path handle it.
                try {
                    // Prefer the already-cached bitmap if the image loaded cleanly and same-origin permits.
                    const c = document.createElement('canvas');
                    c.width = node.naturalWidth || width || 1;
                    c.height = node.naturalHeight || height || 1;
                    const ctx = c.getContext('2d');
                    if (!ctx) return undefined;
                    ctx.drawImage(node, 0, 0, c.width, c.height);
                    return {src: c.toDataURL('image/png'), width, height};
                } catch {
                    // Canvas tainted (cross-origin without CORS) — fall back to fetch+data URL below, or undefined.
                    return undefined;
                }
            }
            return undefined;
        }),
    );

    Then in onCloneNode, the existing replacement loop (lines 52–67) already swaps each captured node by index — so once the undefined for avatars is replaced with a real data URL, the cloned <img> gets src = "data:image/png;base64,…", which domToCanvas embeds with no fetch and no CSP dependency. (For the cross-origin/tainted case, fall back to a fetch(src).then(r => r.blob()) → FileReader → data URL; this still needs connect-src, so the CSP addition from #106018 is kept as the fallback, not the primary path.)

    Secondary (Layer 2, keep PR #106018): add https://d1wpcgnaa73g0y.cloudfront.net to connect-src in web/cloudflare-worker/src/csp.ts and extend web/cloudflare-worker/tests/csp.test.ts to assert it for staging/production. This covers the fetch-fallback for tainted canvases and any other in-DOM remote images, and matches the issue's stated fix.

    Net effect: avatars embed even if the CDN URL changes or a new avatar origin is used, and the CSP change closes the remaining cross-origin fetch gap.

    What specific scenarios should be covered by automated tests?

    • tests/unit/exportInsightsDashboard*:
      1. Given a dashboard DOM with one <canvas> chart + one <img> avatar (remote src), after capture the avatar's cloned node has a data:image/png src (no network fetch in the embed path).
      2. An avatar <img> whose draw-to-canvas throws (tainted) falls back to the fetch→data-URL path and, with CSP permitting, still embeds; without CSP it degrades to the placeholder (assert the fallback is reachable, not silent).
      3. Default (SVG) avatars still embed unchanged.
    • web/cloudflare-worker/tests/csp.test.ts: staging + production connect-src contains https://d1wpcgnaa73g0y.cloudfront.net with no wildcard/script broadening.
    • Manual/staging: export a populated dashboard with uploaded avatars; assert the PNG contains them and the console shows no CSP violations.

    What alternative solutions did you explore? (Optional)

    • CSP-only fix (PR [No QA] Allow avatar fetches for Insights PNG exports on dev #106018 as the whole answer). Necessary but insufficient: it fixes today's CDN but couples every avatar-host change to a CSP deploy, and still routes every export through a runtime cross-origin fetch that can race/taint. Pre-embedding removes the fetch entirely.
    • Configure the avatar CDN to send Access-Control-Allow-Origin and set crossOrigin="anonymous" on <img>. Would avoid canvas taint, but requires backend/CDN config changes and touches the avatar render path outside the export scope. Good long-term companion to the primary fix.
    • Replace modern-screenshot's embed with a custom rasterizer. Over-engineered; pre-embedding the <img> nodes reuses the library for everything else.

    PR

    PR will be linked here once created.

    Contributor details
    Your Expensify account email: svansh1111@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~01b6a593fd295a20bf

  11. melvin-bot commented on Nov 7, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  12. jeffrey-graham commented on Nov 7, 2026

    @jeffrey-graham
  13. melvin-bot commented on Nov 7, 2026

    @melvin-bot
  14. changed the title [-][$175] Insights - Uploaded avatars are missing from PNG exports[/-] [+][Due for payment 2026-11-14] [$175] Insights - Uploaded avatars are missing from PNG exports[/+] on Nov 7, 2026
  15. melvin-bot commented on Nov 7, 2026

    @melvin-bot

    @truph01

    The solution for this issue has been 🚀 deployed to production 🚀 in version 9.5.33-1 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:

    If no regressions arise, payment will be issued on 2026-11-14. 🎊

    The following checklist (instructions) will need to be completed before the issue can be closed. Please copy/paste the Contributor+ Checklist from here into a new comment on this GH and complete it. If you have the K2 extension, you can simply click: [this button]. If no checklist is needed for this issue, you can click: [no checklist button]

  16. melvin-bot commented on Nov 10, 2026

    @melvin-bot

    @truph01 Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!

  17. melvin-bot commented on Nov 12, 2026

    @melvin-bot

    @truph01 Eep! 4 days overdue now. Issues have feelings too...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributorOverdue

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions