Files
multica/packages/views/editor/attachment.tsx
Bohan Jiang 658e63d9be fix: prefer local upload attachment URLs (#4686)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 14:49:04 +08:00

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)
* - readonly-content.tsx (markdown img + fileCard div renderers)
* - issues/components/comment-card.tsx AttachmentList (standalone fallback)
* - common/markdown.tsx (chat / skill viewer Markdown wrapper)
*
* 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>
);
}