mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* refactor(markdown): single canonical sanitize schema for both renderers (MUL-4922) Phase 1 of the RichContent convergence: collapse the duplicated security base shared by the two product-level Markdown chains. Chat (packages/ui/markdown/Markdown.tsx) and Issue/Comment (packages/views/editor/readonly-content.tsx) each carried a verbatim fork of the rehype-sanitize schema and urlTransform, and the forks had already drifted: readonly whitelisted <mark> for `==highlight==`, chat did not. A security-relevant allow-list maintained in two places means every future XSS fix has to land twice, and missing one is a hole — this is the hardest reason for the sweep, ahead of the user-visible feature drift. - Extract markdownSanitizeSchema + markdownUrlTransform into packages/ui/markdown/sanitize.ts and export from the package index. Both chains now import the single copy; no local forks remain. - The canonical schema is the union, so chat gains the <mark> tag name. This is the one intentional behavior delta: <mark> is inert and admits no attributes, and chat needs it anyway once ==highlight== converges. - Annotate the schema as rehype-sanitize's Options: exporting it makes the previously-inferred hast-util-sanitize type unnameable across packages. Adds a cross-surface contract test that runs one set of security fixtures (script, event handlers, javascript: href, data:image vs data:text/html, mark, slash://) through BOTH surfaces and asserts identical outcomes — the mechanism that stops a third fork from growing back. Code-block rendering is deliberately not asserted cross-surface yet: chat highlights with Shiki, readonly with lowlight, so emitted class tokens still differ. Converging them is the RichCodeBlock phase and needs the highlight-engine decision first; only the schema-level allow-list is shared here. Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors), pnpm vitest run in packages/views (228 files, 2665 tests, all passing). Co-authored-by: multica-agent <github@multica.ai> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(rich-content): one RichContent renderer for Chat and Issue/Comment (MUL-4922) Phase 2+3 of the convergence. Chat now renders agent output through the same product renderer as Issue descriptions and Comments, so a ```mermaid fence an agent emits is a diagram in Chat — not a dead code block. Before this there were two product Markdown chains. Issue/Comment had Mermaid, HTML preview, lowlight code, product Mention/Attachment/LinkHover; Chat went through a generic renderer with no Mermaid/HTML dispatcher at all. Chat is where agents emit the most diagrams, so the gap was most visible exactly where it mattered. Architecture - packages/views/rich-content/ holds the ONE product renderer: a single ReactMarkdown pipeline, one sanitize config, one components map, one fenced -code dispatcher. Public API is content/attachments/density/phase — no `surface` prop, no renderMention override, no custom code-renderer hook, because each is a door a per-surface fork walks back through. - `density` is CSS only; `phase` is lifecycle only. Neither switches parser, plugins or the semantic DOM, so all surfaces emit the same blocks. - ReadonlyContent is now a ~40-line compatibility wrapper (was 501 lines); its consumers are untouched. views/common/markdown.tsx is deleted. - Leaf components (Mermaid, HTML preview, lowlight code) stay in editor/ and are imported by direct path, so Tiptap keeps reusing them and Chat does not pull in the editor's Tiptap graph. Moving them is a later mechanical commit. Streaming fence gate Rich blocks upgrade only when the fence is CLOSED, judged by a real CommonMark parse (mdast) rather than a `startsWith("```")` scan. A half-streamed fence renders as source, so Mermaid never parses a partial diagram and no iframe is created for HTML still arriving. Closedness — not `phase` — is the gate, so a settled-but-malformed fence stays source too. The gate returns offsets only; it never renders, because a gate that rendered would be the second renderer this change exists to delete. Verified that source offsets survive rehype-raw + sanitize + katex before relying on them. Live -> persisted row identity The live timeline moved out of Virtuoso's Footer into a real row keyed `task:<taskId>`, and persisted assistant rows key on the same task instead of `message.id`. Completion is now an in-place data swap through one AssistantMessage component, so an already-rendered diagram or iframe stays mounted and is not re-run. The process fold previously relied on that remount to re-collapse, so the collapse is now expressed directly. Notes - Chat code blocks move from Shiki to lowlight (Howard's ratified choice), matching Tiptap/Readonly and the existing hljs CSS. Ligature suppression carries over via code.css instead of utility classes. - Chat message tests now stub react-virtuoso: real Virtuoso renders its Footer but no data rows under jsdom's zero-height viewport, and the live timeline is a row now. Tests: five-surface Mermaid parity (Chat user / live / persisted, Issue description, Comment), streaming open->closed->settled with mount counting, live->persisted no-remount, htmlbars/mermaidx not misdispatched, sandbox and data-URI security regressions, CommonMark fence edge cases (shorter closer, tilde, indented, nested, in-list), and source-level import boundary guards. Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors), packages/views vitest 240 files / 2743 tests all passing. Co-authored-by: multica-agent <github@multica.ai> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(rich-content): drop dangling export, add export guard + lazy rich blocks (MUL-4922) Two review follow-ups. 1. Dangling package export `packages/views` still exported "./common/markdown" after the file was deleted in the RichContent convergence. TypeScript resolves against the source tree so typecheck and unit tests both passed; the subpath would only fail when a consuming app bundled it. No consumer imports it, so this was latent rather than broken in practice. Removes the entry and adds packages/views/rich-content/package-exports.test.ts, which walks every workspace package.json and asserts each export target exists (wildcards checked by directory prefix). Scoped repo-wide because the failure mode belongs to package.json, not to this package. Verified the guard actually fails by re-adding the dangling entry before committing. 2. Near-viewport lazy shell for Mermaid / HTML Implements the deferred performance contract rather than requesting an exemption. LazyRichBlock defers each rich leaf until it is within 800px of the viewport, then latches: once mounted it is never unmounted, so scrolling past does not re-run Mermaid, rebuild the sandboxed iframe, or discard the viewer's pan/zoom state. The stable-size requirement is handled by reserving the block's expected height before AND after mount, so a block never measures 0px off-screen and then jumps — the churn that makes a virtualized list mis-estimate item sizes. The reservation is not a local guess: reservedMermaidHeightPx() reuses the existing session layout cache (real height on a cache hit, else the documented 280px skeleton) and HTML_BLOCK_PREVIEW_HEIGHT_PX is the pixel twin of the preview iframe's fixed h-[480px]. Both are exported from the leaves so there is one source of truth per block type. Environments without IntersectionObserver (jsdom, SSR) mount eagerly, which is today's behaviour; rendering nothing would not be. Verified in real Chromium (jsdom has no IntersectionObserver, so unit tests only exercise the eager fallback): - Non-virtualized Issue/Comment with 25 diagrams: 3 mounted, 22 deferred on load; after scrolling, mounted grows 3 -> 6 and never drops, confirming the latch. This is where the win is real. - Chat gains less: Virtuoso already caps the DOM to ~4 rows, so only 1 of 4 shells was deferred. Stated rather than overclaimed. - Live -> persisted handoff re-checked with the shell in place: scrollTop 220 -> 220 (delta 0), the tagged diagram DOM node survived, and the run reached the persisted state. Contract 1 is not regressed. Tests: 6 lazy-shell cases (defer, mount-on-approach, latch/no-unmount, equal reserved height before and after mount, root margin exceeding the chat list's own overscan, no-IntersectionObserver fallback) plus 3 export-guard cases. Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors), packages/views vitest 242 files / 2752 tests all passing. Co-authored-by: multica-agent <github@multica.ai> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(rich-content): SSR-deterministic lazy state, mention a11y, drop type casts (MUL-4922) Three review blockers. 1. Hydration mismatch in the lazy shell LazyRichBlock derived its initial `mounted` state from feature detection inside useState. On the server (no window) that resolved true and rendered the whole Mermaid/HTML subtree; in the browser (IntersectionObserver present) the hydration pass resolved false and rendered a placeholder — different markup for the same component, and an SSR path that silently bypassed the lazy gate. `"use client"` does not opt a component out of Next's server render, so this was reachable. Initial state is now unconditionally false on both sides. The eager fallback for environments without IntersectionObserver moved into the effect, which never runs on the server, so the first committed frame is identical everywhere and the latch is unchanged. The first version of the SSR test passed against the buggy component: jsdom always provides `window`, so server and client took the same detection branch and the mismatch was unobservable. The suite now removes IntersectionObserver for the duration of renderToString to reproduce the real asymmetry. Verified by reinstating the bug: 2 of the 3 SSR tests fail and React raises its own "Hydration failed" error. 2. Project mention lost keyboard access The unified renderer inherited the readonly surface's `<span onClick>` around ProjectChip, while Chat had previously used AppLink — so converging the surfaces regressed Chat from a focusable anchor to a mouse-only span. A span and an anchor are visually identical and behave the same under a mouse, which is why only an assertion on the emitted element catches it. Now rendered through AppLink, which also owns plain-click, modifier-click and the desktop new-tab adapter, so none of that is reimplemented. The wrapper keeps only stopPropagation, matching IssueMentionCard. Adds project-mention-a11y.test.tsx using the REAL AppLink and NavigationProvider — mocking AppLink to emit an anchor would assert the mock. Covers anchor + href, chip's nearest interactive ancestor, Tab focus, Enter activation, click, and modifier-click labelling. All 6 fail on the old span. 3. Type suppressions in the production renderer `as never` on the plugin lists and `as NonNullable<Components[...]>` on the code/pre overrides silenced real type errors, against the repo's strict-TS rule. Replaced with react-markdown's own types: RichCode/RichPre now derive their props from `ComponentPropsWithoutRef<tag> & ExtraProps`, and the plugin lists use `satisfies NonNullable<Options["remarkPlugins" | "rehypePlugins"]>` so a bad plugin tuple still fails to compile. Also removed the remaining casts this exposed: hast property reads went through `as string` even though hast property values are a union, so a non-string `data-*` attribute would have been typed as a lie — replaced with a runtime-narrowing helper. `toHtml()` already returns string; its cast was redundant. Node position reads are narrowed in one helper. No cast or ts-ignore remains in production rich-content. Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors), packages/views vitest 243 files / 2780 tests all passing. Co-authored-by: multica-agent <github@multica.ai> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(rich-content): cached-height hydration, scroll-root + recycle latch, CDN reactivity (MUL-4922) Three review blockers. 1. Cached reserved height still reached the first frame The previous fix made `mounted` deterministic but left the *height* reading sessionStorage during render: RichFenceBlock called reservedMermaidHeightPx() inline. Server has no sessionStorage so it emitted 280px, while a browser with a warm cache emitted the real height — a differing style="min-height:…" on the frame React hydrates, which React reports as an attribute mismatch and does not repair. Same bug class as the one already fixed, one layer up. RichFenceBlock now splits into Mermaid/HTML components (so the height hook is never conditional) and reserves the skeleton default on the first frame, adopting the cached height in an effect. Zero-shift on a warm cache is kept; only the read moves after hydration. The earlier SSR suite passed a fixed reservedHeightPx straight to the lazy shell, bypassing this path — it could not have caught this. New tests drive the real RichFenceBlock with a real prefilled sessionStorage entry, and simulate the server by removing sessionStorage for the renderToString call (jsdom provides one, so without that the "server" takes the browser branch and the mismatch is invisible). 2. Wrong observer root, and a latch that died with the row The IntersectionObserver set only rootMargin, so it clipped against the viewport — but Chat scrolls inside its own element (Virtuoso customScrollParent). Expanding the viewport box says nothing about a nested scroller, so Chat blocks only loaded once already visible and the preload was effectively dead there. Surfaces now publish their scroll container through RichContentScrollRootProvider and the observer uses it as `root`; page-scrolled surfaces keep the viewport root. Separately, the mount latch was component state, so Virtuoso recycling a row discarded it and scrolling back re-ran Mermaid, rebuilt the iframe and dropped viewer pan/zoom — the per-pass cost the shell exists to prevent. The latch moved to a module-level registry keyed by a hash of the content, bounded at 500 entries with oldest-first eviction. Restore happens in a layout effect (before paint, so no placeholder flash) and never in initial state, keeping the server/client first frame identical. Scope was not narrowed. 3. CDN config arriving late never reprocessed content preprocessMarkdown read configStore.getState().cdnDomain imperatively and RichContent's memo depended only on `content`, so content rendered before the async config landed kept legacy CDN links as plain anchors permanently. cdnDomain is now an explicit parameter: RichContent subscribes via useConfigStore and puts it in the memo deps, while the Tiptap editor — which genuinely preprocesses once at load — reads the store at its own call sites. No fallback branch. Each fix was verified by reinstating its bug and confirming the new tests fail (2/5, 2/14 and 1/3 respectively) rather than trusting a green run. Also fixes a false positive in the boundary guard: the Tiptap check read raw file text instead of using the suite's stripComments helper, so a doc comment mentioning RichContent counted as a violation. Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors), packages/views vitest 245 files / 2793 tests all passing. Co-authored-by: multica-agent <github@multica.ai> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
581 lines
22 KiB
TypeScript
581 lines
22 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Attachment — single unified renderer for every attachment surface.
|
|
*
|
|
* Takes one attachment-shaped input (a full record, a URL-only reference, or
|
|
* an in-flight upload) and dispatches by PreviewKind:
|
|
*
|
|
* - image → ImageAttachmentView (figure + hover toolbar + lightbox via
|
|
* the shared AttachmentPreviewModal)
|
|
* - html → HtmlAttachmentPreview (inline iframe + hover toolbar)
|
|
* - others → AttachmentCard (icon + filename + Eye/Download row)
|
|
*
|
|
* Call sites:
|
|
* - extensions/file-card.tsx FileCardView (Tiptap NodeView)
|
|
* - extensions/image-view.tsx ImageView (Tiptap NodeView)
|
|
* - rich-content/rich-content.tsx (markdown img + fileCard div renderers,
|
|
* serving Chat, Issue descriptions and Comments through one renderer)
|
|
* - issues/components/comment-card.tsx AttachmentList (standalone fallback)
|
|
*
|
|
* The component owns its own preview modal and download dispatcher — callers
|
|
* just pass `attachment` and (for editor surfaces) optional editor chrome
|
|
* hints (selected, editable, onDelete).
|
|
*/
|
|
|
|
import {
|
|
Download,
|
|
Link as LinkIcon,
|
|
Maximize2,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { copyText } from "@multica/ui/lib/clipboard";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
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 { useT } from "../i18n";
|
|
import { useAttachmentDownloadResolver } from "./attachment-download-context";
|
|
import { useAttachmentPreview } from "./attachment-preview-modal";
|
|
import { useDownloadAttachment } from "./use-download-attachment";
|
|
import { AttachmentCard } from "./attachment-card";
|
|
import { HtmlAttachmentPreview } from "./html-attachment-preview";
|
|
import { getPreviewKind, type PreviewKind } from "./utils/preview";
|
|
import "./styles/attachment.css";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type AttachmentInput =
|
|
// Server response in hand — full record. Used by AttachmentList and any
|
|
// caller iterating a server-returned attachments[] array.
|
|
| { kind: "record"; attachment: AttachmentRecord }
|
|
// Markdown / Tiptap inline: only a URL + filename. Resolves to a full
|
|
// record via the surrounding AttachmentDownloadProvider when available;
|
|
// otherwise renders in URL-only mode (media types still preview from URL,
|
|
// text types fall back to a download CTA).
|
|
| {
|
|
kind: "url";
|
|
url: string;
|
|
filename: string;
|
|
contentType?: string;
|
|
/** Editor in-flight state. Renders a loader placeholder. */
|
|
uploading?: boolean;
|
|
/**
|
|
* Intrinsic pixel dimensions. Rendered as `<img width height>` so the
|
|
* browser reserves the box before the image decodes — prevents the
|
|
* layout shift that would otherwise push the caret out of view on paste.
|
|
*/
|
|
width?: number;
|
|
height?: number;
|
|
/**
|
|
* Structural hint from the call site: "this slot is definitionally an
|
|
* image / file / ...". Bypasses `getPreviewKind` autodetect, which
|
|
* needs a filename or content-type and falls back to the file-card
|
|
* chrome when neither is available. Required for callers that KNOW
|
|
* the kind from context (markdown `![]()` is always an image; Tiptap
|
|
* image NodeView is always an image) but receive only a URL with an
|
|
* empty `alt`/`filename`.
|
|
*/
|
|
forceKind?: PreviewKind;
|
|
};
|
|
|
|
export interface AttachmentProps {
|
|
attachment: AttachmentInput;
|
|
/** Editor hint — when true, the image toolbar exposes Trash. */
|
|
editable?: boolean;
|
|
/** Editor hint — applies the "selected" visual to the image figure. */
|
|
selected?: boolean;
|
|
/** Editor hint — wired to Tiptap deleteNode(). */
|
|
onDelete?: () => void;
|
|
className?: string;
|
|
}
|
|
|
|
interface Normalized {
|
|
filename: string;
|
|
contentType: string;
|
|
url: string;
|
|
attachmentId?: string;
|
|
record?: AttachmentRecord;
|
|
uploading: boolean;
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
|
|
function normalize(
|
|
input: AttachmentInput,
|
|
resolve: (url: string) => AttachmentRecord | undefined,
|
|
cdnDomain: string,
|
|
cdnSigned: boolean,
|
|
): Normalized {
|
|
if (input.kind === "record") {
|
|
return {
|
|
filename: input.attachment.filename,
|
|
contentType: input.attachment.content_type,
|
|
url: absolutizeMediaURL(
|
|
pickInlineMediaURL(input.attachment, input.attachment.url, cdnDomain, cdnSigned),
|
|
),
|
|
attachmentId: input.attachment.id,
|
|
record: input.attachment,
|
|
uploading: false,
|
|
};
|
|
}
|
|
const record = input.url ? resolve(input.url) : undefined;
|
|
return {
|
|
filename: input.filename || record?.filename || "",
|
|
contentType: input.contentType || record?.content_type || "",
|
|
// When the markdown URL resolved to an attachment record, swap to
|
|
// the record's freshly-loadable URL. The persisted markdown URL
|
|
// (`/api/attachments/<id>/download` for new content; raw stored URL
|
|
// for legacy) is correct as a stable reference but doesn't
|
|
// necessarily load as a native <img>/<video> resource for every
|
|
// client — token-mode clients can't attach an Authorization header
|
|
// to bare /api/* fetches, and a CloudFront-signed `download_url`
|
|
// is the only working media src in that mode. `pickInlineMediaURL`
|
|
// 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.
|
|
//
|
|
// 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, cdnDomain, cdnSigned) : input.url,
|
|
),
|
|
attachmentId: record?.id,
|
|
record,
|
|
uploading: !!input.uploading,
|
|
width: input.width,
|
|
height: input.height,
|
|
};
|
|
}
|
|
|
|
// 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 carries three URL fields per attachment row,
|
|
// each with a different lifetime / accessibility:
|
|
//
|
|
// - `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`.
|
|
//
|
|
// 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. 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. Skipped when the server
|
|
// reports `cdn_signed` — in CloudFront signed-URL mode the same
|
|
// domain serves PRIVATE content and a raw (unsigned) storage URL is
|
|
// a guaranteed 403 (MUL-3254).
|
|
// 3. Local disk `record.url` — self-host LocalStorage without
|
|
// LOCAL_UPLOAD_BASE_URL stores a site-relative `/uploads/...` path.
|
|
// It is the direct static object URL and is loadable once
|
|
// `absolutizeMediaURL` prefixes apiBaseUrl in split-origin clients.
|
|
// 4. `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), except for the explicit
|
|
// site-relative local upload path above.
|
|
// 5. `record.url` — legacy fallback for responses that omit
|
|
// `markdown_url` (a backend old enough to predate MUL-3192).
|
|
// 6. The input URL — when there's no record at all.
|
|
function pickInlineMediaURL(
|
|
record: AttachmentRecord,
|
|
fallback: string,
|
|
cdnDomain: string,
|
|
cdnSigned: boolean,
|
|
): 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)
|
|
) {
|
|
return dl;
|
|
}
|
|
if (!cdnSigned && storageURLMatchesCdnDomain(record.url, cdnDomain)) return record.url;
|
|
if (isSiteRelativeLocalUploadURL(record.url)) return record.url;
|
|
if (record.markdown_url) return record.markdown_url;
|
|
if (record.url) return record.url;
|
|
return fallback;
|
|
}
|
|
|
|
function isSiteRelativeLocalUploadURL(rawURL: string): boolean {
|
|
if (!rawURL || !rawURL.startsWith("/")) return false;
|
|
const path = rawURL.split(/[?#]/, 1)[0] ?? "";
|
|
return path === "/uploads" || path.startsWith("/uploads/");
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Inline media re-sign (MUL-3254)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Keep refetches well inside the server's signed-URL TTL (30 min default,
|
|
// server/internal/handler/file.go) so a re-render never serves an expired
|
|
// signature from the query cache.
|
|
const RESIGN_STALE_MS = 20 * 60 * 1000;
|
|
|
|
// useResignedInlineMediaURL upgrades an auth-gated media URL to a freshly
|
|
// signed one for clients that cannot load `/api/attachments/<id>/download`
|
|
// natively.
|
|
//
|
|
// The picked inline URL can end up being the stable per-attachment API
|
|
// endpoint (e.g. a reopened issue draft, whose persisted record deliberately
|
|
// strips the short-lived signed `download_url`). That endpoint needs
|
|
// credentials: web loads it because the session cookie rides on the <img>
|
|
// request (same-site), but Desktop's file:// renderer and the mobile webview
|
|
// are cross-site — no cookie is attached and the Bearer token cannot be put
|
|
// on a native resource fetch, so the image 401s. Those clients are exactly
|
|
// the ones with a non-empty `api.getBaseUrl()` (no same-origin /api proxy),
|
|
// which is the existing platform signal `absolutizeMediaURL` keys off.
|
|
//
|
|
// For them, fetch fresh attachment metadata through the authenticated API —
|
|
// the same re-sign the click-time download path already does — and swap in
|
|
// the response's signed `download_url`. When the server has no signed URL to
|
|
// offer (non-CloudFront deployments return the API path again), keep the
|
|
// original URL rather than looping.
|
|
function useResignedInlineMediaURL(
|
|
attachmentId: string | undefined,
|
|
pickedUrl: string,
|
|
): string {
|
|
const idFromPickedUrl = attachmentIdFromDownloadURL(pickedUrl);
|
|
const resignAttachmentId = attachmentId ?? idFromPickedUrl;
|
|
const needsResign =
|
|
!!resignAttachmentId &&
|
|
!!pickedUrl &&
|
|
idFromPickedUrl !== undefined &&
|
|
(api.getBaseUrl?.() ?? "") !== "";
|
|
|
|
const { data: fresh } = useQuery({
|
|
queryKey: ["attachment-inline-resign", resignAttachmentId],
|
|
queryFn: () => api.getAttachment(resignAttachmentId as string),
|
|
enabled: needsResign,
|
|
staleTime: RESIGN_STALE_MS,
|
|
gcTime: RESIGN_STALE_MS,
|
|
});
|
|
|
|
if (!needsResign) return pickedUrl;
|
|
const dl = fresh?.download_url ?? "";
|
|
// Accept the fresh URL only when it is an actual upgrade — absolute and no
|
|
// longer the auth-gated API shape (i.e. a signed storage URL the renderer
|
|
// can load natively).
|
|
if (/^https?:\/\//i.test(dl) && attachmentIdFromDownloadURL(dl) === undefined) {
|
|
return dl;
|
|
}
|
|
return pickedUrl;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dispatcher
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function Attachment({
|
|
attachment,
|
|
editable,
|
|
selected,
|
|
onDelete,
|
|
className,
|
|
}: AttachmentProps) {
|
|
const { resolveAttachment, openByUrl } = useAttachmentDownloadResolver();
|
|
const cdnDomain = useConfigStore((s) => s.cdnDomain);
|
|
const cdnSigned = useConfigStore((s) => s.cdnSigned);
|
|
const download = useDownloadAttachment();
|
|
const preview = useAttachmentPreview();
|
|
|
|
const state = normalize(attachment, resolveAttachment, cdnDomain, cdnSigned);
|
|
// The picked URL may still be the auth-gated API endpoint (reopened drafts
|
|
// whose persisted record has no signed download_url). Upgrade it to a
|
|
// freshly signed URL on clients that can't load the endpoint natively.
|
|
const mediaUrl = useResignedInlineMediaURL(state.attachmentId, state.url);
|
|
const forceKind =
|
|
attachment.kind === "url" ? attachment.forceKind : undefined;
|
|
const kind =
|
|
forceKind ??
|
|
(state.filename || state.contentType
|
|
? getPreviewKind(state.contentType, state.filename)
|
|
: null);
|
|
|
|
const openPreview = () => {
|
|
if (state.record) {
|
|
preview.tryOpen({
|
|
kind: "full",
|
|
attachment: {
|
|
...state.record,
|
|
download_url: mediaUrl || state.record.download_url,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (mediaUrl) {
|
|
preview.tryOpen({
|
|
kind: "url",
|
|
url: mediaUrl,
|
|
filename: state.filename,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDownload = () => {
|
|
if (state.attachmentId) {
|
|
download(state.attachmentId);
|
|
return;
|
|
}
|
|
if (mediaUrl) openByUrl(mediaUrl);
|
|
};
|
|
|
|
if (kind === "image") {
|
|
return (
|
|
<>
|
|
<ImageAttachmentView
|
|
src={mediaUrl}
|
|
alt={state.filename}
|
|
uploading={state.uploading}
|
|
width={state.width}
|
|
height={state.height}
|
|
editable={editable}
|
|
selected={selected}
|
|
onView={openPreview}
|
|
onDownload={handleDownload}
|
|
onDelete={onDelete}
|
|
className={className}
|
|
/>
|
|
{preview.modal}
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (kind === "html" && state.attachmentId && !state.uploading) {
|
|
return (
|
|
<>
|
|
<HtmlAttachmentPreview
|
|
attachmentId={state.attachmentId}
|
|
filename={state.filename}
|
|
onPreview={openPreview}
|
|
onDownload={handleDownload}
|
|
onDelete={editable ? onDelete : undefined}
|
|
/>
|
|
{preview.modal}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<AttachmentCard
|
|
filename={state.filename}
|
|
contentType={state.contentType}
|
|
attachmentId={state.attachmentId}
|
|
href={mediaUrl || undefined}
|
|
uploading={state.uploading}
|
|
onPreview={openPreview}
|
|
onDownload={handleDownload}
|
|
onDelete={editable ? onDelete : undefined}
|
|
/>
|
|
{preview.modal}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ImageAttachmentView — inline image with hover toolbar
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// DOM and styling are intentionally a direct port of the original
|
|
// extensions/image-view.tsx <figure> structure. Shared visual styles live in
|
|
// styles/attachment.css under `.image-figure / .image-content / .image-toolbar`
|
|
// so standalone surfaces (chat messages, AttachmentList) get identical visuals
|
|
// without depending on the editor stylesheet being imported elsewhere.
|
|
|
|
interface ImageAttachmentViewProps {
|
|
src: string;
|
|
alt: string;
|
|
uploading: boolean;
|
|
width?: number;
|
|
height?: number;
|
|
editable?: boolean;
|
|
selected?: boolean;
|
|
onView: () => void;
|
|
onDownload: () => void;
|
|
onDelete?: () => void;
|
|
className?: string;
|
|
}
|
|
|
|
function ImageAttachmentView({
|
|
src,
|
|
alt,
|
|
uploading,
|
|
width,
|
|
height,
|
|
editable,
|
|
selected,
|
|
onView,
|
|
onDownload,
|
|
onDelete,
|
|
className,
|
|
}: ImageAttachmentViewProps) {
|
|
const { t } = useT("editor");
|
|
|
|
const handleCopyLink = async () => {
|
|
if (await copyText(src)) {
|
|
toast.success(t(($) => $.image.link_copied));
|
|
} else {
|
|
toast.error(t(($) => $.image.copy_link_failed));
|
|
}
|
|
};
|
|
|
|
// Click on figure opens the preview only in non-editor / non-uploading
|
|
// surfaces — inside the editor we let ProseMirror own the click for
|
|
// selection / cursor placement and route preview through the explicit
|
|
// Maximize button. The CSS rule `.image-figure[data-clickable="true"] {
|
|
// cursor: zoom-in }` keys off this same flag for the cursor affordance.
|
|
const clickable = !editable && !uploading;
|
|
|
|
// DOM mirrors the original ReadonlyImage (span-only chain so it stays
|
|
// valid HTML5 when rendered inside a markdown <p>). In editor surfaces
|
|
// the NodeViewWrapper still emits its own outer .image-node div around
|
|
// this — the duplicate `image-node` class is harmless.
|
|
return (
|
|
<span className="image-node">
|
|
<span
|
|
className={cn(
|
|
"image-figure",
|
|
selected && editable && "image-selected",
|
|
className,
|
|
)}
|
|
data-clickable={clickable || undefined}
|
|
contentEditable={false}
|
|
onClick={clickable ? onView : undefined}
|
|
>
|
|
<img
|
|
src={src || undefined}
|
|
alt={alt}
|
|
width={width}
|
|
height={height}
|
|
className={cn("image-content", uploading && "image-uploading")}
|
|
draggable={false}
|
|
/>
|
|
{!uploading && src && (
|
|
<span
|
|
className="image-toolbar"
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button type="button" onClick={onView} title={t(($) => $.image.view)}>
|
|
<Maximize2 className="size-3.5" />
|
|
</button>
|
|
<button type="button" onClick={onDownload} title={t(($) => $.image.download)}>
|
|
<Download className="size-3.5" />
|
|
</button>
|
|
<button type="button" onClick={handleCopyLink} title={t(($) => $.image.copy_link)}>
|
|
<LinkIcon className="size-3.5" />
|
|
</button>
|
|
{editable && onDelete && (
|
|
<button type="button" onClick={onDelete} title={t(($) => $.image.delete)}>
|
|
<Trash2 className="size-3.5" />
|
|
</button>
|
|
)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|