Files
multica/server/pkg/db/generated/attachment.sql.go
Multica Eve 13e9485a3b MUL-3130: persist stable /api/attachments/<id>/download URL in comment markdown (#3937)
* 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
`![file](signed-url)` 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 on a740f7a35.
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 on f66a522d0.
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 14:26:36 +08:00

506 lines
14 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: attachment.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createAttachment = `-- 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, $9, $10, $11,
$3, $4, $5, $6, $7, $8
)
RETURNING id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id
`
type CreateAttachmentParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
UploaderType string `json:"uploader_type"`
UploaderID pgtype.UUID `json:"uploader_id"`
Filename string `json:"filename"`
Url string `json:"url"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
IssueID pgtype.UUID `json:"issue_id"`
CommentID pgtype.UUID `json:"comment_id"`
ChatSessionID pgtype.UUID `json:"chat_session_id"`
}
func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (Attachment, error) {
row := q.db.QueryRow(ctx, createAttachment,
arg.ID,
arg.WorkspaceID,
arg.UploaderType,
arg.UploaderID,
arg.Filename,
arg.Url,
arg.ContentType,
arg.SizeBytes,
arg.IssueID,
arg.CommentID,
arg.ChatSessionID,
)
var i Attachment
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.CommentID,
&i.UploaderType,
&i.UploaderID,
&i.Filename,
&i.Url,
&i.ContentType,
&i.SizeBytes,
&i.CreatedAt,
&i.ChatSessionID,
&i.ChatMessageID,
)
return i, err
}
const deleteAttachment = `-- name: DeleteAttachment :exec
DELETE FROM attachment WHERE id = $1 AND workspace_id = $2
`
type DeleteAttachmentParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) DeleteAttachment(ctx context.Context, arg DeleteAttachmentParams) error {
_, err := q.db.Exec(ctx, deleteAttachment, arg.ID, arg.WorkspaceID)
return err
}
const getAttachment = `-- name: GetAttachment :one
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id FROM attachment
WHERE id = $1 AND workspace_id = $2
`
type GetAttachmentParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetAttachment(ctx context.Context, arg GetAttachmentParams) (Attachment, error) {
row := q.db.QueryRow(ctx, getAttachment, arg.ID, arg.WorkspaceID)
var i Attachment
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.CommentID,
&i.UploaderType,
&i.UploaderID,
&i.Filename,
&i.Url,
&i.ContentType,
&i.SizeBytes,
&i.CreatedAt,
&i.ChatSessionID,
&i.ChatMessageID,
)
return i, err
}
const getAttachmentByIDOnly = `-- name: GetAttachmentByIDOnly :one
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id FROM attachment
WHERE id = $1
`
// 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).
func (q *Queries) GetAttachmentByIDOnly(ctx context.Context, id pgtype.UUID) (Attachment, error) {
row := q.db.QueryRow(ctx, getAttachmentByIDOnly, id)
var i Attachment
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.CommentID,
&i.UploaderType,
&i.UploaderID,
&i.Filename,
&i.Url,
&i.ContentType,
&i.SizeBytes,
&i.CreatedAt,
&i.ChatSessionID,
&i.ChatMessageID,
)
return i, err
}
const linkAttachmentsToChatMessage = `-- 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[])
`
type LinkAttachmentsToChatMessageParams struct {
ChatMessageID pgtype.UUID `json:"chat_message_id"`
ChatSessionID pgtype.UUID `json:"chat_session_id"`
Column3 []pgtype.UUID `json:"column_3"`
}
func (q *Queries) LinkAttachmentsToChatMessage(ctx context.Context, arg LinkAttachmentsToChatMessageParams) error {
_, err := q.db.Exec(ctx, linkAttachmentsToChatMessage, arg.ChatMessageID, arg.ChatSessionID, arg.Column3)
return err
}
const linkAttachmentsToComment = `-- name: LinkAttachmentsToComment :exec
UPDATE attachment
SET comment_id = $1
WHERE issue_id = $2
AND comment_id IS NULL
AND id = ANY($3::uuid[])
`
type LinkAttachmentsToCommentParams struct {
CommentID pgtype.UUID `json:"comment_id"`
IssueID pgtype.UUID `json:"issue_id"`
Column3 []pgtype.UUID `json:"column_3"`
}
func (q *Queries) LinkAttachmentsToComment(ctx context.Context, arg LinkAttachmentsToCommentParams) error {
_, err := q.db.Exec(ctx, linkAttachmentsToComment, arg.CommentID, arg.IssueID, arg.Column3)
return err
}
const linkAttachmentsToIssue = `-- name: LinkAttachmentsToIssue :exec
UPDATE attachment
SET issue_id = $1
WHERE workspace_id = $2
AND issue_id IS NULL
AND id = ANY($3::uuid[])
`
type LinkAttachmentsToIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Column3 []pgtype.UUID `json:"column_3"`
}
func (q *Queries) LinkAttachmentsToIssue(ctx context.Context, arg LinkAttachmentsToIssueParams) error {
_, err := q.db.Exec(ctx, linkAttachmentsToIssue, arg.IssueID, arg.WorkspaceID, arg.Column3)
return err
}
const listAttachmentURLsByCommentID = `-- name: ListAttachmentURLsByCommentID :many
SELECT url FROM attachment
WHERE comment_id = $1
`
func (q *Queries) ListAttachmentURLsByCommentID(ctx context.Context, commentID pgtype.UUID) ([]string, error) {
rows, err := q.db.Query(ctx, listAttachmentURLsByCommentID, commentID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var url string
if err := rows.Scan(&url); err != nil {
return nil, err
}
items = append(items, url)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAttachmentURLsByIssueOrComments = `-- 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)
`
func (q *Queries) ListAttachmentURLsByIssueOrComments(ctx context.Context, issueID pgtype.UUID) ([]string, error) {
rows, err := q.db.Query(ctx, listAttachmentURLsByIssueOrComments, issueID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var url string
if err := rows.Scan(&url); err != nil {
return nil, err
}
items = append(items, url)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAttachmentsByChatMessage = `-- name: ListAttachmentsByChatMessage :many
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id FROM attachment
WHERE chat_message_id = $1 AND workspace_id = $2
ORDER BY created_at ASC
`
type ListAttachmentsByChatMessageParams struct {
ChatMessageID pgtype.UUID `json:"chat_message_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) ListAttachmentsByChatMessage(ctx context.Context, arg ListAttachmentsByChatMessageParams) ([]Attachment, error) {
rows, err := q.db.Query(ctx, listAttachmentsByChatMessage, arg.ChatMessageID, arg.WorkspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Attachment{}
for rows.Next() {
var i Attachment
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.CommentID,
&i.UploaderType,
&i.UploaderID,
&i.Filename,
&i.Url,
&i.ContentType,
&i.SizeBytes,
&i.CreatedAt,
&i.ChatSessionID,
&i.ChatMessageID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAttachmentsByChatMessageIDs = `-- name: ListAttachmentsByChatMessageIDs :many
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id FROM attachment
WHERE chat_message_id = ANY($1::uuid[]) AND workspace_id = $2
ORDER BY created_at ASC
`
type ListAttachmentsByChatMessageIDsParams struct {
Column1 []pgtype.UUID `json:"column_1"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) ListAttachmentsByChatMessageIDs(ctx context.Context, arg ListAttachmentsByChatMessageIDsParams) ([]Attachment, error) {
rows, err := q.db.Query(ctx, listAttachmentsByChatMessageIDs, arg.Column1, arg.WorkspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Attachment{}
for rows.Next() {
var i Attachment
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.CommentID,
&i.UploaderType,
&i.UploaderID,
&i.Filename,
&i.Url,
&i.ContentType,
&i.SizeBytes,
&i.CreatedAt,
&i.ChatSessionID,
&i.ChatMessageID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAttachmentsByComment = `-- name: ListAttachmentsByComment :many
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id FROM attachment
WHERE comment_id = $1 AND workspace_id = $2
ORDER BY created_at ASC
`
type ListAttachmentsByCommentParams struct {
CommentID pgtype.UUID `json:"comment_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) ListAttachmentsByComment(ctx context.Context, arg ListAttachmentsByCommentParams) ([]Attachment, error) {
rows, err := q.db.Query(ctx, listAttachmentsByComment, arg.CommentID, arg.WorkspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Attachment{}
for rows.Next() {
var i Attachment
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.CommentID,
&i.UploaderType,
&i.UploaderID,
&i.Filename,
&i.Url,
&i.ContentType,
&i.SizeBytes,
&i.CreatedAt,
&i.ChatSessionID,
&i.ChatMessageID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAttachmentsByCommentIDs = `-- name: ListAttachmentsByCommentIDs :many
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id FROM attachment
WHERE comment_id = ANY($1::uuid[]) AND workspace_id = $2
ORDER BY created_at ASC
`
type ListAttachmentsByCommentIDsParams struct {
Column1 []pgtype.UUID `json:"column_1"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) ListAttachmentsByCommentIDs(ctx context.Context, arg ListAttachmentsByCommentIDsParams) ([]Attachment, error) {
rows, err := q.db.Query(ctx, listAttachmentsByCommentIDs, arg.Column1, arg.WorkspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Attachment{}
for rows.Next() {
var i Attachment
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.CommentID,
&i.UploaderType,
&i.UploaderID,
&i.Filename,
&i.Url,
&i.ContentType,
&i.SizeBytes,
&i.CreatedAt,
&i.ChatSessionID,
&i.ChatMessageID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAttachmentsByIssue = `-- name: ListAttachmentsByIssue :many
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id FROM attachment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at ASC
`
type ListAttachmentsByIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) ListAttachmentsByIssue(ctx context.Context, arg ListAttachmentsByIssueParams) ([]Attachment, error) {
rows, err := q.db.Query(ctx, listAttachmentsByIssue, arg.IssueID, arg.WorkspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Attachment{}
for rows.Next() {
var i Attachment
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.CommentID,
&i.UploaderType,
&i.UploaderID,
&i.Filename,
&i.Url,
&i.ContentType,
&i.SizeBytes,
&i.CreatedAt,
&i.ChatSessionID,
&i.ChatMessageID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const replaceCommentAttachments = `-- name: ReplaceCommentAttachments :exec
UPDATE attachment
SET comment_id = CASE
WHEN id = ANY($3::uuid[]) THEN $1
ELSE NULL
END
WHERE issue_id = $2
AND (
comment_id = $1
OR (comment_id IS NULL AND id = ANY($3::uuid[]))
)
`
type ReplaceCommentAttachmentsParams struct {
CommentID pgtype.UUID `json:"comment_id"`
IssueID pgtype.UUID `json:"issue_id"`
AttachmentIds []pgtype.UUID `json:"attachment_ids"`
}
func (q *Queries) ReplaceCommentAttachments(ctx context.Context, arg ReplaceCommentAttachmentsParams) error {
_, err := q.db.Exec(ctx, replaceCommentAttachments, arg.CommentID, arg.IssueID, arg.AttachmentIds)
return err
}