mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +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>
95 lines
2.7 KiB
SQL
95 lines
2.7 KiB
SQL
-- name: CreateAttachment :one
|
|
INSERT INTO attachment (
|
|
id, workspace_id, issue_id, comment_id, chat_session_id,
|
|
uploader_type, uploader_id, filename, url, content_type, size_bytes
|
|
)
|
|
VALUES (
|
|
$1, $2, sqlc.narg(issue_id), sqlc.narg(comment_id), sqlc.narg(chat_session_id),
|
|
$3, $4, $5, $6, $7, $8
|
|
)
|
|
RETURNING *;
|
|
|
|
-- name: ListAttachmentsByIssue :many
|
|
SELECT * FROM attachment
|
|
WHERE issue_id = $1 AND workspace_id = $2
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: ListAttachmentsByComment :many
|
|
SELECT * FROM attachment
|
|
WHERE comment_id = $1 AND workspace_id = $2
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: GetAttachment :one
|
|
SELECT * FROM attachment
|
|
WHERE id = $1 AND workspace_id = $2;
|
|
|
|
-- name: GetAttachmentByIDOnly :one
|
|
-- Used by the download endpoint, which derives workspace context from the
|
|
-- attachment row itself rather than from request headers/query params. The
|
|
-- caller still has to verify the requester is a member of the returned
|
|
-- workspace_id before serving the bytes — this query is access-neutral on
|
|
-- purpose so a self-contained URL like /api/attachments/{id}/download can
|
|
-- work as a native <img>/<video> resource load (no header attachment).
|
|
SELECT * FROM attachment
|
|
WHERE id = $1;
|
|
|
|
-- name: ListAttachmentsByCommentIDs :many
|
|
SELECT * FROM attachment
|
|
WHERE comment_id = ANY($1::uuid[]) AND workspace_id = $2
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: ListAttachmentURLsByIssueOrComments :many
|
|
SELECT a.url FROM attachment a
|
|
WHERE a.issue_id = $1
|
|
OR a.comment_id IN (SELECT c.id FROM comment c WHERE c.issue_id = $1);
|
|
|
|
-- name: ListAttachmentURLsByCommentID :many
|
|
SELECT url FROM attachment
|
|
WHERE comment_id = $1;
|
|
|
|
-- name: LinkAttachmentsToComment :exec
|
|
UPDATE attachment
|
|
SET comment_id = $1
|
|
WHERE issue_id = $2
|
|
AND comment_id IS NULL
|
|
AND id = ANY($3::uuid[]);
|
|
|
|
-- name: ReplaceCommentAttachments :exec
|
|
UPDATE attachment
|
|
SET comment_id = CASE
|
|
WHEN id = ANY(sqlc.arg(attachment_ids)::uuid[]) THEN $1
|
|
ELSE NULL
|
|
END
|
|
WHERE issue_id = $2
|
|
AND (
|
|
comment_id = $1
|
|
OR (comment_id IS NULL AND id = ANY(sqlc.arg(attachment_ids)::uuid[]))
|
|
);
|
|
|
|
-- name: LinkAttachmentsToChatMessage :exec
|
|
UPDATE attachment
|
|
SET chat_message_id = $1
|
|
WHERE chat_session_id = $2
|
|
AND chat_message_id IS NULL
|
|
AND id = ANY($3::uuid[]);
|
|
|
|
-- name: ListAttachmentsByChatMessage :many
|
|
SELECT * FROM attachment
|
|
WHERE chat_message_id = $1 AND workspace_id = $2
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: ListAttachmentsByChatMessageIDs :many
|
|
SELECT * FROM attachment
|
|
WHERE chat_message_id = ANY($1::uuid[]) AND workspace_id = $2
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: LinkAttachmentsToIssue :exec
|
|
UPDATE attachment
|
|
SET issue_id = $1
|
|
WHERE workspace_id = $2
|
|
AND issue_id IS NULL
|
|
AND id = ANY($3::uuid[]);
|
|
|
|
-- name: DeleteAttachment :exec
|
|
DELETE FROM attachment WHERE id = $1 AND workspace_id = $2;
|