fix(editor): re-sign cross-origin attachment URLs (#6029)

Treat absolute cross-origin attachment API URLs as requiring an authenticated metadata refresh, so draft image previews load on split-origin self-hosted deployments backed by presign-capable or CloudFront-signed storage.

Same-origin URLs (relative or absolute) and external image URLs keep their existing no-refetch path.

Fixes #6049
This commit is contained in:
苗大
2026-07-29 14:19:25 +08:00
committed by GitHub
parent cc5ee169f0
commit 6d98b5eeb3
2 changed files with 56 additions and 7 deletions

View File

@@ -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 <img> 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(
<Attachment
attachment={{
kind: "url",
url: markdownUrl,
filename: "shot.png",
forceKind: "image",
}}
/>,
);
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/<id>/download endpoint 401s as a

View File

@@ -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 <img>
// 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],