diff --git a/packages/views/editor/attachment.test.tsx b/packages/views/editor/attachment.test.tsx index d122b2263c..d18beff574 100644 --- a/packages/views/editor/attachment.test.tsx +++ b/packages/views/editor/attachment.test.tsx @@ -335,7 +335,7 @@ describe("Attachment — image dispatch", () => { // through to the durable markdown_url instead. configStore.setState({ cdnDomain: "cdn.example.test", cdnSigned: true }); const id = "11111111-2222-3333-4444-555555555555"; - const markdownUrl = `https://multica-api.copilothub.ai/api/attachments/${id}/download`; + const markdownUrl = `/api/attachments/${id}/download`; const att = makeRecord({ id, url: "https://cdn.example.test/uploads/ws/shot.png", @@ -362,6 +362,44 @@ describe("Attachment — image dispatch", () => { expect(getAttachmentMock).not.toHaveBeenCalled(); }); + it("re-signs a cross-origin API image URL in the web editor", async () => { + // The web app normally uses the same-origin /api proxy, so getBaseUrl is + // empty. A self-hosted server can still persist an absolute markdown_url + // on a different origin, though. Native loading cannot rely on the + // app session cookie being accepted by that host, while an authenticated + // metadata request can return a freshly signed storage URL. + configStore.setState({ cdnDomain: "cdn.example.test", cdnSigned: true }); + const id = "11111111-2222-3333-4444-555555555555"; + const markdownUrl = `https://api.example.test/api/attachments/${id}/download`; + const signed = + "https://cdn.example.test/uploads/ws/shot.png?Signature=fresh&Key-Pair-Id=K"; + resolverState.attachments = [ + makeRecord({ + id, + url: "https://cdn.example.test/uploads/ws/shot.png", + markdown_url: markdownUrl, + download_url: `/api/attachments/${id}/download`, + }), + ]; + getAttachmentMock.mockResolvedValue(makeRecord({ id, download_url: signed })); + + renderWithQuery( + , + ); + + await waitFor(() => { + expect(document.querySelector("img")?.getAttribute("src")).toBe(signed); + }); + expect(getAttachmentMock).toHaveBeenCalledWith(id); + }); + it("re-signs the inline media URL through getAttachment on token-mode clients (MUL-3254)", async () => { // Desktop / mobile webview: file:// document origin, Bearer-token auth. // The auth-gated /api/attachments//download endpoint 401s as a diff --git a/packages/views/editor/attachment.tsx b/packages/views/editor/attachment.tsx index 534579d057..ceaeb537be 100644 --- a/packages/views/editor/attachment.tsx +++ b/packages/views/editor/attachment.tsx @@ -321,11 +321,12 @@ const RESIGN_STALE_MS = 20 * 60 * 1000; // endpoint (e.g. a reopened issue draft, whose persisted record deliberately // strips the short-lived signed `download_url`). That endpoint needs // credentials: web loads it because the session cookie rides on the -// request (same-site), but Desktop's file:// renderer and the mobile webview -// are cross-site — no cookie is attached and the Bearer token cannot be put -// on a native resource fetch, so the image 401s. Those clients are exactly -// the ones with a non-empty `api.getBaseUrl()` (no same-origin /api proxy), -// which is the existing platform signal `absolutizeMediaURL` keys off. +// request when it is genuinely same-origin. Desktop's file:// renderer, the +// mobile webview, and split-origin web deployments cannot rely on that: no +// cookie is attached and the Bearer token cannot be put on a native resource +// fetch, so the image 401s. Desktop/mobile expose a non-empty +// `api.getBaseUrl()`; web can also hit this path when the server emits an +// absolute markdown URL whose origin differs from the current page. // // For them, fetch fresh attachment metadata through the authenticated API — // the same re-sign the click-time download path already does — and swap in @@ -338,11 +339,21 @@ function useResignedInlineMediaURL( ): string { const idFromPickedUrl = attachmentIdFromDownloadURL(pickedUrl); const resignAttachmentId = attachmentId ?? idFromPickedUrl; + const isCrossOriginWebURL = (() => { + if (!/^https?:\/\//i.test(pickedUrl) || typeof window === "undefined") { + return false; + } + try { + return new URL(pickedUrl).origin !== window.location.origin; + } catch { + return false; + } + })(); const needsResign = !!resignAttachmentId && !!pickedUrl && idFromPickedUrl !== undefined && - (api.getBaseUrl?.() ?? "") !== ""; + ((api.getBaseUrl?.() ?? "") !== "" || isCrossOriginWebURL); const { data: fresh } = useQuery({ queryKey: ["attachment-inline-resign", resignAttachmentId],