From cb46184039bce3830d8d13ac80de5ff84836c5df Mon Sep 17 00:00:00 2001 From: Eve Date: Wed, 10 Jun 2026 16:23:45 +0800 Subject: [PATCH] fix(desktop): render API attachment images with auth Co-authored-by: multica-agent --- packages/core/api/client.test.ts | 42 +++++++++++++++++++ packages/core/api/client.ts | 42 +++++++++++++++++++ packages/views/editor/attachment.test.tsx | 50 ++++++++++++++++++++++- packages/views/editor/attachment.tsx | 35 +++++++++++++++- 4 files changed, 167 insertions(+), 2 deletions(-) diff --git a/packages/core/api/client.test.ts b/packages/core/api/client.test.ts index e3f9517ae..b73d262f6 100644 --- a/packages/core/api/client.test.ts +++ b/packages/core/api/client.test.ts @@ -6,6 +6,48 @@ afterEach(() => { }); describe("ApiClient", () => { + it("fetches internal attachment download URLs with the normal auth headers", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(new Blob(["png"], { type: "image/png" }), { + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const client = new ApiClient("https://api.example.test"); + client.setToken("tok_123"); + + await client.fetchAttachmentDownloadBlob( + "https://api.example.test/api/attachments/019eb094-bed1-7590-85db-acd2d8dd6c5d/download?cache=1", + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe( + "https://api.example.test/api/attachments/019eb094-bed1-7590-85db-acd2d8dd6c5d/download?cache=1", + ); + expect(init).toMatchObject({ + credentials: "include", + headers: expect.objectContaining({ + Authorization: "Bearer tok_123", + }), + }); + }); + + it("does not fetch attachment-looking URLs from a different origin", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const client = new ApiClient("https://api.example.test"); + + await expect( + client.fetchAttachmentDownloadBlob( + "https://evil.example.test/api/attachments/019eb094-bed1-7590-85db-acd2d8dd6c5d/download", + ), + ).rejects.toThrow("not an internal attachment download URL"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("preserves HTTP status on failed requests", async () => { vi.stubGlobal( "fetch", diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index d5891db66..5f08a25e8 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -117,6 +117,7 @@ import type { BillingCheckoutSessionStatus, CreateBillingPortalSessionResponse, } from "../types"; +import { attachmentIdFromDownloadURL } from "../types/attachment-url"; import type { OnboardingCompletionPath } from "../onboarding/types"; import type { CloudRuntimeNode, @@ -269,6 +270,47 @@ export class ApiClient { return this.baseUrl; } + private internalAttachmentDownloadPath(rawUrl: string): string | null { + if (!attachmentIdFromDownloadURL(rawUrl)) return null; + + if (/^https?:\/\//i.test(rawUrl)) { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return null; + } + + let expectedOrigin = ""; + try { + expectedOrigin = this.baseUrl + ? new URL(this.baseUrl).origin + : typeof window !== "undefined" + ? window.location.origin + : ""; + } catch { + return null; + } + if (!expectedOrigin || parsed.origin !== expectedOrigin) return null; + return `${parsed.pathname}${parsed.search}`; + } + + if (!rawUrl.startsWith("/")) return null; + try { + const parsed = new URL(rawUrl, "http://multica.local"); + return `${parsed.pathname}${parsed.search}`; + } catch { + return null; + } + } + + async fetchAttachmentDownloadBlob(rawUrl: string): Promise { + const path = this.internalAttachmentDownloadPath(rawUrl); + if (!path) throw new Error("not an internal attachment download URL"); + const res = await this.fetchRaw(path); + return res.blob(); + } + setToken(token: string | null) { this.token = token; } diff --git a/packages/views/editor/attachment.test.tsx b/packages/views/editor/attachment.test.tsx index dbe0d4b57..f0f8ba297 100644 --- a/packages/views/editor/attachment.test.tsx +++ b/packages/views/editor/attachment.test.tsx @@ -1,17 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import type { ReactElement, ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Attachment as AttachmentRecord } from "@multica/core/types"; const { getAttachmentTextContentMock, + fetchAttachmentDownloadBlobMock, getBaseUrlMock, downloadMock, openExternalMock, openByUrlMock, + isDesktopShellMock, } = vi.hoisted(() => ({ getAttachmentTextContentMock: vi.fn(), + fetchAttachmentDownloadBlobMock: vi.fn(), // Default: empty base URL so existing tests render site-relative URLs // through the proxy (i.e. exactly the way the web app behaves). The // absolutize-specific suite below overrides this to simulate Desktop / @@ -20,11 +23,13 @@ const { downloadMock: vi.fn(), openExternalMock: vi.fn(), openByUrlMock: vi.fn(), + isDesktopShellMock: vi.fn(() => false), })); vi.mock("@multica/core/api", () => ({ api: { getAttachmentTextContent: getAttachmentTextContentMock, + fetchAttachmentDownloadBlob: fetchAttachmentDownloadBlobMock, getBaseUrl: getBaseUrlMock, }, PreviewTooLargeError: class extends Error {}, @@ -37,6 +42,7 @@ vi.mock("./use-download-attachment", () => ({ vi.mock("../platform", () => ({ openExternal: openExternalMock, + isDesktopShell: isDesktopShellMock, })); vi.mock("../i18n", () => ({ @@ -138,6 +144,8 @@ beforeEach(() => { // the web app's same-origin proxy. Tests that simulate Desktop / mobile // webview override per-case via getBaseUrlMock.mockReturnValue(...). getBaseUrlMock.mockReturnValue(""); + fetchAttachmentDownloadBlobMock.mockRejectedValue(new Error("not internal")); + isDesktopShellMock.mockReturnValue(false); }); afterEach(() => { @@ -277,6 +285,46 @@ describe("Attachment — image dispatch", () => { expect(img?.getAttribute("src")).not.toContain("prod.s3.amazonaws.com"); }); + it("internal API image URLs are rendered through an authenticated blob URL", async () => { + isDesktopShellMock.mockReturnValue(true); + const rawSrc = + "https://multica-api.copilothub.ai/api/attachments/019eb094-bed1-7590-85db-acd2d8dd6c5d/download"; + const createObjectURL = vi.fn(() => "blob:authenticated-image"); + const revokeObjectURL = vi.fn(); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: createObjectURL, + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: revokeObjectURL, + }); + fetchAttachmentDownloadBlobMock.mockResolvedValueOnce( + new Blob(["png"], { type: "image/png" }), + ); + + const { unmount } = renderWithQuery( + , + ); + + const img = document.querySelector("img"); + expect(img?.getAttribute("src")).toBe(rawSrc); + await waitFor(() => { + expect(img?.getAttribute("src")).toBe("blob:authenticated-image"); + }); + expect(fetchAttachmentDownloadBlobMock).toHaveBeenCalledWith(rawSrc); + + unmount(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:authenticated-image"); + }); + it("legacy backend (no markdown_url on record) still falls back to record.url", () => { // A backend old enough to predate MUL-3192 omits markdown_url; the // fallback chain bottoms out on record.url, preserving render diff --git a/packages/views/editor/attachment.tsx b/packages/views/editor/attachment.tsx index c0126e035..2a2f084bc 100644 --- a/packages/views/editor/attachment.tsx +++ b/packages/views/editor/attachment.tsx @@ -23,6 +23,7 @@ * hints (selected, editable, onDelete). */ +import { useEffect, useState } from "react"; import { Download, Link as LinkIcon, @@ -35,6 +36,7 @@ import { copyText } from "@multica/ui/lib/clipboard"; import { api } from "@multica/core/api"; import type { Attachment as AttachmentRecord } from "@multica/core/types"; import { useT } from "../i18n"; +import { isDesktopShell } from "../platform"; import { useAttachmentDownloadResolver } from "./attachment-download-context"; import { useAttachmentPreview } from "./attachment-preview-modal"; import { useDownloadAttachment } from "./use-download-attachment"; @@ -379,6 +381,7 @@ function ImageAttachmentView({ className, }: ImageAttachmentViewProps) { const { t } = useT("editor"); + const renderedSrc = useAuthenticatedImageSrc(src); const handleCopyLink = async () => { if (await copyText(src)) { @@ -412,7 +415,7 @@ function ImageAttachmentView({ onClick={clickable ? onView : undefined} > {alt} ); } + +function useAuthenticatedImageSrc(src: string): string { + const [objectUrl, setObjectUrl] = useState(null); + + useEffect(() => { + let cancelled = false; + let currentObjectUrl: string | null = null; + setObjectUrl(null); + + if (!src) return undefined; + if (!isDesktopShell()) return undefined; + + api.fetchAttachmentDownloadBlob?.(src) + .then((blob) => { + if (cancelled) return; + currentObjectUrl = URL.createObjectURL(blob); + setObjectUrl(currentObjectUrl); + }) + .catch(() => { + // Non-internal/external URLs should keep using the original src. + }); + + return () => { + cancelled = true; + if (currentObjectUrl) URL.revokeObjectURL(currentObjectUrl); + }; + }, [src]); + + return objectUrl ?? src; +}