Files
multica/packages/views/editor/readonly-content.tsx
Bohan Jiang 352e838b01 fix(attachments): re-sign CloudFront download URLs at click time (#2407)
* fix(attachments): re-sign CloudFront download URLs at click time

The attachment download buttons opened `download_url` directly from cached
timeline/comment payloads. The signed URL is valid for 30 minutes, so a page
left open past that window would 403 with `AccessDenied` (MUL-2038 /
GitHub #2397).

- Add `GET /api/attachments/{id}` client method that re-signs on every call,
  validated by a stricter `AttachmentResponseSchema` (enforces `url`,
  `download_url`, `filename` so a malformed response degrades to the
  EMPTY_ATTACHMENT record instead of opening `undefined`).
- Introduce `useDownloadAttachment` hook with two execution shapes:
  - Web: synchronously open `about:blank` inside the click gesture to keep
    popup activation, then hydrate `location.href` after the fetch. Cannot
    pass `noopener` here — HTML spec dom-open step 17 makes that return
    null.
  - Desktop: skip the placeholder (Electron's setWindowOpenHandler rejects
    about:blank) and hand the fresh URL to `openExternal`.
- Wire the hook into the standalone attachment buttons (comment-card) and
  the inline `<img>` / file-card buttons inside `ReadonlyContent`. Inline
  buttons resolve the attachment id by URL match; external URLs fall back
  to `openExternal`.

Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): re-sign downloads from ContentEditor file/image NodeViews

The previous commit only wired the click-time fresh-sign through
ReadonlyContent + the standalone attachment list. The Tiptap NodeViews
inside ContentEditor still opened the raw URL with
`window.open(href, "_blank", "noopener,noreferrer")`, leaving two
download surfaces on stale signatures:

- Issue description (always renders via ContentEditor)
- Comment edit mode (transient ContentEditor instance)

- Add AttachmentDownloadContext + AttachmentDownloadProvider so NodeViews
  can resolve markdown URLs to an attachment id and call the existing
  `useDownloadAttachment` hook. The default fallback (no provider mounted)
  hands the raw URL to `openExternal`, keeping non-editor mounts unaffected.
- ContentEditor accepts `attachments?: Attachment[]` and wraps EditorContent
  with the provider.
- file-card.tsx and image-view.tsx NodeViews swap their `window.open(...)`
  calls for `openByUrl(href|src)` from the provider.
- issue-detail.tsx threads `useQuery(issueAttachmentsOptions(id))` into
  ContentEditor for the description.
- comment-card.tsx passes `entry.attachments` to both edit-mode editors.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 16:33:41 +08:00

432 lines
14 KiB
TypeScript

"use client";
/**
* ReadonlyContent — lightweight markdown renderer for readonly content display.
*
* Replaces <ContentEditor editable={false}> for comment cards and other
* read-only surfaces. Uses react-markdown instead of a full Tiptap/ProseMirror
* instance, eliminating EditorView, Plugin, and NodeView overhead.
*
* Visual parity with ContentEditor is achieved by:
* - Wrapping output in <div class="rich-text-editor readonly"> so the same
* content-editor.css rules apply to standard HTML tags
* - Using the same preprocessMarkdown pipeline (mention shortcodes + linkify)
* - Using lowlight for code highlighting (same engine as Tiptap's CodeBlockLowlight)
* so .hljs-* CSS rules from content-editor.css produce identical colors
* - Rendering mentions with the same IssueMentionCard component and .mention class
*/
import { isValidElement, memo, useCallback, useMemo, useRef, useState } from "react";
import ReactMarkdown, {
defaultUrlTransform,
type Components,
} from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { createLowlight, common } from "lowlight";
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
import { toHtml } from "hast-util-to-html";
import { Maximize2, Download, Link as LinkIcon, FileText } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { useWorkspacePaths, useWorkspaceSlug } from "@multica/core/paths";
import type { Attachment } from "@multica/core/types";
import { useNavigation } from "../navigation";
import { useT } from "../i18n";
import { openExternal } from "../platform";
import { IssueMentionCard } from "../issues/components/issue-mention-card";
import { ImageLightbox } from "./extensions/image-view";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import { openLink, isMentionHref } from "./utils/link-handler";
import { preprocessMarkdown } from "./utils/preprocess";
import { MermaidDiagram } from "./mermaid-diagram";
import { useDownloadAttachment } from "./use-download-attachment";
import "katex/dist/katex.min.css";
import "./content-editor.css";
// ---------------------------------------------------------------------------
// Lowlight — same engine + language set as Tiptap's CodeBlockLowlight
// ---------------------------------------------------------------------------
const lowlight = createLowlight(common);
// ---------------------------------------------------------------------------
// Sanitization schema — extends GitHub defaults to allow file-card data attrs
// ---------------------------------------------------------------------------
const sanitizeSchema = {
...defaultSchema,
protocols: {
...defaultSchema.protocols,
href: [...(defaultSchema.protocols?.href ?? []), "mention"],
},
attributes: {
...defaultSchema.attributes,
div: [
...(defaultSchema.attributes?.div ?? []),
"dataType",
"dataHref",
"dataFilename",
],
code: [
...(defaultSchema.attributes?.code ?? []),
["className", /^language-/],
["className", /^math-/],
["className", /^hljs/],
],
img: [
...(defaultSchema.attributes?.img ?? []),
"alt",
],
},
};
// ---------------------------------------------------------------------------
// URL transform — allow mention:// protocol through react-markdown's sanitizer
// ---------------------------------------------------------------------------
function urlTransform(url: string): string {
if (url.startsWith("mention://")) return url;
return defaultUrlTransform(url);
}
// ---------------------------------------------------------------------------
// Custom react-markdown components
// ---------------------------------------------------------------------------
function IssueMentionLink({ issueId, label }: { issueId: string; label?: string }) {
const { push, openInNewTab } = useNavigation();
const p = useWorkspacePaths();
const path = p.issueDetail(issueId);
return (
<span
className="inline align-middle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (e.metaKey || e.ctrlKey || e.shiftKey) {
if (openInNewTab) {
openInNewTab(path, label);
}
return;
}
push(path);
}}
>
<IssueMentionCard issueId={issueId} fallbackLabel={label} />
</span>
);
}
// Named component so it can call useWorkspaceSlug() — arrow function inlined
// inside `components` below would still work, but extracting it keeps the
// hook usage explicit and avoids hook-in-object-literal surprises.
function ReadonlyLink({
href,
children,
}: {
href?: string;
children?: React.ReactNode;
}) {
const slug = useWorkspaceSlug();
if (isMentionHref(href)) {
const match = href.match(/^mention:\/\/(member|agent|issue|all)\/(.+)$/);
if (match?.[1] === "issue" && match[2]) {
const label =
typeof children === "string"
? children
: Array.isArray(children)
? children.join("")
: undefined;
return <IssueMentionLink issueId={match[2]} label={label} />;
}
// Member / agent / all mentions
return <span className="mention">{children}</span>;
}
// Regular links — open directly on click
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
if (href) openLink(href, slug);
}}
>
{children}
</a>
);
}
// Image renderer with a download button that prefers fresh-signed URLs.
// Lifted out of the components map so it can call hooks; receives the
// attachment lookup as props so the components map can stay a pure
// data-build inside `ReadonlyContent`'s `useMemo`.
function ReadonlyImage({
src,
alt,
resolveAttachmentId,
onDownload,
}: {
src?: string;
alt?: string;
resolveAttachmentId: (url: string) => string | undefined;
onDownload: (attachmentId: string) => void;
}) {
const { t } = useT("editor");
const [lightbox, setLightbox] = useState(false);
const imgSrc = typeof src === "string" ? src : "";
const imgAlt = alt ?? "";
const handleView = () => setLightbox(true);
const handleDownload = () => {
const id = imgSrc ? resolveAttachmentId(imgSrc) : undefined;
if (id) {
onDownload(id);
return;
}
// External image — no attachment record to re-sign through. Falling back
// to `openExternal` keeps us off `window.open(...)` (which Electron's
// setWindowOpenHandler would route through openExternalSafely anyway,
// but only after rejecting non-http schemes loudly).
if (imgSrc) openExternal(imgSrc);
};
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(imgSrc);
toast.success(t(($) => $.image.link_copied));
} catch {
toast.error(t(($) => $.image.copy_link_failed));
}
};
return (
<span className="image-node">
<span className="image-figure" onClick={handleView}>
<img src={imgSrc} alt={imgAlt} className="image-content" draggable={false} />
<span
className="image-toolbar"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button type="button" onClick={handleView} title={t(($) => $.image.view)}>
<Maximize2 className="size-3.5" />
</button>
<button type="button" onClick={handleDownload} 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>
</span>
</span>
{lightbox && (
<ImageLightbox src={imgSrc} alt={imgAlt} onClose={() => setLightbox(false)} />
)}
</span>
);
}
// Inline file card — same download semantics as the standalone attachment
// list: fresh-sign through `useDownloadAttachment` when the href matches a
// known attachment, otherwise hand the raw URL to the platform's external
// opener.
function ReadonlyFileCard({
href,
filename,
resolveAttachmentId,
onDownload,
}: {
href: string;
filename: string;
resolveAttachmentId: (url: string) => string | undefined;
onDownload: (attachmentId: string) => void;
}) {
const handleClick = () => {
const id = resolveAttachmentId(href);
if (id) {
onDownload(id);
return;
}
openExternal(href);
};
return (
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
<FileText className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{filename}</p>
</div>
{href && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onClick={handleClick}
>
<Download className="size-3.5" />
</button>
)}
</div>
);
}
function buildComponents(
resolveAttachmentId: (url: string) => string | undefined,
onDownload: (attachmentId: string) => void,
): Partial<Components> {
return {
// Links — route mention:// to mention components, others show preview card
a: ReadonlyLink,
// Images — centered with toolbar + lightbox (matches Tiptap ImageView NodeView)
img: ({ src, alt }) => (
<ReadonlyImage
src={typeof src === "string" ? src : undefined}
alt={alt ?? undefined}
resolveAttachmentId={resolveAttachmentId}
onDownload={onDownload}
/>
),
// FileCard — intercept <div data-type="fileCard"> from preprocessMarkdown
div: ({ node, children, ...props }) => {
const dataType = node?.properties?.dataType as string | undefined;
if (dataType === "fileCard") {
const rawHref = (node?.properties?.dataHref as string) || "";
// Only allow http(s) URLs to prevent javascript: and other dangerous schemes.
const href = /^https?:\/\//i.test(rawHref) ? rawHref : "";
const filename = (node?.properties?.dataFilename as string) || "";
return (
<ReadonlyFileCard
href={href}
filename={filename}
resolveAttachmentId={resolveAttachmentId}
onDownload={onDownload}
/>
);
}
return <div {...props}>{children}</div>;
},
// Tables — wrap in tableWrapper div for border/radius/scroll (matches Tiptap)
table: ({ children }) => (
<div className="tableWrapper">
<table>{children}</table>
</div>
),
// Code — lowlight highlighting for blocks, plain render for inline
code: ({ className, children, node, ...props }) => {
const lang = /language-(\w+)/.exec(className || "")?.[1];
const isBlock =
node?.position &&
node.position.start.line !== node.position.end.line;
if (isBlock && lang === "mermaid") {
return <MermaidDiagram chart={String(children).replace(/\n$/, "")} />;
}
if (!isBlock && !lang) {
// Inline code — CSS handles styling via .rich-text-editor code
return <code {...props}>{children}</code>;
}
// Block code — highlight with lowlight, output hljs classes
const code = String(children).replace(/\n$/, "");
try {
const tree = lang
? lowlight.highlight(lang, code)
: lowlight.highlightAuto(code);
return (
<code
className={cn("hljs", lang && `language-${lang}`)}
dangerouslySetInnerHTML={{ __html: toHtml(tree) }}
/>
);
} catch {
// Fallback — render without highlighting
return (
<code className={className} {...props}>
{children}
</code>
);
}
},
// Pre — pass through (CSS handles styling via .rich-text-editor pre)
pre: ({ children }) => {
if (isValidElement(children) && children.type === MermaidDiagram) {
return <>{children}</>;
}
return <pre>{children}</pre>;
},
};
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface ReadonlyContentProps {
content: string;
className?: string;
/**
* Attachments associated with the surrounding entity (comment / issue
* body). When the markdown contains an inline `<img>` or file card whose
* URL matches one of these attachments, the download button re-signs the
* URL at click time via `useDownloadAttachment` instead of opening the
* potentially stale link embedded in the markdown.
*
* Callers SHOULD pass a stable reference (e.g. the field on a memoized
* timeline entry); a fresh array on every parent render busts the memo.
*/
attachments?: Attachment[];
}
// Memoized so a long timeline of comments (Inbox + IssueDetail) does not
// re-run the full react-markdown + rehype-* + lowlight pipeline on every
// parent re-render. Props are `content`/`className`/`attachments`, all
// shallow-comparable; stability is the caller's responsibility for the
// array.
export const ReadonlyContent = memo(function ReadonlyContent({
content,
className,
attachments,
}: ReadonlyContentProps) {
const processed = useMemo(() => preprocessMarkdown(content), [content]);
const wrapperRef = useRef<HTMLDivElement>(null);
const hover = useLinkHover(wrapperRef);
const download = useDownloadAttachment();
const resolveAttachmentId = useCallback(
(url: string): string | undefined => {
if (!url || !attachments?.length) return undefined;
return attachments.find((a) => a.url === url)?.id;
},
[attachments],
);
const components = useMemo(
() => buildComponents(resolveAttachmentId, download),
[resolveAttachmentId, download],
);
return (
<div ref={wrapperRef} className={cn("rich-text-editor readonly text-sm", className)}>
<ReactMarkdown
remarkPlugins={[remarkMath, remarkBreaks, [remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
urlTransform={urlTransform}
components={components}
>
{processed}
</ReactMarkdown>
<LinkHoverCard {...hover} />
</div>
);
});