mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
fix: re-sign inline attachment media
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -1,17 +1,19 @@
|
||||
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,
|
||||
getAttachmentMock,
|
||||
getBaseUrlMock,
|
||||
downloadMock,
|
||||
openExternalMock,
|
||||
openByUrlMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getAttachmentTextContentMock: vi.fn(),
|
||||
getAttachmentMock: 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 /
|
||||
@@ -25,6 +27,7 @@ const {
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
getAttachmentTextContent: getAttachmentTextContentMock,
|
||||
getAttachment: getAttachmentMock,
|
||||
getBaseUrl: getBaseUrlMock,
|
||||
},
|
||||
PreviewTooLargeError: class extends Error {},
|
||||
@@ -159,6 +162,9 @@ beforeEach(() => {
|
||||
// the web app's same-origin proxy. Tests that simulate Desktop / mobile
|
||||
// webview override per-case via getBaseUrlMock.mockReturnValue(...).
|
||||
getBaseUrlMock.mockReturnValue("");
|
||||
getAttachmentMock.mockImplementation(async (id: string) =>
|
||||
makeRecord({ id }),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -181,6 +187,7 @@ describe("Attachment — image dispatch", () => {
|
||||
expect(screen.getByTitle("View")).toBeTruthy();
|
||||
expect(screen.getByTitle("Download")).toBeTruthy();
|
||||
expect(screen.getByTitle("Copy link")).toBeTruthy();
|
||||
expect(getAttachmentMock).not.toHaveBeenCalled();
|
||||
// Trash only shows in editable mode.
|
||||
expect(screen.queryByTitle("Delete")).toBeNull();
|
||||
});
|
||||
@@ -226,7 +233,7 @@ describe("Attachment — image dispatch", () => {
|
||||
expect(downloadMock).toHaveBeenCalledWith("att-1");
|
||||
});
|
||||
|
||||
it("renders the configured CDN URL when description markdown stores the stable API URL", () => {
|
||||
it("does not choose raw CDN when the durable markdown URL is an attachment API endpoint", () => {
|
||||
configStore.setState({ cdnDomain: "cdn.example.test" });
|
||||
const id = "11111111-2222-3333-4444-555555555555";
|
||||
const markdownUrl = `https://multica-api.copilothub.ai/api/attachments/${id}/download`;
|
||||
@@ -254,22 +261,26 @@ describe("Attachment — image dispatch", () => {
|
||||
);
|
||||
|
||||
const img = document.querySelector("img");
|
||||
expect(img?.getAttribute("src")).toBe(
|
||||
"https://cdn.example.test/uploads/ws/shot.png",
|
||||
);
|
||||
expect(img?.getAttribute("src")).toBe(markdownUrl);
|
||||
expect(img?.getAttribute("src")).not.toBe(att.url);
|
||||
});
|
||||
|
||||
it("opens preview with the same resolved media URL when a reopened draft record has no download_url", () => {
|
||||
it("re-signs a reopened draft record that has no download_url instead of rendering raw CDN", async () => {
|
||||
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 mediaUrl = "https://cdn.example.test/uploads/ws/shot.png";
|
||||
const signedUrl = `${mediaUrl}?Policy=fresh&Signature=fresh&Key-Pair-Id=kp`;
|
||||
const att = makeRecord({
|
||||
id,
|
||||
url: mediaUrl,
|
||||
markdown_url: markdownUrl,
|
||||
download_url: "",
|
||||
});
|
||||
getAttachmentMock.mockResolvedValueOnce({
|
||||
...att,
|
||||
download_url: signedUrl,
|
||||
});
|
||||
resolverState.attachments = [att];
|
||||
|
||||
renderWithQuery(
|
||||
@@ -283,12 +294,54 @@ describe("Attachment — image dispatch", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const img = document.querySelector("img");
|
||||
expect(img?.getAttribute("src")).toBe(markdownUrl);
|
||||
expect(img?.getAttribute("src")).not.toBe(mediaUrl);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector("img")?.getAttribute("src")).toBe(signedUrl);
|
||||
});
|
||||
expect(getAttachmentMock).toHaveBeenCalledWith(id);
|
||||
});
|
||||
|
||||
it("opens preview with the same fresh signed URL after inline re-sign", async () => {
|
||||
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 mediaUrl = "https://cdn.example.test/uploads/ws/shot.png";
|
||||
const signedUrl = `${mediaUrl}?Policy=fresh&Signature=fresh&Key-Pair-Id=kp`;
|
||||
const att = makeRecord({
|
||||
id,
|
||||
url: mediaUrl,
|
||||
markdown_url: markdownUrl,
|
||||
download_url: "",
|
||||
});
|
||||
getAttachmentMock.mockResolvedValueOnce({
|
||||
...att,
|
||||
download_url: signedUrl,
|
||||
});
|
||||
resolverState.attachments = [att];
|
||||
|
||||
renderWithQuery(
|
||||
<Attachment
|
||||
attachment={{
|
||||
kind: "url",
|
||||
url: markdownUrl,
|
||||
filename: "shot.png",
|
||||
forceKind: "image",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector("img")?.getAttribute("src")).toBe(signedUrl);
|
||||
});
|
||||
fireEvent.click(screen.getByTitle("View"));
|
||||
|
||||
const imageSrcs = [...document.querySelectorAll("img")].map((img) =>
|
||||
img.getAttribute("src"),
|
||||
);
|
||||
expect(imageSrcs).toEqual([mediaUrl, mediaUrl]);
|
||||
expect(imageSrcs).toEqual([signedUrl, signedUrl]);
|
||||
expect(imageSrcs).not.toContain("");
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ 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 { attachmentIdFromDownloadURL } from "@multica/core/types/attachment-url";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useT } from "../i18n";
|
||||
import { useAttachmentDownloadResolver } from "./attachment-download-context";
|
||||
import { useAttachmentPreview } from "./attachment-preview-modal";
|
||||
@@ -243,18 +245,27 @@ function pickInlineMediaURL(
|
||||
cdnDomain: string,
|
||||
): string {
|
||||
const dl = record.download_url ?? "";
|
||||
if (
|
||||
/^https?:\/\//i.test(dl) &&
|
||||
/[?&](Signature|X-Amz-Signature|Key-Pair-Id|Expires|X-Amz-Expires)=/i.test(dl)
|
||||
) {
|
||||
if (hasSignedHTTPURL(dl)) {
|
||||
return dl;
|
||||
}
|
||||
if (storageURLMatchesCdnDomain(record.url, cdnDomain)) return record.url;
|
||||
if (
|
||||
storageURLMatchesCdnDomain(record.url, cdnDomain) &&
|
||||
!attachmentIdFromDownloadURL(record.markdown_url)
|
||||
) {
|
||||
return record.url;
|
||||
}
|
||||
if (record.markdown_url) return record.markdown_url;
|
||||
if (record.url) return record.url;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function hasSignedHTTPURL(rawURL: string): boolean {
|
||||
return (
|
||||
/^https?:\/\//i.test(rawURL) &&
|
||||
/[?&](Signature|X-Amz-Signature|Key-Pair-Id|Expires|X-Amz-Expires)=/i.test(rawURL)
|
||||
);
|
||||
}
|
||||
|
||||
function storageURLMatchesCdnDomain(rawURL: string, cdnDomain: string): boolean {
|
||||
const expected = normalizeHost(cdnDomain);
|
||||
if (!rawURL || !expected) return false;
|
||||
@@ -302,51 +313,70 @@ export function Attachment({
|
||||
const preview = useAttachmentPreview();
|
||||
|
||||
const state = normalize(attachment, resolveAttachment, cdnDomain);
|
||||
const shouldRefreshInlineMedia = Boolean(
|
||||
state.record?.id &&
|
||||
!hasSignedHTTPURL(state.record.download_url ?? "") &&
|
||||
attachmentIdFromDownloadURL(state.record.markdown_url || state.url),
|
||||
);
|
||||
const { data: refreshedRecord } = useQuery({
|
||||
queryKey: ["attachments", state.record?.id, "inline-media"],
|
||||
queryFn: () => api.getAttachment(state.record!.id),
|
||||
enabled: shouldRefreshInlineMedia,
|
||||
staleTime: 25 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
});
|
||||
const renderState = refreshedRecord
|
||||
? normalize(
|
||||
{ kind: "record", attachment: refreshedRecord },
|
||||
resolveAttachment,
|
||||
cdnDomain,
|
||||
)
|
||||
: state;
|
||||
const forceKind =
|
||||
attachment.kind === "url" ? attachment.forceKind : undefined;
|
||||
const kind =
|
||||
forceKind ??
|
||||
(state.filename || state.contentType
|
||||
? getPreviewKind(state.contentType, state.filename)
|
||||
(renderState.filename || renderState.contentType
|
||||
? getPreviewKind(renderState.contentType, renderState.filename)
|
||||
: null);
|
||||
|
||||
const openPreview = () => {
|
||||
if (state.record) {
|
||||
if (renderState.record) {
|
||||
preview.tryOpen({
|
||||
kind: "full",
|
||||
attachment: {
|
||||
...state.record,
|
||||
download_url: state.url || state.record.download_url,
|
||||
...renderState.record,
|
||||
download_url: renderState.url || renderState.record.download_url,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (state.url) {
|
||||
if (renderState.url) {
|
||||
preview.tryOpen({
|
||||
kind: "url",
|
||||
url: state.url,
|
||||
filename: state.filename,
|
||||
url: renderState.url,
|
||||
filename: renderState.filename,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (state.attachmentId) {
|
||||
download(state.attachmentId);
|
||||
if (renderState.attachmentId) {
|
||||
download(renderState.attachmentId);
|
||||
return;
|
||||
}
|
||||
if (state.url) openByUrl(state.url);
|
||||
if (renderState.url) openByUrl(renderState.url);
|
||||
};
|
||||
|
||||
if (kind === "image") {
|
||||
return (
|
||||
<>
|
||||
<ImageAttachmentView
|
||||
src={state.url}
|
||||
alt={state.filename}
|
||||
uploading={state.uploading}
|
||||
width={state.width}
|
||||
height={state.height}
|
||||
src={renderState.url}
|
||||
alt={renderState.filename}
|
||||
uploading={renderState.uploading}
|
||||
width={renderState.width}
|
||||
height={renderState.height}
|
||||
editable={editable}
|
||||
selected={selected}
|
||||
onView={openPreview}
|
||||
@@ -359,12 +389,12 @@ export function Attachment({
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "html" && state.attachmentId && !state.uploading) {
|
||||
if (kind === "html" && renderState.attachmentId && !renderState.uploading) {
|
||||
return (
|
||||
<>
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId={state.attachmentId}
|
||||
filename={state.filename}
|
||||
attachmentId={renderState.attachmentId}
|
||||
filename={renderState.filename}
|
||||
onPreview={openPreview}
|
||||
onDownload={handleDownload}
|
||||
onDelete={editable ? onDelete : undefined}
|
||||
@@ -377,11 +407,11 @@ export function Attachment({
|
||||
return (
|
||||
<>
|
||||
<AttachmentCard
|
||||
filename={state.filename}
|
||||
contentType={state.contentType}
|
||||
attachmentId={state.attachmentId}
|
||||
href={state.url || undefined}
|
||||
uploading={state.uploading}
|
||||
filename={renderState.filename}
|
||||
contentType={renderState.contentType}
|
||||
attachmentId={renderState.attachmentId}
|
||||
href={renderState.url || undefined}
|
||||
uploading={renderState.uploading}
|
||||
onPreview={openPreview}
|
||||
onDownload={handleDownload}
|
||||
onDelete={editable ? onDelete : undefined}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
||||
WorkspaceCreationDisabled: os.Getenv("DISABLE_WORKSPACE_CREATION") == "true",
|
||||
}
|
||||
if h.Storage != nil {
|
||||
if h.Storage != nil && h.CFSigner == nil {
|
||||
config.CdnDomain = h.Storage.CdnDomain()
|
||||
}
|
||||
config.DaemonServerURL, config.DaemonAppURL = daemonSetupURLsFromEnv()
|
||||
|
||||
@@ -61,6 +61,33 @@ func TestGetConfigIncludesRuntimeAuthConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigOmitsCdnDomainInCloudFrontSignedMode(t *testing.T) {
|
||||
origStorage := testHandler.Storage
|
||||
origSigner := testHandler.CFSigner
|
||||
testHandler.Storage = &mockStorage{}
|
||||
testHandler.CFSigner = testCloudFrontSigner(t)
|
||||
defer func() {
|
||||
testHandler.Storage = origStorage
|
||||
testHandler.CFSigner = origSigner
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
testHandler.GetConfig(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetConfig: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var cfg AppConfig
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode config: %v", err)
|
||||
}
|
||||
if cfg.CdnDomain != "" {
|
||||
t.Fatalf("cdn_domain: want empty in CloudFront signed mode, got %q", cfg.CdnDomain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigUsesAppURLForSameOriginDaemonSetup(t *testing.T) {
|
||||
t.Setenv("MULTICA_APP_URL", "https://multica.internal.example/")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user