fix(desktop): render API attachment images with auth

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Eve
2026-06-10 16:23:45 +08:00
parent abf99eb700
commit cb46184039
4 changed files with 167 additions and 2 deletions

View File

@@ -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",

View File

@@ -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<Blob> {
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;
}

View File

@@ -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(
<Attachment
attachment={{
kind: "url",
url: rawSrc,
filename: "shot.png",
forceKind: "image",
}}
/>,
);
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

View File

@@ -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}
>
<img
src={src || undefined}
src={renderedSrc || undefined}
alt={alt}
width={width}
height={height}
@@ -445,3 +448,33 @@ function ImageAttachmentView({
</span>
);
}
function useAuthenticatedImageSrc(src: string): string {
const [objectUrl, setObjectUrl] = useState<string | null>(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;
}