mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
fix(desktop): render API attachment images with auth
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user