fix(attachments): server-driven markdown_url + legacy compat (MUL-3192) (#3991)

Comment / issue / chat images uploaded inside the Desktop app rendered
as the broken-image fallback. The editor was persisting a site-relative
`/api/attachments/<id>/download` URL into markdown — that path only
resolves when the document origin proxies /api to the API host (apps/web
via Next.js rewrite). On Electron's file:// origin it never resolved.

Per GPT-Boy's plan, move the durable-URL choice from the client to the
server so the persisted shape is correct regardless of which client
performed the upload.

Server:
- AttachmentResponse gains a markdown_url field, computed by
  buildMarkdownURL from the deployment policy:
  • storage URL is already absolute + unsigned (public CDN, S3 public
    bucket, LocalStorage with MULTICA_LOCAL_UPLOAD_BASE_URL on https) →
    use it verbatim;
  • CloudFront-signed mode → never expose the raw S3 URL (private
    bucket); return cfg.PublicURL + /api/attachments/<id>/download so
    the server can re-sign on every request;
  • LocalStorage relative + cfg.PublicURL set → same prefixed API
    endpoint;
  • cfg.PublicURL unset → fall back to site-relative path so web's
    Next.js rewrite still works.
- isDurablePublicURL helper rejects URLs carrying CloudFront / S3
  signature query params, so a freshly-signed download_url can never
  leak into persistence — the original MUL-3130 bug stays closed.

Frontend:
- Attachment type + AttachmentResponseSchema (and apps/mobile mirror)
  carry markdown_url. Schema lenient-defaults to '' so a backend old
  enough to predate this field doesn't break clients.
- useFileUpload picks markdownLink with three-layer fallback:
  (1) att.markdown_url (modern server),
  (2) attachmentDownloadPath(att.id) — legacy site-relative shape,
      retained for backends old enough to omit markdown_url,
  (3) att.url — no-workspace avatar branch with no attachment-row id.
- attachment.tsx keeps the relative→absolute absolutize pass, but
  reframed as the legacy-compat fallback for already-persisted
  /api/attachments/<id>/download or /uploads/<key> URLs in old
  bodies. New content writes absolute URLs and skips this path.
- ContentEditor still tracks freshly-uploaded records into
  AttachmentDownloadProvider so Quick Create's editor can swap the URL
  via the resolver during the same session even before the server-side
  binding lands.

Tests:
- server/internal/handler/file_test.go: 5 new buildMarkdownURL matrix
  tests (public CDN passthrough, CloudFront-signed swap, relative
  prefixing, PublicURL unset fallback, trailing-slash strip) + 15
  table-driven isDurablePublicURL cases.
- packages/core/hooks/use-file-upload.test.ts: new file, 4 cases
  covering modern server / legacy server / no-id avatar / oversize.
- packages/views/editor/attachment.test.tsx + content-editor.test.tsx:
  10 cases for the absolutize matrix and in-session attachment merge.
- 6 existing test fixtures updated to include markdown_url.

Verification: 1236 @multica/views tests pass; 514 @multica/core tests
pass (4 new); server handler package tests pass for the new matrix
plus all pre-existing TestAttachmentToResponse* and TestDownload*
cases. Typecheck green for views/core/web/desktop. Lint clean on
touched files.

Quick Create attachment_ids binding (orphaned attachment relationship
on the resulting issue) is a follow-up — it requires a new --attachment-id
CLI flag and daemon prompt-template work and is intentionally scoped
out of this PR.

Refs: MUL-3192

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Multica Eve
2026-06-10 16:00:40 +08:00
committed by GitHub
parent 9455310c0c
commit abf99eb700
16 changed files with 1042 additions and 74 deletions

View File

@@ -58,6 +58,7 @@ export const AttachmentSchema: z.ZodType<Attachment> = z.object({
filename: z.string(),
url: z.string(),
download_url: z.string().default(""),
markdown_url: z.string().default(""),
content_type: z.string().default(""),
size_bytes: z.number().default(0),
created_at: z.string().default(""),

View File

@@ -84,10 +84,18 @@ const AttachmentSchema = z.object({
// in a new tab — `download_url` and `url` — must be strings, otherwise we'd
// happily `window.open(undefined)`. `filename` gates the toast/title and is
// also enforced so a missing value falls back to the empty record below.
//
// `markdown_url` is parsed lenient: a server old enough to predate
// MUL-3192 omits the field, in which case the schema defaults it to "".
// Callers that need to persist a URL into markdown should go through the
// `useFileUpload` helper (which falls back to the legacy
// `attachmentDownloadPath` shape when `markdown_url` is empty), so the
// empty-string default does not silently break any persistence path.
export const AttachmentResponseSchema = z.object({
id: z.string(),
url: z.string(),
download_url: z.string(),
markdown_url: z.string().optional().default(""),
filename: z.string(),
chat_session_id: z.string().nullable().optional(),
chat_message_id: z.string().nullable().optional(),
@@ -105,6 +113,7 @@ export const EMPTY_ATTACHMENT: Attachment = {
filename: "",
url: "",
download_url: "",
markdown_url: "",
content_type: "",
size_bytes: 0,
created_at: "",

View File

@@ -0,0 +1,102 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { ApiClient } from "../api/client";
import type { Attachment } from "../types";
import { useFileUpload, type UploadResult } from "./use-file-upload";
// MUL-3192 — verifies that the URL chosen for markdown persistence is
// a durable, server-supplied absolute URL when available, and falls
// through to the legacy site-relative shape only when the server didn't
// populate `markdown_url` (older deployments) or when there's no
// attachment-row id at all (no-workspace avatar branch).
function makeAttachment(overrides: Partial<Attachment> = {}): Attachment {
return {
id: "att-1",
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "u-1",
filename: "shot.png",
url: "/uploads/ws-1/shot.png",
download_url: "/api/attachments/att-1/download",
markdown_url: "https://api.multica.test/api/attachments/att-1/download",
content_type: "image/png",
size_bytes: 1,
created_at: "2026-06-10T00:00:00Z",
...overrides,
};
}
function makeApi(att: Attachment): ApiClient {
return {
uploadFile: vi.fn().mockResolvedValue(att),
} as unknown as ApiClient;
}
async function runUpload(api: ApiClient): Promise<UploadResult | null> {
const { result } = renderHook(() => useFileUpload(api));
let upload: UploadResult | null = null;
await act(async () => {
upload = await result.current.upload(
new File(["data"], "shot.png", { type: "image/png" }),
);
});
return upload;
}
describe("useFileUpload — markdownLink picks the durable URL with three-layer fallback", () => {
it("prefers att.markdown_url when the server populates it (modern deployment)", async () => {
const att = makeAttachment({
markdown_url: "https://cdn.multica.test/uploads/abc.png",
});
const upload = await runUpload(makeApi(att));
expect(upload?.markdownLink).toBe("https://cdn.multica.test/uploads/abc.png");
// `link` keeps its legacy semantics — same as att.url, used by avatar /
// logo callers that persist into long-lived fields.
expect(upload?.link).toBe(att.url);
});
it("falls back to the site-relative download path when the server omitted markdown_url (legacy server)", async () => {
const att = makeAttachment({ markdown_url: "" });
const upload = await runUpload(makeApi(att));
// On web this is fine — Next.js rewrite proxies /api/* to the API
// host server-side. Non-web surfaces hit attachment.tsx's legacy
// absolutize fallback at render time.
expect(upload?.markdownLink).toBe("/api/attachments/att-1/download");
});
it("falls back to att.url when there's no attachment-row id (no-workspace avatar branch)", async () => {
const att = makeAttachment({
id: "",
markdown_url: "",
url: "https://cdn.multica.test/avatars/u-1.png",
});
const upload = await runUpload(makeApi(att));
expect(upload?.markdownLink).toBe("https://cdn.multica.test/avatars/u-1.png");
expect(upload?.link).toBe("https://cdn.multica.test/avatars/u-1.png");
});
it("rejects oversize files before hitting the network", async () => {
const att = makeAttachment();
const api = makeApi(att);
const huge = new File([new ArrayBuffer(1)], "big.bin", {
type: "application/octet-stream",
});
Object.defineProperty(huge, "size", { value: 200 * 1024 * 1024 });
const { result } = renderHook(() => useFileUpload(api));
await expect(
act(async () => {
await result.current.upload(huge);
}),
).rejects.toThrow(/100 MB/);
expect(api.uploadFile as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
});
});

View File

@@ -22,22 +22,33 @@ import { MAX_FILE_SIZE } from "../constants/upload";
// so avatar uploads do not get rerouted through
// the workspace-membership-gated download endpoint.
//
// `markdownLink` — the stable per-attachment URL
// `/api/attachments/<id>/download`. Safe to embed
// in markdown bodies that outlive the current
// session because the server self-resolves the
// workspace from the attachment row and re-signs /
// proxies on every request. Empty when the upload
// has no attachment-row id (the no-workspace
// avatar branch of UploadFile, which falls back
// to `link`). Only the editor's markdown-persisting
// flow should use it; everything else uses `link`.
// `markdownLink` — the URL the editor writes into markdown bodies.
// Source: `att.markdown_url` from the server, which
// `buildMarkdownURL` picks per deployment policy
// (public CDN durable URL, or
// `<MULTICA_PUBLIC_URL>/api/attachments/<id>/download`).
// The contract is "absolute, no TTL, loads natively
// on every client" — that's what fixes the Desktop /
// mobile-webview regression where a site-relative
// `/api/attachments/<id>/download` couldn't resolve
// against `file://` (MUL-3192).
//
// Falls back through two layers when the server
// didn't populate `markdown_url`:
// 1. `attachmentDownloadPath(att.id)` — the legacy
// site-relative shape. Works on web (Next
// rewrite proxies /api/* to the API host) and
// is what older comments persist; render
// surfaces handle the absolutize for non-web
// clients via attachment.tsx's legacy compat.
// 2. `att.url` — the no-workspace avatar branch
// where there's no attachment-row id at all.
//
// MUL-3130 introduced the persisted-image regression by collapsing
// these two semantics into a single `link` field. Keeping them
// separate is what lets new comments embed a stable URL while
// avatar / logo callers continue to write `att.url` into long-lived
// fields without picking up workspace-membership gating.
// these two semantics into a single `link` field; MUL-3192 followed up
// by moving the durable-URL choice from the client to the server so
// the persisted shape is correct for the deployment without the client
// having to know whether it's running on web / desktop / mobile.
export type UploadResult = Attachment & {
link: string;
markdownLink: string;
@@ -49,6 +60,26 @@ export interface UploadContext {
chatSessionId?: string;
}
// pickMarkdownLink chooses the URL the editor will write into markdown.
//
// Order:
// 1. `att.markdown_url` — server-provided durable URL. This is the
// modern contract introduced in MUL-3192; the server (`buildMarkdownURL`)
// decides whether to emit a public CDN URL or an absolute API
// endpoint pinned to `MULTICA_PUBLIC_URL` based on the deployment.
// 2. `attachmentDownloadPath(att.id)` — site-relative legacy shape,
// retained for compatibility with backends old enough to predate
// MUL-3192. Web's Next rewrite makes this load; desktop / mobile
// surfaces hit the attachment.tsx legacy-absolutize fallback.
// 3. `att.url` — no attachment-row id (the no-workspace avatar branch
// of UploadFile). Markdown callers fall back to whatever storage
// backend produced for the upload; persistence is on the caller.
function pickMarkdownLink(att: Attachment): string {
if (att.markdown_url) return att.markdown_url;
if (att.id) return attachmentDownloadPath(att.id);
return att.url;
}
export function useFileUpload(
api: ApiClient,
onError?: (error: Error) => void,
@@ -68,14 +99,7 @@ export function useFileUpload(
commentId: ctx?.commentId,
chatSessionId: ctx?.chatSessionId,
});
// Avatar / no-workspace uploads come back without an
// attachment-row id (UploadFile's no-workspace branch returns
// {id, url, filename} that fails the AttachmentResponseSchema
// and degrades to the empty record). In that case the stable
// markdown URL doesn't exist — markdown callers fall back to
// `link` which mirrors `att.url`.
const markdownLink = att.id ? attachmentDownloadPath(att.id) : att.url;
return { ...att, link: att.url, markdownLink };
return { ...att, link: att.url, markdownLink: pickMarkdownLink(att) };
} finally {
setUploading(false);
}

View File

@@ -320,6 +320,7 @@ describe("onIssueDeleted", () => {
filename: "evidence.png",
url: "s3://bucket/evidence.png",
download_url: "https://example.test/evidence.png",
markdown_url: "https://example.test/api/attachments/att-1/download",
content_type: "image/png",
size_bytes: 1,
created_at: "2025-01-01T00:00:00Z",

View File

@@ -10,6 +10,31 @@ export interface Attachment {
filename: string;
url: string;
download_url: string;
/**
* Durable URL the client persists into markdown bodies.
*
* The server (`buildMarkdownURL` in server/internal/handler/file.go)
* computes this per deployment policy:
* - public CDN path when storage URL is itself absolute and unsigned;
* - otherwise `<MULTICA_PUBLIC_URL>/api/attachments/<id>/download`,
* which the server self-resigns / proxies on every request.
*
* Distinct from `url` (raw storage URL — may be private / site-relative)
* and `download_url` (this-response click-time URL — may be a short-lived
* CloudFront / S3 signed URL with a TTL). `markdown_url` is contracted
* to be safe to embed in markdown bodies that outlive the current
* session and to load as a native browser resource fetch on every
* supported client (web / desktop / mobile webview). MUL-3192.
*
* Empty when the response was produced by a server old enough to
* predate this field, or by an upload path that did not produce a
* persisted attachment row (e.g. the no-workspace avatar branch).
* Frontend callers that need to embed a URL into markdown should use
* the helper in `useFileUpload` rather than reading this field
* directly so the legacy fallbacks (download path / `att.url`) stay
* centralized.
*/
markdown_url: string;
content_type: string;
size_bytes: number;
created_at: string;

View File

@@ -17,6 +17,7 @@ function makeUpload(overrides: Partial<UploadResult> & { id: string; link: strin
uploader_id: "user-1",
url: overrides.link,
download_url: overrides.link,
markdown_url: overrides.link,
content_type: "image/png",
size_bytes: 1,
created_at: new Date(0).toISOString(),

View File

@@ -143,6 +143,7 @@ function makeAttachment(overrides: Partial<Attachment> = {}): Attachment {
filename: "test.bin",
url: "https://cdn.example.test/att-1.bin",
download_url: "https://cdn.example.test/att-1.bin?Signature=s",
markdown_url: "https://cdn.example.test/api/attachments/att-1/download",
content_type: "application/octet-stream",
size_bytes: 0,
created_at: "2026-05-13T00:00:00Z",

View File

@@ -6,18 +6,27 @@ import type { Attachment as AttachmentRecord } from "@multica/core/types";
const {
getAttachmentTextContentMock,
getBaseUrlMock,
downloadMock,
openExternalMock,
openByUrlMock,
} = vi.hoisted(() => ({
getAttachmentTextContentMock: 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 /
// mobile webview, where the renderer's origin does NOT proxy /api.
getBaseUrlMock: vi.fn(() => ""),
downloadMock: vi.fn(),
openExternalMock: vi.fn(),
openByUrlMock: vi.fn(),
}));
vi.mock("@multica/core/api", () => ({
api: { getAttachmentTextContent: getAttachmentTextContentMock },
api: {
getAttachmentTextContent: getAttachmentTextContentMock,
getBaseUrl: getBaseUrlMock,
},
PreviewTooLargeError: class extends Error {},
PreviewUnsupportedError: class extends Error {},
}));
@@ -107,6 +116,7 @@ function makeRecord(overrides: Partial<AttachmentRecord> = {}): AttachmentRecord
filename: "shot.png",
url: "https://cdn.example.test/att-1.png",
download_url: "https://cdn.example.test/att-1.png?Signature=s",
markdown_url: "https://cdn.example.test/api/attachments/att-1/download",
content_type: "image/png",
size_bytes: 1024,
created_at: "2026-05-13T00:00:00Z",
@@ -124,6 +134,10 @@ function renderWithQuery(ui: ReactElement) {
beforeEach(() => {
vi.clearAllMocks();
resolverState.attachments = [];
// 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(...).
getBaseUrlMock.mockReturnValue("");
});
afterEach(() => {
@@ -240,28 +254,41 @@ describe("Attachment — image dispatch", () => {
expect(screen.queryByTitle("Download")).toBeNull();
});
it("stable /api/attachments/<id>/download download_url falls through to the freshly-signed record.url for token-mode loadability (MUL-3130)", () => {
// Pinned behavior: when an attachment record carries the default
// proxy-mode download_url (a bare /api/attachments/<id>/download
// path, which does NOT load as a native <img>/<video> src in
// token-mode clients because they can't attach Authorization
// headers), the renderer must fall through to record.url. On the
// LocalStorage backend record.url carries a freshly-minted
// /uploads/<key>?exp&sig query whose signature IS the auth, so
// the image loads regardless of how the client is authenticated.
// pickInlineMediaURL implements this fallback (attachment.tsx).
const signedStorageURL =
"/uploads/workspaces/ws-1/freshly-signed.png?exp=99&sig=fresh";
it("stable /api/attachments/<id>/download download_url falls through to the durable record.markdown_url for native <img> loadability (MUL-3192)", () => {
// After MUL-3192, pickInlineMediaURL prefers `record.markdown_url`
// (the server-chosen durable URL — public CDN passthrough or absolute
// API endpoint) over the raw `record.url` (which can be a private
// bucket URL on S3 / R2 / MinIO presign deployments). The signed
// download_url stays the highest-priority pick when present, but the
// unsigned `/api/attachments/<id>/download` shape now defers to
// markdown_url instead of record.url.
const att = makeRecord({
url: signedStorageURL,
// bare API path — no signature query, so pickInlineMediaURL
// skips download_url and uses record.url.
// Raw private-bucket URL — must NOT be the rendered src.
url: "https://prod.s3.amazonaws.com/key.png",
markdown_url: "https://api.multica.test/api/attachments/att-1/download",
// bare API path on download_url — no signature query.
download_url: "/api/attachments/att-1/download",
});
renderWithQuery(<Attachment attachment={{ kind: "record", attachment: att }} />);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe(signedStorageURL);
expect(img?.getAttribute("src")).not.toContain("/api/attachments/");
expect(img?.getAttribute("src")).toBe(
"https://api.multica.test/api/attachments/att-1/download",
);
expect(img?.getAttribute("src")).not.toContain("prod.s3.amazonaws.com");
});
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
// behaviour for legacy attachment metadata in the cache.
const att = makeRecord({
url: "https://cdn.example.test/legacy.png",
markdown_url: "",
download_url: "/api/attachments/att-1/download",
});
renderWithQuery(<Attachment attachment={{ kind: "record", attachment: att }} />);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe("https://cdn.example.test/legacy.png");
});
});
@@ -330,3 +357,120 @@ describe("Attachment — file-card dispatch", () => {
expect(screen.queryByTitle("Download")).toBeNull();
});
});
// MUL-3192 — Desktop quick-create: site-relative `/api/attachments/<id>/
// download` and `/uploads/<key>` URLs only resolve in environments where the
// document origin proxies them to the API host (web via Next.js rewrites).
// In Electron desktop the renderer origin is `file://`, so the same path
// can't load. The Attachment renderer runs the picked URL through an
// absolutize pass that prefixes `apiBaseUrl` when one is configured.
describe("Attachment — absolutize site-relative URLs (MUL-3192)", () => {
it("prefixes site-relative /api/attachments/<id>/download with apiBaseUrl in Desktop-like environments", () => {
getBaseUrlMock.mockReturnValue("https://api.example.test");
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: "/api/attachments/abc-1/download",
filename: "shot.png",
forceKind: "image",
}}
/>,
);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"https://api.example.test/api/attachments/abc-1/download",
);
});
it("absolutizes the legacy site-relative /uploads/<key> when record.markdown_url is empty (legacy backend)", () => {
// Legacy compat: a backend old enough to predate MUL-3192 omits
// markdown_url, so pickInlineMediaURL falls through to record.url.
// For LocalStorage with no LOCAL_UPLOAD_BASE_URL configured that's
// a site-relative `/uploads/<key>` — the absolutize pass at the
// renderer's edge prefixes the apiBaseUrl so Desktop's file:// origin
// doesn't resolve it to file:///uploads/...
getBaseUrlMock.mockReturnValue("https://api.example.test");
const att = makeRecord({
url: "/uploads/ws-1/abc.png",
markdown_url: "",
download_url: "/api/attachments/att-1/download",
});
renderWithQuery(<Attachment attachment={{ kind: "record", attachment: att }} />);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"https://api.example.test/uploads/ws-1/abc.png",
);
});
it("strips a trailing slash on apiBaseUrl so the prefixed URL has exactly one separator", () => {
getBaseUrlMock.mockReturnValue("https://api.example.test/");
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: "/api/attachments/abc-2/download",
filename: "shot.png",
forceKind: "image",
}}
/>,
);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"https://api.example.test/api/attachments/abc-2/download",
);
});
it("leaves absolute https URLs untouched even when apiBaseUrl is set", () => {
getBaseUrlMock.mockReturnValue("https://api.example.test");
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: "https://cdn.other.test/foo.png",
filename: "foo.png",
forceKind: "image",
}}
/>,
);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe("https://cdn.other.test/foo.png");
});
it("leaves blob: URLs untouched (in-flight upload preview)", () => {
getBaseUrlMock.mockReturnValue("https://api.example.test");
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: "blob:https://app.local/abc-123",
filename: "in-flight.png",
forceKind: "image",
// uploading=true would hide the toolbar, but the src normalization
// path runs regardless — keep it false so we still mount the <img>.
}}
/>,
);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe("blob:https://app.local/abc-123");
});
it("is a no-op when apiBaseUrl is empty (web app same-origin proxy)", () => {
getBaseUrlMock.mockReturnValue("");
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: "/api/attachments/abc-3/download",
filename: "shot.png",
forceKind: "image",
}}
/>,
);
const img = document.querySelector("img");
// Persisted markdown URL stays site-relative — Next.js rewrite proxies
// /api/* to the API host, so the relative path loads through the same
// origin as the rendered HTML.
expect(img?.getAttribute("src")).toBe("/api/attachments/abc-3/download");
});
});

View File

@@ -32,6 +32,7 @@ import {
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 type { Attachment as AttachmentRecord } from "@multica/core/types";
import { useT } from "../i18n";
import { useAttachmentDownloadResolver } from "./attachment-download-context";
@@ -110,7 +111,9 @@ function normalize(
return {
filename: input.attachment.filename,
contentType: input.attachment.content_type,
url: pickInlineMediaURL(input.attachment, input.attachment.url),
url: absolutizeMediaURL(
pickInlineMediaURL(input.attachment, input.attachment.url),
),
attachmentId: input.attachment.id,
record: input.attachment,
uploading: false,
@@ -131,7 +134,19 @@ function normalize(
// picks the URL with embedded credentials when one exists and
// falls back to the input URL otherwise so legacy / unresolved
// markdown stays on its existing path. See MUL-3130 review.
url: record ? pickInlineMediaURL(record, input.url) : input.url,
//
// After picking the credential-bearing URL we run the absolutize
// pass so a site-relative `/api/attachments/...` or `/uploads/...`
// path becomes a proper origin-bearing URL when the renderer's
// document origin doesn't proxy /api or /uploads to the API host
// (Electron desktop, mobile webview). Web with a same-origin
// proxy keeps `apiBaseUrl=""` and the helper is a no-op there.
// See MUL-3192 — quick-create modal regressed because the freshly-
// 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,
),
attachmentId: record?.id,
record,
uploading: !!input.uploading,
@@ -140,36 +155,80 @@ function normalize(
};
}
// absolutizeMediaURL is the legacy-compat fallback for old markdown bodies
// that persisted a site-relative `/api/attachments/<id>/download` or
// `/uploads/<key>` URL.
//
// The current (post-MUL-3192) write path persists an absolute URL chosen
// server-side by `buildMarkdownURL` (see server/internal/handler/file.go),
// so new content already loads natively on every client. This helper only
// matters for content written BEFORE MUL-3192 — those bodies still carry
// the old relative shape, and rendering them on a surface whose document
// origin is NOT the API host (Electron desktop, mobile webview) needs the
// API base URL pinned in at render time.
//
// On web, `api.getBaseUrl()` is empty (the Next.js rewrite proxies /api/*
// to the API host server-side), so this is a no-op there.
//
// http(s)://, blob:, and data: URLs are passed through unchanged — they
// already carry their own origin.
function absolutizeMediaURL(rawUrl: string): string {
if (!rawUrl) return rawUrl;
if (/^https?:\/\//i.test(rawUrl)) return rawUrl;
if (/^blob:/i.test(rawUrl) || /^data:/i.test(rawUrl)) return rawUrl;
if (!rawUrl.startsWith("/")) return rawUrl;
// The api singleton is a Proxy that returns `undefined` for any property
// access before `setApiInstance()` runs (boot ordering, SSR). Optional
// chaining lets us cope with that without throwing — pre-init renders
// simply keep the site-relative path.
const baseUrl = (api.getBaseUrl?.() ?? "").replace(/\/+$/, "");
if (!baseUrl) return rawUrl;
return `${baseUrl}${rawUrl}`;
}
// pickInlineMediaURL returns the URL most likely to load successfully
// inside a native <img>/<video>/<iframe> resource fetch — i.e. without
// the calling client attaching an Authorization header.
//
// The metadata response from the backend offers two URL fields per
// attachment row:
// The metadata response carries three URL fields per attachment row,
// each with a different lifetime / accessibility:
//
// - `record.url` — for LocalStorage this is a freshly-signed
// `/uploads/<key>?exp=<unix>&sig=<HMAC>`
// URL whose query string IS the auth (works
// for token-mode <img> loads). For S3/
// CloudFront it's the raw stored URL with
// no signature.
// - `record.download_url` — `/api/attachments/<id>/download` in the
// default proxy/presign mode (requires
// cookie or Authorization header — does
// NOT work as a native resource load for
// token-mode clients). In CloudFront mode
// this is replaced server-side with a
// CloudFront-signed URL that DOES work as
// a native <img> src.
// - `record.download_url` — this-response click-time URL. In
// CloudFront-signed mode this is the
// signed redirect (works as a native img
// src for the duration of the TTL); in
// other modes it's the bare API endpoint
// (`/api/attachments/<id>/download`) which
// requires per-request auth and does NOT
// load as a native img on a non-same-site
// origin like Desktop's file://.
// - `record.markdown_url` — the durable URL the server picked for
// persistence (MUL-3192 / `buildMarkdownURL`):
// public CDN passthrough when the storage is
// public-readable, or `MULTICA_PUBLIC_URL +
// /api/attachments/<id>/download` for
// private-bucket modes. Aligned with the
// server-side policy by construction, so it
// beats `record.url` whenever both exist.
// - `record.url` — raw storage URL. May be private (S3 /
// CloudFront-signed, R2, MinIO) and unable
// to load directly. Last-resort fallback
// for legacy responses that omit
// `markdown_url`.
//
// Heuristic: when `download_url` is an absolute URL with a recognised
// CDN signature query (`Signature` / `Expires` / `Key-Pair-Id` for
// CloudFront, `X-Amz-Signature` / `X-Amz-Expires` for raw S3 presigns
// that may surface here in future modes), use it. Otherwise use
// `record.url`, which carries the LocalStorage `?exp&sig` token and is
// the only inline-loadable URL in that backend. Falls back to the
// input URL when neither is usable so legacy markdown links keep their
// pre-fix behaviour.
// Order:
//
// 1. Signed `download_url` — when CloudFront has minted a signed
// redirect for THIS response, use it; the TTL means the signed URL
// 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.
// 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
// `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 {
const dl = record.download_url ?? "";
if (
@@ -178,6 +237,7 @@ function pickInlineMediaURL(record: AttachmentRecord, fallback: string): string
) {
return dl;
}
if (record.markdown_url) return record.markdown_url;
if (record.url) return record.url;
return fallback;
}

View File

@@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { Attachment } from "@multica/core/types";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
const mockFocus = vi.hoisted(() => vi.fn());
const mockSetContent = vi.hoisted(() => vi.fn());
@@ -10,6 +12,16 @@ const editorState = vi.hoisted(() => ({
markdown: "",
}));
// Records the attachments[] prop the provider received on its most recent
// render. Content-editor merges its `attachments` prop with in-session
// upload results before passing them down — these tests assert that merged
// shape lands here.
const providerProps = vi.hoisted<{ attachments: Attachment[] | undefined }>(
() => ({ attachments: undefined }),
);
const uploadAndInsertFileMock = vi.hoisted(() => vi.fn());
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({}),
}));
@@ -19,7 +31,7 @@ vi.mock("./extensions", () => ({
}));
vi.mock("./extensions/file-upload", () => ({
uploadAndInsertFile: vi.fn(),
uploadAndInsertFile: uploadAndInsertFileMock,
}));
vi.mock("./utils/preprocess", () => ({
@@ -30,6 +42,19 @@ vi.mock("./bubble-menu", () => ({
EditorBubbleMenu: () => null,
}));
vi.mock("./attachment-download-context", () => ({
AttachmentDownloadProvider: ({
attachments,
children,
}: {
attachments?: Attachment[];
children: React.ReactNode;
}) => {
providerProps.attachments = attachments;
return <>{children}</>;
},
}));
const editorRef = vi.hoisted<{ current: unknown }>(() => ({ current: null }));
const onCreateFired = vi.hoisted(() => ({ value: false }));
@@ -79,6 +104,7 @@ describe("ContentEditor", () => {
editorState.markdown = "";
editorRef.current = null;
onCreateFired.value = false;
providerProps.attachments = undefined;
});
it("focuses the editor when clicking the empty container area", () => {
@@ -185,3 +211,154 @@ describe("ContentEditor", () => {
expect(mockSetContent).not.toHaveBeenCalled();
});
});
function makeAttachment(id: string, overrides: Partial<Attachment> = {}): Attachment {
return {
id,
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "u-1",
filename: `${id}.png`,
url: `/uploads/${id}.png`,
download_url: `/api/attachments/${id}/download`,
markdown_url: `https://api.multica.test/api/attachments/${id}/download`,
content_type: "image/png",
size_bytes: 1,
created_at: "2026-06-10T00:00:00Z",
...overrides,
};
}
function asUploadResult(att: Attachment): UploadResult {
return { ...att, link: att.url, markdownLink: `/api/attachments/${att.id}/download` };
}
// MUL-3192 — surfaces like the quick-create modal upload images through the
// editor without a server-supplied `attachments` prop. Without in-session
// tracking, the AttachmentDownloadProvider had nothing to resolve the
// freshly-inserted /api/attachments/<id>/download URL against, so
// Attachment.normalize() couldn't swap it for a freshly-loadable URL — the
// <img> rendered broken on Desktop where the renderer's origin doesn't
// proxy /api to the API host. ContentEditor now wraps onUploadFile so the
// successful UploadResult lands in the provider as a tracked record.
describe("ContentEditor — in-session attachment tracking (MUL-3192)", () => {
it("seeds the AttachmentDownloadProvider with the caller-supplied attachments prop", () => {
const att = makeAttachment("seed-1");
render(<ContentEditor attachments={[att]} />);
expect(providerProps.attachments).toEqual([att]);
});
it("appends a successful upload result to the provider's attachments list", async () => {
const onUploadFile = vi.fn(async (_file: File) =>
asUploadResult(makeAttachment("uploaded-1")),
);
// Capture the wrapped uploader the editor hands to uploadAndInsertFile,
// then invoke it the same way the file-upload extension would.
let capturedHandler:
| ((file: File) => Promise<UploadResult | null>)
| undefined;
uploadAndInsertFileMock.mockImplementation(
async (_editor: unknown, file: File, handler: typeof capturedHandler) => {
capturedHandler = handler;
await handler?.(file);
},
);
let imperativeRef: { uploadFile: (file: File) => void } | null = null;
render(
<ContentEditor
onUploadFile={onUploadFile}
ref={(r) => {
imperativeRef = r;
}}
/>,
);
expect(providerProps.attachments).toBeUndefined();
await act(async () => {
imperativeRef?.uploadFile(new File(["payload"], "shot.png", { type: "image/png" }));
});
// The wrapper (not the raw caller-supplied uploader) is what reaches
// the file-upload extension — that's the layer that captures successful
// results into the provider.
expect(capturedHandler).toBeTypeOf("function");
expect(capturedHandler).not.toBe(onUploadFile);
expect(onUploadFile).toHaveBeenCalledTimes(1);
expect(providerProps.attachments).toHaveLength(1);
expect(providerProps.attachments?.[0]?.id).toBe("uploaded-1");
});
it("merges in-session uploads with the caller's attachments prop, preferring the prop on id collision", async () => {
// The pre-loaded record carries a freshly-signed download_url; the
// upload result for the same id has an older download_url. After merge
// the provider should still expose the prop's record so the editor's
// resolveAttachment lookup hands back the freshest data.
const seeded = makeAttachment("shared-1", {
download_url: "https://cdn.example/freshly-signed.png?Signature=fresh",
});
const collision = makeAttachment("shared-1", {
download_url: "https://cdn.example/freshly-signed.png?Signature=stale",
});
const onUploadFile = vi.fn(async () => asUploadResult(collision));
uploadAndInsertFileMock.mockImplementation(
async (_e: unknown, file: File, handler: (f: File) => Promise<unknown>) => {
await handler(file);
},
);
let imperativeRef: { uploadFile: (file: File) => void } | null = null;
render(
<ContentEditor
attachments={[seeded]}
onUploadFile={onUploadFile}
ref={(r) => {
imperativeRef = r;
}}
/>,
);
await act(async () => {
imperativeRef?.uploadFile(new File(["x"], "shared.png", { type: "image/png" }));
});
expect(providerProps.attachments).toHaveLength(1);
expect(providerProps.attachments?.[0]?.download_url).toContain("Signature=fresh");
});
it("does not append a duplicate when the same upload result returns twice (paste-then-drop the same blob)", async () => {
const result = asUploadResult(makeAttachment("dedup-1"));
const onUploadFile = vi.fn(async () => result);
uploadAndInsertFileMock.mockImplementation(
async (_e: unknown, file: File, handler: (f: File) => Promise<unknown>) => {
await handler(file);
},
);
let imperativeRef: { uploadFile: (file: File) => void } | null = null;
render(
<ContentEditor
onUploadFile={onUploadFile}
ref={(r) => {
imperativeRef = r;
}}
/>,
);
await act(async () => {
imperativeRef?.uploadFile(new File(["a"], "a.png", { type: "image/png" }));
});
await act(async () => {
imperativeRef?.uploadFile(new File(["b"], "b.png", { type: "image/png" }));
});
expect(providerProps.attachments).toHaveLength(1);
expect(providerProps.attachments?.[0]?.id).toBe("dedup-1");
});
});

View File

@@ -34,7 +34,9 @@ import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
@@ -168,10 +170,66 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
const onBlurRef = useRef(onBlur);
const onUploadFileRef = useRef(onUploadFile);
const onUploadFileRef = useRef<
((file: File) => Promise<UploadResult | null>) | undefined
>(undefined);
const mentionContextItemsRef = useRef<MentionItem[]>(mentionContextItems ?? []);
const lastEmittedRef = useRef<string | null>(null);
// In-session record of attachments freshly uploaded through this editor.
// Surfaces (like the quick-create modal) that don't have a server-supplied
// `attachments` prop still need the AttachmentDownloadProvider to know
// about images the user just pasted/dropped — without a record in scope,
// Attachment.normalize() can't swap the persisted /api/attachments/<id>/
// download URL to a freshly-loadable one, and the <img> renders broken in
// any environment where the renderer's origin doesn't proxy /api to the
// API host (MUL-3192, Desktop/Electron).
const [sessionUploads, setSessionUploads] = useState<Attachment[]>([]);
// Wrap the caller-supplied uploader so we can stash each successful result
// in `sessionUploads`. The wrapper is rebuilt only when the underlying
// `onUploadFile` identity changes, so the inner ref handed to Tiptap stays
// stable across renders the way the original passthrough did.
const wrappedOnUploadFile = useMemo(() => {
if (!onUploadFile) return undefined;
return async (file: File): Promise<UploadResult | null> => {
const result = await onUploadFile(file);
// Only track attachments that carry a persisted id — the no-workspace
// avatar branch returns an id-less record that the resolver can't key
// off of, and tracking it would just bloat memory without helping
// anyone. See useFileUpload's `markdownLink` docstring for why.
if (result?.id) {
setSessionUploads((prev) =>
// Deduplicate on id so a re-upload (or a paste-then-drop of the
// same blob) doesn't create a parallel record.
prev.some((a) => a.id === result.id) ? prev : [...prev, result],
);
}
return result;
};
}, [onUploadFile]);
// Merged list fed to AttachmentDownloadProvider. Caller-supplied attachments
// (issue / comment editors that pre-load the full attachments[] from the
// server) take precedence — we only append session uploads the caller
// doesn't already have, so a parent re-render that includes the same record
// doesn't end up with two copies.
const providerAttachments = useMemo(() => {
if (sessionUploads.length === 0) return attachments;
const seen = new Set<string>();
const merged: Attachment[] = [];
for (const a of attachments ?? []) {
if (a.id) seen.add(a.id);
merged.push(a);
}
for (const a of sessionUploads) {
if (!seen.has(a.id)) {
seen.add(a.id);
merged.push(a);
}
}
return merged;
}, [attachments, sessionUploads]);
// Current workspace slug kept in a ref so the click handler always sees the
// latest value without recreating the editor. Used by openLink to prefix
// legacy /issues/... style paths that lack a workspace slug.
@@ -183,7 +241,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
onBlurRef.current = onBlur;
onUploadFileRef.current = onUploadFile;
onUploadFileRef.current = wrappedOnUploadFile;
mentionContextItemsRef.current = mentionContextItems ?? [];
const queryClient = useQueryClient();
@@ -390,7 +448,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
if (!editor) return null;
return (
<AttachmentDownloadProvider attachments={attachments}>
<AttachmentDownloadProvider attachments={providerAttachments}>
<div
ref={wrapperRef}
className="relative flex flex-1 min-h-full flex-col"

View File

@@ -55,6 +55,7 @@ function makeUpload(
uploader_id: "user-1",
url: overrides.link,
download_url: overrides.link,
markdown_url: overrides.link,
content_type: "image/png",
size_bytes: 1,
created_at: new Date(0).toISOString(),

View File

@@ -98,6 +98,7 @@ const attachment: Attachment = {
filename: "evidence.png",
url: "s3://bucket/evidence.png",
download_url: "https://example.test/evidence.png",
markdown_url: "https://example.test/api/attachments/attachment-1/download",
content_type: "image/png",
size_bytes: 1,
created_at: "2026-01-01T00:00:00Z",

View File

@@ -65,9 +65,35 @@ type AttachmentResponse struct {
Filename string `json:"filename"`
URL string `json:"url"`
DownloadURL string `json:"download_url"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
// MarkdownURL is the durable, absolute-when-possible URL the client
// SHOULD persist into markdown bodies (issue descriptions, comments,
// chat messages). It is computed per deployment policy by
// buildMarkdownURL — preferring the storage URL when it is already a
// public, durable absolute URL (public CDN / LocalStorage with
// MULTICA_LOCAL_UPLOAD_BASE_URL), and otherwise prefixing
// MULTICA_PUBLIC_URL onto the stable per-attachment endpoint that the
// server self-resigns / proxies on every request.
//
// Why a separate field from URL / DownloadURL:
// - URL is the raw storage object URL — fine for avatar/logo
// surfaces but may be private (S3 + CloudFront-signed mode) or
// site-relative (LocalStorage with no base URL configured).
// - DownloadURL is the URL the renderer uses for THIS response — it
// can be a short-lived signed URL (CloudFront, S3 presign) and
// therefore must NOT be persisted. It expires.
// - MarkdownURL is contracted to be persistable: it never carries a
// TTL, and on every supported deployment shape it is loadable as
// a native browser resource fetch (no Authorization header required
// beyond the cookies/credentials the client already has on the
// resolved host).
//
// MUL-3192 — fixes the Desktop / mobile-webview regression where the
// previous site-relative `/api/attachments/<id>/download` link only
// resolved when the document origin proxied /api to the API host.
MarkdownURL string `json:"markdown_url"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
}
func (h *Handler) attachmentToResponse(a db.Attachment) AttachmentResponse {
@@ -80,6 +106,7 @@ func (h *Handler) attachmentToResponse(a db.Attachment) AttachmentResponse {
Filename: a.Filename,
URL: a.Url,
DownloadURL: attachmentDownloadPath(id),
MarkdownURL: h.buildMarkdownURL(a, id),
ContentType: a.ContentType,
SizeBytes: a.SizeBytes,
CreatedAt: a.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
@@ -110,6 +137,104 @@ func attachmentDownloadPath(id string) string {
return "/api/attachments/" + id + "/download"
}
// buildMarkdownURL chooses the durable URL the client persists into
// markdown bodies. The contract is "absolute, no TTL, loadable as a native
// browser resource fetch on every supported client" (MUL-3192).
//
// Decision:
//
// 1. Persist `a.Url` only when the deployment has signaled the storage
// backend serves URLs publicly without per-request auth:
// - `Storage.CdnDomain()` is non-empty (operator configured a
// public-facing base URL — `S3_CDN_DOMAIN` for the S3 backend or
// `LOCAL_UPLOAD_BASE_URL` for LocalStorage), AND
// - `h.CFSigner` is nil (no per-request CloudFront signing — when
// signing is on, the same CDN domain serves PRIVATE content via
// time-bounded signed URLs and the raw `a.Url` is unauth-deny),
// AND
// - `a.Url` is itself an absolute http(s) URL with no signature
// query — defends against legacy rows backfilled while baseURL
// was unset, and against a freshly-signed `download_url` ever
// leaking into `a.Url` (the original MUL-3130 bug).
//
// 2. Every other shape — CloudFront-signed mode, S3 presign /proxy
// against a private bucket without a CDN domain, raw S3 / R2 /
// MinIO, LocalStorage with no `LOCAL_UPLOAD_BASE_URL` — uses the
// stable per-attachment endpoint that the server self-signs /
// proxies on every request, anchored on `MULTICA_PUBLIC_URL` so the
// persisted URL keeps working for clients that don't share the
// document origin (Desktop / mobile webview).
//
// 3. Last-resort fallback (no `MULTICA_PUBLIC_URL` configured): emit
// the site-relative path. Web's Next.js rewrite handles this; non-
// web clients on a deployment without `PublicURL` configured were
// already broken before MUL-3192 and stay broken here, but we
// don't make them worse.
func (h *Handler) buildMarkdownURL(a db.Attachment, id string) string {
relPath := attachmentDownloadPath(id)
publicURL := strings.TrimRight(h.cfg.PublicURL, "/")
if h.storageURLIsPubliclyReadable(a.Url) {
return a.Url
}
if publicURL != "" {
return publicURL + relPath
}
return relPath
}
// storageURLIsPubliclyReadable returns true when the deployment has signaled
// that `a.Url` can be loaded directly by an unauthenticated native browser
// fetch — the only case where it is safe to persist `a.Url` into a markdown
// body that will outlive the current session.
func (h *Handler) storageURLIsPubliclyReadable(rawURL string) bool {
if h.Storage == nil || h.CFSigner != nil {
// CFSigner != nil is per-request signing; the CDN domain serves
// private content via signed URLs and `a.Url` is the raw S3 URL.
return false
}
if h.Storage.CdnDomain() == "" {
// No public-facing base URL configured — the storage's URL is
// the raw private object URL (S3 / R2 / MinIO) or a site-relative
// LocalStorage path that doesn't carry an origin.
return false
}
return isDurablePublicURL(rawURL)
}
// isDurablePublicURL is true when `rawURL` is an absolute http(s) URL that
// is safe to persist into long-lived markdown bodies — i.e. it carries no
// CloudFront / S3 signature query that would make it expire.
func isDurablePublicURL(rawURL string) bool {
if rawURL == "" {
return false
}
u, err := url.Parse(rawURL)
if err != nil {
return false
}
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
if u.Host == "" {
return false
}
q := u.Query()
for _, k := range []string{
"Signature",
"X-Amz-Signature",
"Key-Pair-Id",
"Expires",
"X-Amz-Expires",
} {
if q.Get(k) != "" {
return false
}
}
return true
}
func normalizeAttachmentDownloadMode(raw string) (attachmentDownloadMode, bool) {
switch attachmentDownloadMode(strings.ToLower(strings.TrimSpace(raw))) {
case "", attachmentDownloadModeAuto:

View File

@@ -85,6 +85,15 @@ func (m *mockStorage) KeyFromURL(rawURL string) string {
return rawURL
}
func (m *mockStorage) CdnDomain() string { return "cdn.example.com" }
// mockStorageNoCdn is a mockStorage variant that returns an empty CdnDomain
// to simulate a private S3 / R2 / MinIO deployment where the operator has
// NOT configured a public-facing CDN domain. buildMarkdownURL must not
// persist `a.Url` for this shape — it would write a private bucket URL
// into markdown that no client can load.
type mockStorageNoCdn struct{ mockStorage }
func (m *mockStorageNoCdn) CdnDomain() string { return "" }
func (m *mockStorage) GetReader(_ context.Context, key string) (io.ReadCloser, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -958,3 +967,232 @@ func TestIsTextPreviewable(t *testing.T) {
})
}
}
// MUL-3192 — buildMarkdownURL must emit a durable, absolute-when-possible
// URL that loads natively in any client (web, desktop, mobile webview).
// `download_url` may be a short-lived signed URL and is unsafe to persist;
// `markdown_url` is the contract for "ok to embed in markdown body".
//
// Matrix:
//
// - public CDN durable URL ............... reuse a.Url verbatim
// - LocalStorage with PublicURL set ....... reuse a.Url (already absolute)
// - CloudFront-signed mode ................ never reuse a.Url (raw S3),
// prefer absolute API endpoint
// - LocalStorage relative + PublicURL set . prefix to absolute API endpoint
// - PublicURL unset ....................... fall back to site-relative
// (web's Next rewrite handles it)
// - signed URL (CloudFront-signed leaked
// into a.Url somehow) ................... reject as durable, fall through
// to API endpoint to avoid
// re-opening MUL-3130
func TestBuildMarkdownURL_PublicCdnAbsoluteURLReusedVerbatim(t *testing.T) {
origPublic := testHandler.cfg.PublicURL
origSigner := testHandler.CFSigner
origStorage := testHandler.Storage
t.Cleanup(func() {
testHandler.cfg.PublicURL = origPublic
testHandler.CFSigner = origSigner
testHandler.Storage = origStorage
})
testHandler.cfg.PublicURL = "https://api.multica.test"
testHandler.CFSigner = nil
// mockStorage.CdnDomain() returns "cdn.example.com" — that's the
// operator-set signal that the URL host serves content publicly
// without per-request auth. Without this, the new gate routes
// through the API endpoint to be safe.
testHandler.Storage = &mockStorage{}
id := seedAttachmentURL(t, "https://cdn.multica.test/uploads/abc.png", "abc.png", "image/png", 1)
att, err := testHandler.Queries.GetAttachment(context.Background(), db.GetAttachmentParams{
ID: parseUUID(id),
WorkspaceID: parseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("GetAttachment: %v", err)
}
resp := testHandler.attachmentToResponse(att)
if resp.MarkdownURL != "https://cdn.multica.test/uploads/abc.png" {
t.Fatalf("markdown_url = %q, want raw a.Url passthrough", resp.MarkdownURL)
}
}
// MUL-3192 review must-fix 1 — `att.url` for a private S3 / R2 / MinIO
// bucket is absolute https + unsigned but is NOT publicly readable. The
// generic "absolute http(s) without signature" check would have wrongly
// persisted it; the gate now also requires `Storage.CdnDomain()` to be
// set so the operator has explicitly opted into "URLs from this storage
// load directly".
func TestBuildMarkdownURL_PrivateBucketWithoutCdnDomainRoutesThroughAPIEndpoint(t *testing.T) {
origPublic := testHandler.cfg.PublicURL
origSigner := testHandler.CFSigner
origStorage := testHandler.Storage
t.Cleanup(func() {
testHandler.cfg.PublicURL = origPublic
testHandler.CFSigner = origSigner
testHandler.Storage = origStorage
})
testHandler.cfg.PublicURL = "https://api.multica.test"
testHandler.CFSigner = nil
testHandler.Storage = &mockStorageNoCdn{}
id := seedAttachmentURL(t, "https://prod.s3.amazonaws.com/key.png", "key.png", "image/png", 1)
att, err := testHandler.Queries.GetAttachment(context.Background(), db.GetAttachmentParams{
ID: parseUUID(id),
WorkspaceID: parseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("GetAttachment: %v", err)
}
resp := testHandler.attachmentToResponse(att)
want := "https://api.multica.test/api/attachments/" + id + "/download"
if resp.MarkdownURL != want {
t.Fatalf("markdown_url = %q, want absolute API endpoint %q (private bucket without explicit CDN must not persist raw S3 URL)", resp.MarkdownURL, want)
}
}
func TestBuildMarkdownURL_CloudFrontSignedModeNeverPersistsRawStorageURL(t *testing.T) {
origPublic := testHandler.cfg.PublicURL
origSigner := testHandler.CFSigner
t.Cleanup(func() {
testHandler.cfg.PublicURL = origPublic
testHandler.CFSigner = origSigner
})
testHandler.cfg.PublicURL = "https://api.multica.test"
testHandler.CFSigner = testCloudFrontSigner(t)
// Raw S3 URL — private bucket, not loadable directly by clients.
id := seedAttachmentURL(t, "https://prod.s3.amazonaws.com/key.png", "key.png", "image/png", 1)
att, err := testHandler.Queries.GetAttachment(context.Background(), db.GetAttachmentParams{
ID: parseUUID(id),
WorkspaceID: parseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("GetAttachment: %v", err)
}
resp := testHandler.attachmentToResponse(att)
want := "https://api.multica.test/api/attachments/" + id + "/download"
if resp.MarkdownURL != want {
t.Fatalf("markdown_url = %q, want absolute API endpoint %q", resp.MarkdownURL, want)
}
// download_url is allowed to carry a TTL (CloudFront-signed); it's NOT
// what the client persists, but it IS what the renderer uses for this
// response. The two are intentionally distinct.
if resp.DownloadURL == resp.MarkdownURL {
t.Fatalf("download_url and markdown_url must differ in CloudFront-signed mode (got identical %q)", resp.DownloadURL)
}
}
func TestBuildMarkdownURL_RelativeStorageURLPrefixedWithPublicURL(t *testing.T) {
origPublic := testHandler.cfg.PublicURL
origSigner := testHandler.CFSigner
t.Cleanup(func() {
testHandler.cfg.PublicURL = origPublic
testHandler.CFSigner = origSigner
})
testHandler.cfg.PublicURL = "https://api.multica.test"
testHandler.CFSigner = nil
// LocalStorage without LOCAL_UPLOAD_BASE_URL stores a site-relative URL.
id := seedAttachmentURL(t, "/uploads/abc.png", "abc.png", "image/png", 1)
att, err := testHandler.Queries.GetAttachment(context.Background(), db.GetAttachmentParams{
ID: parseUUID(id),
WorkspaceID: parseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("GetAttachment: %v", err)
}
resp := testHandler.attachmentToResponse(att)
want := "https://api.multica.test/api/attachments/" + id + "/download"
if resp.MarkdownURL != want {
t.Fatalf("markdown_url = %q, want absolute API endpoint %q", resp.MarkdownURL, want)
}
}
func TestBuildMarkdownURL_PublicURLUnsetFallsBackToSiteRelative(t *testing.T) {
origPublic := testHandler.cfg.PublicURL
origSigner := testHandler.CFSigner
t.Cleanup(func() {
testHandler.cfg.PublicURL = origPublic
testHandler.CFSigner = origSigner
})
testHandler.cfg.PublicURL = ""
testHandler.CFSigner = nil
id := seedAttachmentURL(t, "/uploads/abc.png", "abc.png", "image/png", 1)
att, err := testHandler.Queries.GetAttachment(context.Background(), db.GetAttachmentParams{
ID: parseUUID(id),
WorkspaceID: parseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("GetAttachment: %v", err)
}
resp := testHandler.attachmentToResponse(att)
want := "/api/attachments/" + id + "/download"
if resp.MarkdownURL != want {
t.Fatalf("markdown_url = %q, want site-relative fallback %q", resp.MarkdownURL, want)
}
}
func TestBuildMarkdownURL_StripsTrailingSlashOnPublicURL(t *testing.T) {
origPublic := testHandler.cfg.PublicURL
origSigner := testHandler.CFSigner
t.Cleanup(func() {
testHandler.cfg.PublicURL = origPublic
testHandler.CFSigner = origSigner
})
testHandler.cfg.PublicURL = "https://api.multica.test/"
testHandler.CFSigner = nil
id := seedAttachmentURL(t, "/uploads/abc.png", "abc.png", "image/png", 1)
att, err := testHandler.Queries.GetAttachment(context.Background(), db.GetAttachmentParams{
ID: parseUUID(id),
WorkspaceID: parseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("GetAttachment: %v", err)
}
resp := testHandler.attachmentToResponse(att)
want := "https://api.multica.test/api/attachments/" + id + "/download"
if resp.MarkdownURL != want {
t.Fatalf("markdown_url = %q, want exactly one separator %q", resp.MarkdownURL, want)
}
}
func TestIsDurablePublicURL(t *testing.T) {
cases := []struct {
name string
url string
want bool
}{
{"absolute https no signature", "https://cdn.multica.test/foo.png", true},
{"absolute http no signature", "http://cdn.multica.test/foo.png", true},
{"absolute with port + path", "https://cdn.example.test:8080/a/b/c.png", true},
{"empty string", "", false},
{"site-relative", "/uploads/abc.png", false},
{"protocol-relative", "//cdn.example/foo.png", false},
{"data URL", "data:image/png;base64,abc", false},
{"blob URL", "blob:https://app/abc", false},
{"unsupported scheme", "ftp://server/foo", false},
{"cloudfront-signed Signature", "https://cdn.example/foo.png?Signature=abc&Key-Pair-Id=K1", false},
{"cloudfront-signed Key-Pair-Id alone", "https://cdn.example/foo.png?Key-Pair-Id=K1", false},
{"s3-presigned X-Amz-Signature", "https://bucket.s3/foo.png?X-Amz-Signature=abc", false},
{"s3-presigned X-Amz-Expires alone", "https://bucket.s3/foo.png?X-Amz-Expires=900", false},
{"plain Expires query", "https://cdn.example/foo.png?Expires=99", false},
{"unrelated query", "https://cdn.example/foo.png?cache=1", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isDurablePublicURL(tc.url); got != tc.want {
t.Errorf("isDurablePublicURL(%q) = %v, want %v", tc.url, got, tc.want)
}
})
}
}