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],