mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +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>
136 lines
5.6 KiB
TypeScript
136 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState, useCallback, useEffect } from "react";
|
|
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
|
|
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
|
import { SubmitButton } from "@multica/ui/components/common/submit-button";
|
|
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
|
import { api } from "@multica/core/api";
|
|
import type { Attachment } from "@multica/core/types";
|
|
import { contentReferencesAttachment } from "@multica/core/types";
|
|
import { enterKey, formatShortcut, modKey } from "@multica/core/platform";
|
|
import { useCommentDraftStore } from "@multica/core/issues/stores";
|
|
import { useT } from "../../i18n";
|
|
|
|
interface CommentInputProps {
|
|
issueId: string;
|
|
onSubmit: (content: string, attachmentIds?: string[]) => Promise<void>;
|
|
}
|
|
|
|
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
|
const { t } = useT("issues");
|
|
const editorRef = useRef<ContentEditorRef>(null);
|
|
// Read the persisted draft once on mount. ContentEditor only honors
|
|
// `defaultValue` at mount time, so this snapshot drives both the editor's
|
|
// initial content and the submit-button enable state — without this the
|
|
// button would be disabled even though the editor visibly contains text.
|
|
const draftKey = `new:${issueId}` as const;
|
|
const initialDraft = useCommentDraftStore.getState().getDraft(draftKey);
|
|
const [isEmpty, setIsEmpty] = useState(() => !initialDraft?.trim());
|
|
const [submitting, setSubmitting] = useState(false);
|
|
// Attachments uploaded in this composer session. Drives both:
|
|
// - submit-time `attachment_ids` payload (filtered to URLs still in markdown)
|
|
// - the editor's AttachmentDownloadProvider, so file-card Eye buttons can
|
|
// resolve text/code/markdown previews that require the attachment id.
|
|
const [pendingAttachments, setPendingAttachments] = useState<Attachment[]>([]);
|
|
const { uploadWithToast } = useFileUpload(api);
|
|
const { isDragOver, dropZoneProps } = useFileDropZone({
|
|
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
|
});
|
|
|
|
// Draft persistence. Hydrate from store on mount via `defaultValue` above
|
|
// (ContentEditorRef has no setContent, so this is the only injection point).
|
|
// Flush on every onUpdate (debounced upstream) + visibilitychange/pagehide
|
|
// so tab close / mobile background doesn't lose work. Cleared on submit.
|
|
const setDraft = useCommentDraftStore((s) => s.setDraft);
|
|
const clearDraft = useCommentDraftStore((s) => s.clearDraft);
|
|
useEffect(() => {
|
|
const flush = () => {
|
|
const md = editorRef.current?.getMarkdown();
|
|
if (md && md.trim().length > 0) setDraft(draftKey, md);
|
|
};
|
|
const onVis = () => { if (document.visibilityState === "hidden") flush(); };
|
|
document.addEventListener("visibilitychange", onVis);
|
|
window.addEventListener("pagehide", flush);
|
|
return () => {
|
|
document.removeEventListener("visibilitychange", onVis);
|
|
window.removeEventListener("pagehide", flush);
|
|
};
|
|
}, [draftKey, setDraft]);
|
|
|
|
const handleUpload = useCallback(async (file: File) => {
|
|
const result = await uploadWithToast(file, { issueId });
|
|
if (result) {
|
|
setPendingAttachments((prev) => [...prev, result]);
|
|
}
|
|
return result;
|
|
}, [uploadWithToast, issueId]);
|
|
|
|
const handleSubmit = async () => {
|
|
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
|
if (!content || submitting) return;
|
|
// Track every attachment whose stable download URL OR legacy
|
|
// storage URL is referenced in the markdown body. Both shapes
|
|
// can appear in the same comment during the MUL-3130 rollout —
|
|
// see contentReferencesAttachment for the rationale.
|
|
const activeIds = pendingAttachments
|
|
.filter((a) => contentReferencesAttachment(content, a))
|
|
.map((a) => a.id);
|
|
setSubmitting(true);
|
|
try {
|
|
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
|
|
editorRef.current?.clearContent();
|
|
setIsEmpty(true);
|
|
setPendingAttachments([]);
|
|
clearDraft(draftKey);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
{...dropZoneProps}
|
|
className="relative flex flex-col rounded-lg bg-card pb-8 ring-1 ring-border"
|
|
>
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
|
<ContentEditor
|
|
ref={editorRef}
|
|
defaultValue={initialDraft}
|
|
placeholder={t(($) => $.comment.leave_comment_placeholder)}
|
|
onUpdate={(md) => {
|
|
setIsEmpty(!md.trim());
|
|
// Debounced upstream (debounceMs=100). Persist on every tick so a
|
|
// reload or scroll-out-of-viewport restores work to the keystroke.
|
|
if (md.trim().length > 0) setDraft(draftKey, md);
|
|
else clearDraft(draftKey);
|
|
}}
|
|
onSubmit={handleSubmit}
|
|
onUploadFile={handleUpload}
|
|
debounceMs={100}
|
|
currentIssueId={issueId}
|
|
attachments={pendingAttachments}
|
|
enableSlashCommands
|
|
slashCommandMode="command"
|
|
/>
|
|
</div>
|
|
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
|
|
<FileUploadButton
|
|
size="sm"
|
|
multiple
|
|
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
|
/>
|
|
<SubmitButton
|
|
onClick={handleSubmit}
|
|
disabled={isEmpty}
|
|
loading={submitting}
|
|
tooltip={`${t(($) => $.comment.send_tooltip)} · ${formatShortcut(modKey, enterKey)}`}
|
|
/>
|
|
</div>
|
|
{isDragOver && <FileDropOverlay />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export { CommentInput };
|