mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
* 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>
149 lines
4.4 KiB
TypeScript
149 lines
4.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { NodeViewWrapper } from "@tiptap/react";
|
|
import type { NodeViewProps } from "@tiptap/react";
|
|
import {
|
|
Maximize2,
|
|
Download,
|
|
Link as LinkIcon,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { useT } from "../../i18n";
|
|
import { useAttachmentDownloadResolver } from "../attachment-download-context";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lightbox — full-screen image preview (ESC or click backdrop to close)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ImageLightbox({
|
|
src,
|
|
alt,
|
|
onClose,
|
|
}: {
|
|
src: string;
|
|
alt: string;
|
|
onClose: () => void;
|
|
}) {
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
document.addEventListener("keydown", handler);
|
|
return () => document.removeEventListener("keydown", handler);
|
|
}, [onClose]);
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 cursor-zoom-out"
|
|
onClick={onClose}
|
|
>
|
|
<img
|
|
src={src}
|
|
alt={alt}
|
|
className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Image NodeView — renders img with hover toolbar + lightbox
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ImageView({ node, editor, selected, deleteNode }: NodeViewProps) {
|
|
const { t } = useT("editor");
|
|
const src = node.attrs.src as string;
|
|
const alt = (node.attrs.alt as string) || "";
|
|
const title = node.attrs.title as string | undefined;
|
|
const uploading = node.attrs.uploading as boolean;
|
|
const { openByUrl } = useAttachmentDownloadResolver();
|
|
|
|
const [lightbox, setLightbox] = useState(false);
|
|
const isEditable = editor.isEditable;
|
|
|
|
const handleView = () => setLightbox(true);
|
|
|
|
const handleDownload = () => {
|
|
// Cross-origin CDN images can't be fetched as blob (CORS),
|
|
// and <a download> is ignored for cross-origin URLs.
|
|
// Re-sign through the provider when the src maps to a known
|
|
// attachment; otherwise just open externally.
|
|
openByUrl(src);
|
|
};
|
|
|
|
const handleCopyLink = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(src);
|
|
toast.success(t(($) => $.image.link_copied));
|
|
} catch {
|
|
toast.error(t(($) => $.image.copy_link_failed));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<NodeViewWrapper className="image-node">
|
|
<figure
|
|
className={cn(
|
|
"image-figure",
|
|
selected && isEditable && "image-selected",
|
|
)}
|
|
contentEditable={false}
|
|
onClick={!isEditable && !uploading ? handleView : undefined}
|
|
>
|
|
<img
|
|
src={src}
|
|
alt={alt}
|
|
title={title || undefined}
|
|
className={cn("image-content", uploading && "image-uploading")}
|
|
draggable={false}
|
|
/>
|
|
{!uploading && (
|
|
<div
|
|
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>
|
|
{isEditable && (
|
|
<button
|
|
type="button"
|
|
onClick={() => deleteNode()}
|
|
title={t(($) => $.image.delete)}
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</figure>
|
|
{lightbox && (
|
|
<ImageLightbox
|
|
src={src}
|
|
alt={alt}
|
|
onClose={() => setLightbox(false)}
|
|
/>
|
|
)}
|
|
</NodeViewWrapper>
|
|
);
|
|
}
|
|
|
|
export { ImageView, ImageLightbox };
|