Compare commits

...

1 Commits

Author SHA1 Message Date
yushen
34d838d1dc fix(attachments): render description images with CDN URL 2026-06-10 17:19:39 +08:00
2 changed files with 107 additions and 9 deletions

View File

@@ -89,12 +89,31 @@ vi.mock("@multica/core/paths", async (importOriginal) => {
// Resolver mock — feeds the test-scoped attachments[] into the
// useAttachmentDownloadResolver hook the component reads.
const resolverState: { attachments: AttachmentRecord[] } = { attachments: [] };
function attachmentIdFromTestDownloadURL(url: string): string | undefined {
const path = /^https?:\/\//i.test(url)
? (() => {
try {
return new URL(url).pathname;
} catch {
return "";
}
})()
: url.split(/[?#]/, 1)[0] ?? "";
const match = path.match(/^\/api\/attachments\/([^/]+)\/download$/);
return match?.[1];
}
vi.mock("./attachment-download-context", () => ({
useAttachmentDownloadResolver: () => ({
resolveAttachmentId: (url: string) =>
resolverState.attachments.find((a) => a.url === url)?.id,
resolverState.attachments.find((a) => {
const id = attachmentIdFromTestDownloadURL(url);
return a.url === url || (id !== undefined && a.id === id);
})?.id,
resolveAttachment: (url: string) =>
resolverState.attachments.find((a) => a.url === url),
resolverState.attachments.find((a) => {
const id = attachmentIdFromTestDownloadURL(url);
return a.url === url || (id !== undefined && a.id === id);
}),
openByUrl: openByUrlMock,
}),
AttachmentDownloadProvider: ({ children }: { children: ReactNode }) =>
@@ -102,6 +121,7 @@ vi.mock("./attachment-download-context", () => ({
}));
import { Attachment } from "./attachment";
import { configStore } from "@multica/core/config";
function makeRecord(overrides: Partial<AttachmentRecord> = {}): AttachmentRecord {
return {
@@ -134,6 +154,7 @@ function renderWithQuery(ui: ReactElement) {
beforeEach(() => {
vi.clearAllMocks();
resolverState.attachments = [];
configStore.setState({ cdnDomain: "" });
// Default to "no proxy override" — site-relative URLs stay as-is, mirroring
// the web app's same-origin proxy. Tests that simulate Desktop / mobile
// webview override per-case via getBaseUrlMock.mockReturnValue(...).
@@ -205,6 +226,39 @@ describe("Attachment — image dispatch", () => {
expect(downloadMock).toHaveBeenCalledWith("att-1");
});
it("renders the configured CDN URL when description markdown stores the stable API URL", () => {
configStore.setState({ cdnDomain: "cdn.example.test" });
const id = "11111111-2222-3333-4444-555555555555";
const markdownUrl = `https://multica-api.copilothub.ai/api/attachments/${id}/download`;
const att = makeRecord({
id,
url: "https://cdn.example.test/uploads/ws/shot.png",
// This is the shape persisted in issue descriptions on deployments
// that keep markdown stable via the API endpoint. Once the URL
// resolves to an attachment record, the rendered <img> must expose the
// CDN URL instead of copying the API endpoint back to the user.
markdown_url: markdownUrl,
download_url: `/api/attachments/${id}/download`,
});
resolverState.attachments = [att];
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: markdownUrl,
filename: "shot.png",
forceKind: "image",
}}
/>,
);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"https://cdn.example.test/uploads/ws/shot.png",
);
});
it("forceKind=image renders as image even when filename is empty (markdown ![](url) regression)", () => {
renderWithQuery(
<Attachment

View File

@@ -33,6 +33,7 @@ import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { api } from "@multica/core/api";
import { useConfigStore } from "@multica/core/config";
import type { Attachment as AttachmentRecord } from "@multica/core/types";
import { useT } from "../i18n";
import { useAttachmentDownloadResolver } from "./attachment-download-context";
@@ -106,13 +107,14 @@ interface Normalized {
function normalize(
input: AttachmentInput,
resolve: (url: string) => AttachmentRecord | undefined,
cdnDomain: string,
): Normalized {
if (input.kind === "record") {
return {
filename: input.attachment.filename,
contentType: input.attachment.content_type,
url: absolutizeMediaURL(
pickInlineMediaURL(input.attachment, input.attachment.url),
pickInlineMediaURL(input.attachment, input.attachment.url, cdnDomain),
),
attachmentId: input.attachment.id,
record: input.attachment,
@@ -145,7 +147,7 @@ function normalize(
// uploaded image URL stayed site-relative and Electron's renderer
// origin (file://) couldn't load it.
url: absolutizeMediaURL(
record ? pickInlineMediaURL(record, input.url) : input.url,
record ? pickInlineMediaURL(record, input.url, cdnDomain) : input.url,
),
attachmentId: record?.id,
record,
@@ -223,13 +225,23 @@ function absolutizeMediaURL(rawUrl: string): string {
// beats `markdown_url` on first paint (no extra hop through the
// API endpoint), and the renderer doesn't persist it so the TTL is
// not a problem.
// 2. `record.markdown_url` — the durable, server-policy-aligned URL.
// 2. Known CDN `record.url` — when `/api/config` exposes the same CDN
// host as the attachment record, the browser can load the object
// directly (public CDN, or CloudFront cookie mode). Prefer it over
// an API-shaped `markdown_url` so the rendered `<img src>` and Copy
// Link affordance expose the CDN URL while the persisted markdown
// can remain the stable attachment endpoint.
// 3. `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).
// 3. `record.url` — legacy fallback for responses that omit
// 4. `record.url` — legacy fallback for responses that omit
// `markdown_url` (a backend old enough to predate MUL-3192).
// 4. The input URL — when there's no record at all.
function pickInlineMediaURL(record: AttachmentRecord, fallback: string): string {
// 5. The input URL — when there's no record at all.
function pickInlineMediaURL(
record: AttachmentRecord,
fallback: string,
cdnDomain: string,
): string {
const dl = record.download_url ?? "";
if (
/^https?:\/\//i.test(dl) &&
@@ -237,11 +249,42 @@ function pickInlineMediaURL(record: AttachmentRecord, fallback: string): string
) {
return dl;
}
if (storageURLMatchesCdnDomain(record.url, cdnDomain)) return record.url;
if (record.markdown_url) return record.markdown_url;
if (record.url) return record.url;
return fallback;
}
function storageURLMatchesCdnDomain(rawURL: string, cdnDomain: string): boolean {
const expected = normalizeHost(cdnDomain);
if (!rawURL || !expected) return false;
try {
const u = new URL(rawURL);
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
if (normalizeHost(u.hostname) !== expected) return false;
return !hasExpiringSignatureQuery(u.searchParams);
} catch {
return false;
}
}
function normalizeHost(host: string): string {
return host.trim().toLowerCase().replace(/\.$/, "");
}
function hasExpiringSignatureQuery(q: URLSearchParams): boolean {
for (const key of [
"Signature",
"X-Amz-Signature",
"Key-Pair-Id",
"Expires",
"X-Amz-Expires",
]) {
if (q.has(key)) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Dispatcher
// ---------------------------------------------------------------------------
@@ -254,10 +297,11 @@ export function Attachment({
className,
}: AttachmentProps) {
const { resolveAttachment, openByUrl } = useAttachmentDownloadResolver();
const cdnDomain = useConfigStore((s) => s.cdnDomain);
const download = useDownloadAttachment();
const preview = useAttachmentPreview();
const state = normalize(attachment, resolveAttachment);
const state = normalize(attachment, resolveAttachment, cdnDomain);
const forceKind =
attachment.kind === "url" ? attachment.forceKind : undefined;
const kind =