From 8ef0a217fcd7eef8f7b53fa627fb41e449c219c6 Mon Sep 17 00:00:00 2001 From: J Date: Mon, 29 Jun 2026 12:40:26 +0800 Subject: [PATCH] fix: prefer local upload attachment URLs Co-authored-by: multica-agent --- packages/views/editor/attachment.test.tsx | 34 +++++++++++++++++++++++ packages/views/editor/attachment.tsx | 20 ++++++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/views/editor/attachment.test.tsx b/packages/views/editor/attachment.test.tsx index cfc1fe95d..d122b2263 100644 --- a/packages/views/editor/attachment.test.tsx +++ b/packages/views/editor/attachment.test.tsx @@ -262,6 +262,40 @@ describe("Attachment — image dispatch", () => { ); }); + it("prefers a local disk /uploads URL over API markdown in split-origin self-host", () => { + getBaseUrlMock.mockReturnValue("https://api.example.test"); + const id = "11111111-2222-3333-4444-555555555555"; + const markdownUrl = `https://api.example.test/api/attachments/${id}/download`; + const mediaUrl = "https://api.example.test/uploads/workspaces/ws-1/shot.png"; + const att = makeRecord({ + id, + url: "/uploads/workspaces/ws-1/shot.png", + markdown_url: markdownUrl, + download_url: `/api/attachments/${id}/download`, + }); + resolverState.attachments = [att]; + + renderWithQuery( + , + ); + + expect(document.querySelector("img")?.getAttribute("src")).toBe(mediaUrl); + + fireEvent.click(screen.getByTitle("View")); + + const imageSrcs = [...document.querySelectorAll("img")].map((img) => + img.getAttribute("src"), + ); + expect(imageSrcs).toEqual([mediaUrl, mediaUrl]); + }); + it("opens preview with the same resolved media URL when a reopened draft record has no download_url", () => { configStore.setState({ cdnDomain: "cdn.example.test" }); const id = "11111111-2222-3333-4444-555555555555"; diff --git a/packages/views/editor/attachment.tsx b/packages/views/editor/attachment.tsx index 76d0b7aa7..c85d9abb2 100644 --- a/packages/views/editor/attachment.tsx +++ b/packages/views/editor/attachment.tsx @@ -237,12 +237,17 @@ function absolutizeMediaURL(rawUrl: string): string { // reports `cdn_signed` — in CloudFront signed-URL mode the same // domain serves PRIVATE content and a raw (unsigned) storage URL is // a guaranteed 403 (MUL-3254). -// 3. `record.markdown_url` — the durable, server-policy-aligned URL. +// 3. Local disk `record.url` — self-host LocalStorage without +// LOCAL_UPLOAD_BASE_URL stores a site-relative `/uploads/...` path. +// It is the direct static object URL and is loadable once +// `absolutizeMediaURL` prefixes apiBaseUrl in split-origin clients. +// 4. `record.markdown_url` — the durable, server-policy-aligned URL. // Beats raw `record.url` because it never points at a private -// bucket (must-fix 2 from MUL-3192 review). -// 4. `record.url` — legacy fallback for responses that omit +// bucket (must-fix 2 from MUL-3192 review), except for the explicit +// site-relative local upload path above. +// 5. `record.url` — legacy fallback for responses that omit // `markdown_url` (a backend old enough to predate MUL-3192). -// 5. The input URL — when there's no record at all. +// 6. The input URL — when there's no record at all. function pickInlineMediaURL( record: AttachmentRecord, fallback: string, @@ -257,11 +262,18 @@ function pickInlineMediaURL( return dl; } if (!cdnSigned && storageURLMatchesCdnDomain(record.url, cdnDomain)) return record.url; + if (isSiteRelativeLocalUploadURL(record.url)) return record.url; if (record.markdown_url) return record.markdown_url; if (record.url) return record.url; return fallback; } +function isSiteRelativeLocalUploadURL(rawURL: string): boolean { + if (!rawURL || !rawURL.startsWith("/")) return false; + const path = rawURL.split(/[?#]/, 1)[0] ?? ""; + return path === "/uploads" || path.startsWith("/uploads/"); +} + function storageURLMatchesCdnDomain(rawURL: string, cdnDomain: string): boolean { const expected = normalizeHost(cdnDomain); if (!rawURL || !expected) return false;