mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* MUL-3130: persist a stable attachment download URL in comment markdown Comment image attachments rendered as broken placeholders ~30 minutes after upload because the editor was persisting a short-lived HMAC-signed URL into the comment body. After PR #3903 (MUL-3132) hardened /uploads/* with auth, `attachmentToResponse` started signing `attachment.url` as `/uploads/<key>?exp=<unix>&sig=<HMAC>` for LocalStorage so token-auth clients could keep loading inline images. The signature has a 30-min TTL by design — but `useFileUpload` was returning that signed value as `link` and the editor was writing `` straight into the markdown, so the comment permanently captured a URL that stopped working as soon as the signature expired. The fix is to persist a stable per-attachment URL that the server can re-sign on every request: * `useFileUpload` now returns `link = /api/attachments/<id>/download` (avatar uploads without an id still fall back to `att.url` so the pre-attachment-row code paths keep working). * `DownloadAttachment` self-resolves the workspace from the attachment row instead of reading X-Workspace-Slug / X-Workspace-ID headers, and the route is registered under the auth-only group so a native browser <img>/<video> resource load (which cannot attach those headers) succeeds. Membership is checked inside the handler with a 404 deny shape so the route does not act as an IDOR oracle. * A new `GetAttachmentByIDOnly` SQL query supports the workspace- derivation step. * `AttachmentDownloadProvider` now extracts the attachment id from the stable URL when matching markdown refs to attachment records, with a fallback to the existing url-equality check for legacy comments (and S3/CloudFront markdown that points straight at the CDN). * `contentReferencesAttachment` covers both URL shapes for the composer / standalone-list dedup paths so an attachment uploaded before the fix and one uploaded after both deduplicate cleanly. Tests: - New unit tests for the URL helpers (16 tests, packages/core). - Backend regression test: bare `<img src>`-style request without workspace headers now succeeds for a member (200) and 404s for a non-member, replacing the previous "400 without workspace context" contract. - Existing TestDownload*, TestServeLocalUpload*, TestAttachmentTo Response* and the 1220 frontend views tests all pass. Refs: MUL-3130, GitHub issue #3891 Co-authored-by: multica-agent <github@multica.ai> * MUL-3130: address PR review — split markdown link from upload link, swap render src Two follow-ups from GPT-Boy's review on PR #3937. (1) Don't reroute every upload consumer through the workspace-gated download endpoint. The previous change made `useFileUpload`'s `link` field unconditionally return `/api/attachments/<id>/download` whenever the upload had an id. But `useFileUpload` is also used by avatar / logo pickers (account-tab, workspace-tab, agents/avatar-picker, squads/squad-detail-page) that persist `result.link` directly into `avatar_url`. Avatars are referenced cross-workspace (mention chips, member lists, inbox items), so binding their URL to a workspace-membership-gated endpoint would silently break cross-workspace avatar visibility. The fix splits the URL into two semantically distinct fields: - `link` — same as `att.url` (legacy contract). Avatar / logo callers continue to use this and remain on whatever URL semantics the storage backend dictates. - `markdownLink` — the stable per-attachment URL `/api/attachments/<id>/download`. Only the editor's markdown-persisting flow consumes this. Falls back to `link` for the no-workspace upload branch (where there is no attachment-row id to address). `editor/extensions/file-upload.ts` switches `image.src` and `fileCard.href` to `markdownLink ?? link` so comment markdown gets the stable shape while avatar callers stay on `link` unchanged. (2) Make the render-time img src loadable for token-mode clients. Persisting the stable `/api/attachments/<id>/download` URL fixes the expiry problem but the path itself sits behind `middleware.Auth`, which expects either a `multica_auth` cookie or a Bearer token in `Authorization`. Native `<img>`/`<video>` resource loads from token-mode clients (Electron's default mode, the mobile app, legacy-token web sessions) cannot attach the Authorization header, so the bare URL would 401 immediately rather than 30 minutes later. `Attachment.normalize` now runs the resolved record through a new `pickInlineMediaURL` helper that returns: - `record.download_url` when it's an absolute URL with a recognised CDN signature query (CloudFront-signed `Signature` / `Expires` / `Key-Pair-Id`, or `X-Amz-Signature` for raw S3 presigns) — these load as native resource src in any client. - else `record.url`, which on the LocalStorage backend carries a freshly-minted `/uploads/<key>?exp&sig` query whose signature IS the auth (token-mode-loadable). On non-CF S3 backends this is the raw stored URL — same behaviour as today. - else the original input URL (legacy / unresolved markdown keeps its existing path). This gives the same effect for both `kind: "record"` and `kind: "url"` attachment inputs: once a record is in hand, the rendered media src is whichever URL the current backend exposes a working signature on. Tests: - New `file-upload.test.ts` regression pinning that `markdownLink` is what lands in the markdown body when the upload result returns both a short-lived storage URL and a stable download path. - Updated `attachment.test.tsx` to reflect the new render-time swap (the rendered img src now follows the freshly signed URL, not the raw storage URL) and added a record-mode regression pinning the LocalStorage default — when `download_url` is the bare /api/attachments/<id>/download path, the renderer must fall through to the signed `record.url`. - Updated `chat-input.test.tsx` makeUpload helper for the new `markdownLink` UploadResult field. - 1222 frontend views tests + 507 core tests + typecheck across @multica/{core,ui,views} all pass. Refs: MUL-3130, GitHub issue #3891. Builds ona740f7a35. Co-authored-by: multica-agent <github@multica.ai> * MUL-3130: chat upload map keys on persisted markdownLink, not the short-lived link GPT-Boy's second-round review on PR #3937 caught a chat-only blocker left over from the previous fix. After the previous commit split `UploadResult.link` into `link` (legacy avatar/logo URL) and `markdownLink` (stable per-attachment URL persisted into markdown), the comment editor's image src + file card href correctly switched to `markdownLink ?? link`. But chat input still kept the upload-map key on the old `link`: uploadMapRef.current.set(result.link, result.id) … if (content.includes(url)) activeIds.push(id) In the LocalStorage backend `link` is the short-lived `/uploads/<key>?exp=&sig=` URL. The editor persists the stable `/api/attachments/<id>/download` URL into the message body, so `content.includes(url)` never matches and the send call drops `attachment_ids`. The attachment ends up bound only to the chat session, not to the message — agents reading message-level metadata see no attachments. Fix: key the upload map on the same value the editor actually wrote into the markdown body (`markdownLink || link`). The `content.includes(url)` check then matches and the attachment id is correctly forwarded on send. Tests: - Updated the chat-input mock editor to insert `markdownLink || link` into its value, mirroring the real editor's persisted-URL choice (uploadAndInsertFile in editor/extensions/file-upload.ts). Without this the mock would silently paper over the bug. - Added a regression test where the upload result returns a short-lived `link = /uploads/...?exp&sig` and a stable `markdownLink = /api/attachments/<id>/download`. Asserts (a) the message body carries the stable URL and never the signed query, and (b) the bound `attachment_ids` includes the attachment id. All 1223 frontend views tests pass (was 1222, +1 new regression). Typecheck and 507 core tests still green. Refs: MUL-3130, PR #3937 review by GPT-Boy. Builds onf66a522d0. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
275 lines
9.3 KiB
TypeScript
275 lines
9.3 KiB
TypeScript
import { Extension } from "@tiptap/core";
|
|
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
|
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
|
import { createSafeId } from "@multica/core/utils";
|
|
|
|
/** Find and remove a fileCard node by uploadId. */
|
|
|
|
function removeUploadingFileCard(editor: any, uploadId: string) {
|
|
const { tr } = editor.state;
|
|
let deleted = false;
|
|
editor.state.doc.descendants((node: any, pos: number) => {
|
|
if (deleted) return false;
|
|
if (node.type.name === "fileCard" && node.attrs.uploadId === uploadId) {
|
|
tr.delete(pos, pos + node.nodeSize);
|
|
deleted = true;
|
|
return false;
|
|
}
|
|
return undefined;
|
|
});
|
|
if (deleted) editor.view.dispatch(tr);
|
|
}
|
|
|
|
/** Update a fileCard node from uploading state to final state with real URL. */
|
|
|
|
function finalizeFileCard(editor: any, uploadId: string, href: string) {
|
|
const { tr } = editor.state;
|
|
let updated = false;
|
|
editor.state.doc.descendants((node: any, nodePos: number) => {
|
|
if (updated) return false;
|
|
if (node.type.name === "fileCard" && node.attrs.uploadId === uploadId) {
|
|
tr.setNodeMarkup(nodePos, undefined, {
|
|
...node.attrs,
|
|
href,
|
|
uploading: false,
|
|
});
|
|
updated = true;
|
|
return false;
|
|
}
|
|
return undefined;
|
|
});
|
|
if (updated) editor.view.dispatch(tr);
|
|
}
|
|
|
|
export function findImagePosBySrc(editor: any, src: string): number | null {
|
|
if (!editor) return null;
|
|
let imagePos: number | null = null;
|
|
editor.state.doc.descendants((node: any, pos: number) => {
|
|
if (imagePos !== null) return false;
|
|
if (node.type.name === "image" && node.attrs.src === src) {
|
|
imagePos = pos;
|
|
return false;
|
|
}
|
|
return undefined;
|
|
});
|
|
return imagePos;
|
|
}
|
|
|
|
function removeImageBySrc(editor: any, src: string) {
|
|
const imagePos = findImagePosBySrc(editor, src);
|
|
if (imagePos === null) return;
|
|
|
|
const imageNode = editor.state.doc.nodeAt(imagePos);
|
|
if (!imageNode) return;
|
|
|
|
const tr = editor.state.tr.delete(imagePos, imagePos + imageNode.nodeSize);
|
|
editor.view.dispatch(tr);
|
|
}
|
|
|
|
/**
|
|
* Read an image's intrinsic pixel dimensions off-thread. Returns null when the
|
|
* decode fails or the API is unavailable (e.g. jsdom in tests, where
|
|
* `createImageBitmap` is undefined) — callers degrade to no reserved box.
|
|
*/
|
|
async function readImageDimensions(
|
|
file: File,
|
|
): Promise<{ width: number; height: number } | null> {
|
|
if (typeof createImageBitmap !== "function") return null;
|
|
try {
|
|
const bitmap = await createImageBitmap(file);
|
|
const dims = { width: bitmap.width, height: bitmap.height };
|
|
bitmap.close();
|
|
return dims.width > 0 && dims.height > 0 ? dims : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Measure the file's intrinsic size and write it onto the freshly-inserted
|
|
* image node so the browser reserves the box before decode (no layout shift).
|
|
* Fire-and-forget after insert: keyed on the blob `src`, so if the upload swap
|
|
* already replaced it we simply skip — the swap preserves any width/height we
|
|
* managed to set via `...imageNode.attrs`.
|
|
*/
|
|
async function applyImageDimensions(editor: any, file: File, src: string) {
|
|
const dims = await readImageDimensions(file);
|
|
if (!dims) return;
|
|
|
|
const imagePos = findImagePosBySrc(editor, src);
|
|
if (imagePos === null) return;
|
|
|
|
const imageNode = editor.state.doc.nodeAt(imagePos);
|
|
if (!imageNode || imageNode.attrs.width) return;
|
|
|
|
const tr = editor.state.tr.setNodeMarkup(imagePos, undefined, {
|
|
...imageNode.attrs,
|
|
width: dims.width,
|
|
height: dims.height,
|
|
});
|
|
editor.view.dispatch(tr);
|
|
}
|
|
|
|
function moveSelectionToParagraphAfterImage(editor: any, src: string) {
|
|
const imagePos = findImagePosBySrc(editor, src);
|
|
if (imagePos === null) return;
|
|
|
|
const imageNode = editor.state.doc.nodeAt(imagePos);
|
|
if (!imageNode) return;
|
|
|
|
const afterImagePos = imagePos + imageNode.nodeSize;
|
|
const $afterImage = editor.state.doc.resolve(afterImagePos);
|
|
if ($afterImage.nodeAfter?.type.name !== "paragraph") return;
|
|
|
|
const paragraphStart = afterImagePos + 1;
|
|
const tr = editor.state.tr
|
|
.setSelection(TextSelection.create(editor.state.doc, paragraphStart))
|
|
.scrollIntoView();
|
|
editor.view.dispatch(tr);
|
|
}
|
|
|
|
/**
|
|
* Shared upload flow: insert blob preview → upload → replace with real URL.
|
|
* Used by both paste/drop (at cursor) and button upload (at end of doc).
|
|
*/
|
|
export async function uploadAndInsertFile(
|
|
|
|
editor: any,
|
|
file: File,
|
|
handler: (file: File) => Promise<UploadResult | null>,
|
|
pos?: number,
|
|
) {
|
|
const isImage = file.type.startsWith("image/");
|
|
|
|
if (isImage) {
|
|
const blobUrl = URL.createObjectURL(file);
|
|
const imgAttrs = { src: blobUrl, alt: file.name, uploading: true };
|
|
if (pos !== undefined) {
|
|
editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run();
|
|
} else {
|
|
editor.chain().focus().setImage(imgAttrs).run();
|
|
moveSelectionToParagraphAfterImage(editor, blobUrl);
|
|
}
|
|
|
|
// Reserve the image box ASAP so the async decode doesn't shift layout.
|
|
// Fire-and-forget: must not delay the handler() call below, which the
|
|
// synchronous-insert contract (instant preview) depends on.
|
|
void applyImageDimensions(editor, file, blobUrl);
|
|
|
|
try {
|
|
const result = await handler(file);
|
|
if (result) {
|
|
const imagePos = findImagePosBySrc(editor, blobUrl);
|
|
const imageNode = imagePos === null ? null : editor.state.doc.nodeAt(imagePos);
|
|
if (imagePos !== null && imageNode) {
|
|
const tr = editor.state.tr.setNodeMarkup(imagePos, undefined, {
|
|
...imageNode.attrs,
|
|
// Persist the stable per-attachment URL into markdown so
|
|
// the comment doesn't capture a short-lived signed URL
|
|
// (MUL-3130). Falls back to `link` for the no-workspace
|
|
// avatar branch where there's no attachment-row id; that
|
|
// path is unreachable from comment/issue editors but the
|
|
// fallback keeps the contract consistent for any caller
|
|
// that drops in without an issue context.
|
|
src: result.markdownLink || result.link,
|
|
alt: result.filename,
|
|
uploading: false,
|
|
});
|
|
editor.view.dispatch(tr);
|
|
}
|
|
} else {
|
|
removeImageBySrc(editor, blobUrl);
|
|
}
|
|
} catch {
|
|
removeImageBySrc(editor, blobUrl);
|
|
} finally {
|
|
URL.revokeObjectURL(blobUrl);
|
|
}
|
|
} else {
|
|
// Non-image: insert skeleton fileCard → upload → finalize with real URL
|
|
const uploadId = createSafeId();
|
|
const cardAttrs = { filename: file.name, href: "", fileSize: file.size, uploading: true, uploadId };
|
|
const insertContent = { type: "fileCard", attrs: cardAttrs };
|
|
if (pos !== undefined) {
|
|
editor.chain().focus().insertContentAt(pos, insertContent).run();
|
|
} else {
|
|
editor.chain().focus().insertContent(insertContent).run();
|
|
}
|
|
|
|
try {
|
|
const result = await handler(file);
|
|
if (result) {
|
|
finalizeFileCard(editor, uploadId, result.markdownLink || result.link);
|
|
} else {
|
|
removeUploadingFileCard(editor, uploadId);
|
|
}
|
|
} catch {
|
|
removeUploadingFileCard(editor, uploadId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Deduplicate files from the same paste/drop event.
|
|
* macOS/Chrome can put the same file in the FileList twice. */
|
|
function dedupFiles(files: FileList): File[] {
|
|
const seen = new Set<string>();
|
|
return Array.from(files).filter((file) => {
|
|
const key = `${file.name}\0${file.size}\0${file.type}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export function createFileUploadExtension(
|
|
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
|
|
) {
|
|
return Extension.create({
|
|
name: "fileUpload",
|
|
addProseMirrorPlugins() {
|
|
const { editor } = this;
|
|
|
|
const handleFiles = async (files: FileList) => {
|
|
const handler = onUploadFileRef.current;
|
|
if (!handler) return false;
|
|
for (const file of dedupFiles(files)) {
|
|
await uploadAndInsertFile(editor, file, handler);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
return [
|
|
new Plugin({
|
|
key: new PluginKey("fileUpload"),
|
|
props: {
|
|
handlePaste(_view, event) {
|
|
const files = event.clipboardData?.files;
|
|
if (!files?.length) return false;
|
|
if (!onUploadFileRef.current) return false;
|
|
handleFiles(files);
|
|
return true;
|
|
},
|
|
handleDrop(view, event) {
|
|
const dragEvent = event as DragEvent;
|
|
const files = dragEvent.dataTransfer?.files;
|
|
if (!files?.length) return false;
|
|
const handler = onUploadFileRef.current;
|
|
if (!handler) return false;
|
|
// Resolve drop position from mouse coordinates.
|
|
// Only the first file uses the drop position; subsequent files
|
|
// append to the end to avoid stale position issues.
|
|
const dropPos = view.posAtCoords({ left: dragEvent.clientX, top: dragEvent.clientY });
|
|
const unique = dedupFiles(files);
|
|
for (let i = 0; i < unique.length; i++) {
|
|
const insertPos = i === 0 ? dropPos?.pos : undefined;
|
|
uploadAndInsertFile(editor, unique[i]!, handler, insertPos);
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
}),
|
|
];
|
|
},
|
|
});
|
|
}
|