Files
multica/server/internal/handler/file.go
Naiyuan Qing a19e60a9e6 feat(chat): support images/files in agent chat replies (MUL-4287) (#5164)
* feat(chat): support images/files in agent chat replies (MUL-4287)

Agents can now attach images/files to their chat replies, matching how
comment attachments already work. The write-side gap was that the assistant
chat_message is synthesized server-side from the completion callback's text
output and never bound any attachments.

Backend:
- migration 150: nullable attachment.task_id (+ partial index), the transient
  handle that ties an agent's in-run upload to the reply it produces.
- POST /api/upload-file accepts task_id: gated to the task's own agent, in
  this workspace, on a chat task; tags the row with task_id + chat_session_id.
- CompleteTask (chat branch) binds the task's still-unclaimed attachments to
  the assistant message via BindChatAttachmentsToMessage (rejects rows already
  owned by an issue/comment/chat_message). An empty-output reply that produced
  files still creates a message so the images have an owner. FailTask binds
  nothing.

CLI:
- `multica attachment upload <path>` uploads a file for the current chat task
  (task from MULTICA_TASK_ID or --task) and prints id / markdown_url / a
  ready-to-paste markdown snippet.

Prompt:
- web/mobile chat prompt tells the agent how to attach a file to its reply.

Mobile:
- chat:done handler now always invalidates the messages list so attachments
  (absent from the event payload) refetch; mirrors web's self-heal.
- chat bubbles render standalone attachment cards via the existing
  CommentAttachmentList (dedup vs inline references), matching web.

Web/desktop needed no change — they already render message.attachments inline
and via AttachmentList, and self-heal on chat:done.

Tests: upload permission/isolation, bind-on-complete, empty-output+attachments,
FailTask no-bind, null task_id untouched, already-owned not stolen, CLI output
contract, mobile refetch-on-done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): address review blockers on chat reply attachments (MUL-4287)

Two final-review blockers on PR #5164:

1. Mobile inline dedup only checked raw `url`, so an attachment referenced
   inline via `markdown_url` (exactly what the CLI snippet emits) rendered
   twice — once inline, once as a standalone card. Reuse the core
   `contentReferencesAttachment` helper so dedup covers every real reference
   form (stable /api/attachments/<id>/download path, url, download_url,
   markdown_url), matching web's AttachmentList. Extracted the filter into a
   pure `lib/attachment-dedup.ts` so it is unit-testable, and added a
   regression test covering `content` containing `attachment.markdown_url`
   (plus the other URL forms and same-identity sibling dedup).

2. CLI `attachment upload` emitted `![...]` image markdown for every file,
   producing a broken-image snippet for non-images. Emit image markdown only
   for image/* content types and a plain link otherwise, with a CLI contract
   test for both.

Approved scope otherwise unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): renumber attachment_task_id migration 150 -> 157 after main merge (MUL-4287)

Merged latest main; main renumbered its migrations and now occupies 150-156,
so 150_attachment_task_id collided with 150_agent_task_coalesced_comments and
would fail TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Renamed to the
next unique prefix (157). No content change; migrate up applies cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): render agent-produced files as attachment cards, not raw links

The chat upload command handed the agent a bare `[name](url)` markdown
snippet. Pasted mid-sentence it renders as a plain text link (not a card),
and the referenced URL hides the auto-bound standalone attachment — so a
file the agent produced could end up showing as nothing.

Return the block-level `!file[name](url)` card syntax instead (images keep
`![name](url)` inline), and markdown-escape the filename so names with `[`/`]`
don't truncate the label. The prompt and CLI help now state the file
auto-attaches below the reply and the snippet is optional, only for placement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): soften message-list scroll fade (32px → 16px)

The 32px edge fade washed out full-bleed content (HTML / image previews)
at the list edges. Halve the fade distance so it barely grazes previews
while still hinting at more content above/below.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): renumber attachment_task_id migration 157 -> 158

main landed 157_agent_task_delivered_comments while this branch was open,
colliding on prefix 157 and failing TestMigrationNumericPrefixesStayUniqueAfterLegacySet.
Bump this PR's migration to the next free prefix (158). Rename only; the
migration body (nullable attachment.task_id + partial index) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): pin attachment upload to the token's task; build index concurrently

Two code-review findings on the chat-attachment path (MUL-4287):

- Isolation/privacy: POST /api/upload-file only checked the form task_id
  belonged to the caller's agent, not that it matched the task-scoped token's
  authoritative X-Task-ID. A run authorized for task A could tag an attachment
  onto task B (another chat task of the same agent, possibly another user's
  session), binding it into that reply on completion. Require the form task_id
  to equal the server-set X-Task-ID; add a same-agent/other-task 403 regression.

- Migration: split the task_id lookup index into its own migration (159) built
  with CREATE INDEX CONCURRENTLY (repo convention) — it cannot share a
  multi-command file with the ADD COLUMN in 158.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): enforce task-token source on attachment upload; drop transient task_id FK (MUL-4287)

Addresses the two remaining Preflight BLOCKERs on PR #5164.

Security (file.go): the task_id upload path compared the form task_id to
X-Task-ID but did not require X-Actor-Source=task_token. A normal JWT/mul_ PAT
leaves that header empty and the middleware does NOT strip a client-forged
X-Task-ID; resolveActor's fallback accepts a valid X-Agent-ID+X-Task-ID pair.
So a member who learned a task ID could forge both and inject an attachment
onto another chat task's assistant reply (cross-session/privacy leak). Now the
branch requires X-Actor-Source=task_token first (mirrors chat_history.go's
load-bearing boundary), then pins to the middleware-injected X-Task-ID. Tests
now go through the real task-token headers and add a forged-JWT-403 regression.

Migration (158): task_id is a transient binding handle (written once at upload
against an already-validated task, read only during that task's own
completion; durable owner is chat_message_id). There is no app-layer path that
hard-deletes agent_task_queue rows, and orphan uploads are already reaped by
attachment.chat_session_id's ON DELETE CASCADE — so an FK here would only add a
cascade dependency the app never relies on plus write overhead on the hot
attachment table. Drop the FK; task_id is now a plain UUID column. Added a
regression test that an unbound task-tagged upload is reaped on chat_session
delete. Index (159, CONCURRENTLY) unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(mobile): align !file card preprocess with web parser + CLI escaped labels (MUL-4287)

Howard final-review blocker: mobile's `!file[...]` preprocess didn't keep up
with the CLI's file-card output, so agent-produced non-image files rendered
nowhere on mobile.

- `FILE_LINE_RE` used `[^\]]+` for the label, so the CLI's escaped-bracket
  output `!file[a\]b.pdf](url)` (cmd_attachment.go escapeMarkdownLabel) never
  matched — the line stayed literal AND `standaloneAttachments` still hid the
  fallback card (the URL is in `content`), so the file showed nowhere.
- Align the matcher with web's `packages/ui/markdown/file-cards.ts`: label
  allows backslash-escaped metacharacters (ReDoS-safe class), and the URL is
  restricted to the same allowlist (site-relative /uploads + /api/attachments/
  <UUID>/download, plus absolute http(s)); disallowed schemes stay plain text.
- Unescape the label to the real filename, then re-escape only the chars that
  would break a markdown LINK label (mobile emits `[📎 name](url)`, re-parsed
  by the renderer — unlike web's HTML data-filename), so a raw `]` never
  truncates the link text.

No dedup change: once the inline `!file` renders, hiding the standalone card is
correct. Added focused unit tests covering the escaped-label case, parens/
backslash unescape, the site-relative URL form, and disallowed-scheme rejection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 09:15:36 +08:00

1336 lines
49 KiB
Go

package handler
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/netip"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/storage"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// extContentTypes overrides http.DetectContentType for extensions it gets wrong.
// Go's sniffer returns text/xml for SVG, text/plain for CSS/JS, etc.
var extContentTypes = map[string]string{
".svg": "image/svg+xml",
".css": "text/css",
".js": "application/javascript",
".mjs": "application/javascript",
".json": "application/json",
".wasm": "application/wasm",
}
const maxUploadSize = 100 << 20 // 100 MB
const defaultAttachmentDownloadURLTTL = 30 * time.Minute
type attachmentDownloadMode string
const (
attachmentDownloadModeAuto attachmentDownloadMode = "auto"
attachmentDownloadModeCloudFront attachmentDownloadMode = "cloudfront"
attachmentDownloadModePresign attachmentDownloadMode = "presign"
attachmentDownloadModeProxy attachmentDownloadMode = "proxy"
)
// maxPreviewTextSize caps the body the preview proxy will load into memory
// for text-based types. Anything larger returns 413 and the UI falls back
// to "please download". Sized so a typical README/source-file fits but a
// 100 MB log dump can't blow up the renderer.
const maxPreviewTextSize = 2 << 20 // 2 MB
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
type AttachmentResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
IssueID *string `json:"issue_id"`
CommentID *string `json:"comment_id"`
ChatSessionID *string `json:"chat_session_id"`
ChatMessageID *string `json:"chat_message_id"`
UploaderType string `json:"uploader_type"`
UploaderID string `json:"uploader_id"`
Filename string `json:"filename"`
URL string `json:"url"`
DownloadURL string `json:"download_url"`
// MarkdownURL is the durable, absolute-when-possible URL the client
// SHOULD persist into markdown bodies (issue descriptions, comments,
// chat messages). It is computed per deployment policy by
// buildMarkdownURL — preferring the storage URL when it is already a
// public, durable absolute URL (public CDN / LocalStorage with
// MULTICA_LOCAL_UPLOAD_BASE_URL), and otherwise prefixing
// MULTICA_PUBLIC_URL onto the stable per-attachment endpoint that the
// server self-resigns / proxies on every request.
//
// Why a separate field from URL / DownloadURL:
// - URL is the raw storage object URL — fine for avatar/logo
// surfaces but may be private (S3 + CloudFront-signed mode) or
// site-relative (LocalStorage with no base URL configured).
// - DownloadURL is the URL the renderer uses for THIS response — it
// can be a short-lived signed URL (CloudFront, S3 presign) and
// therefore must NOT be persisted. It expires.
// - MarkdownURL is contracted to be persistable: it never carries a
// TTL, and on every supported deployment shape it is loadable as
// a native browser resource fetch (no Authorization header required
// beyond the cookies/credentials the client already has on the
// resolved host).
//
// MUL-3192 — fixes the Desktop / mobile-webview regression where the
// previous site-relative `/api/attachments/<id>/download` link only
// resolved when the document origin proxied /api to the API host.
MarkdownURL string `json:"markdown_url"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
}
func (h *Handler) attachmentToResponse(a db.Attachment) AttachmentResponse {
id := uuidToString(a.ID)
resp := AttachmentResponse{
ID: id,
WorkspaceID: uuidToString(a.WorkspaceID),
UploaderType: a.UploaderType,
UploaderID: uuidToString(a.UploaderID),
Filename: a.Filename,
URL: a.Url,
DownloadURL: attachmentDownloadPath(id),
MarkdownURL: h.buildMarkdownURL(a, id),
ContentType: a.ContentType,
SizeBytes: a.SizeBytes,
CreatedAt: a.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
}
if h.CFSigner != nil {
resp.DownloadURL = h.CFSigner.SignedURL(a.Url, time.Now().Add(h.attachmentDownloadURLTTL()))
}
if a.IssueID.Valid {
s := uuidToString(a.IssueID)
resp.IssueID = &s
}
if a.CommentID.Valid {
s := uuidToString(a.CommentID)
resp.CommentID = &s
}
if a.ChatSessionID.Valid {
s := uuidToString(a.ChatSessionID)
resp.ChatSessionID = &s
}
if a.ChatMessageID.Valid {
s := uuidToString(a.ChatMessageID)
resp.ChatMessageID = &s
}
return resp
}
func attachmentDownloadPath(id string) string {
return "/api/attachments/" + id + "/download"
}
// buildMarkdownURL chooses the durable URL the client persists into
// markdown bodies. The contract is "absolute, no TTL, loadable as a native
// browser resource fetch on every supported client" (MUL-3192).
//
// Decision:
//
// 1. Persist `a.Url` only when the deployment has signaled the storage
// backend serves URLs publicly without per-request auth:
// - `Storage.CdnDomain()` is non-empty (operator configured a
// public-facing base URL — `S3_CDN_DOMAIN` for the S3 backend or
// `LOCAL_UPLOAD_BASE_URL` for LocalStorage), AND
// - `h.CFSigner` is nil (no per-request CloudFront signing — when
// signing is on, the same CDN domain serves PRIVATE content via
// time-bounded signed URLs and the raw `a.Url` is unauth-deny),
// AND
// - `a.Url` is itself an absolute http(s) URL with no signature
// query — defends against legacy rows backfilled while baseURL
// was unset, and against a freshly-signed `download_url` ever
// leaking into `a.Url` (the original MUL-3130 bug).
//
// 2. Every other shape — CloudFront-signed mode, S3 presign /proxy
// against a private bucket without a CDN domain, raw S3 / R2 /
// MinIO, LocalStorage with no `LOCAL_UPLOAD_BASE_URL` — uses the
// stable per-attachment endpoint that the server self-signs /
// proxies on every request, anchored on `MULTICA_PUBLIC_URL` so the
// persisted URL keeps working for clients that don't share the
// document origin (Desktop / mobile webview).
//
// 3. Last-resort fallback (no `MULTICA_PUBLIC_URL` configured): emit
// the site-relative path. Web's Next.js rewrite handles this; non-
// web clients on a deployment without `PublicURL` configured were
// already broken before MUL-3192 and stay broken here, but we
// don't make them worse.
func (h *Handler) buildMarkdownURL(a db.Attachment, id string) string {
relPath := attachmentDownloadPath(id)
publicURL := strings.TrimRight(h.cfg.PublicURL, "/")
if h.storageURLIsPubliclyReadable(a.Url) {
return a.Url
}
if publicURL != "" {
return publicURL + relPath
}
return relPath
}
// storageURLIsPubliclyReadable returns true when the deployment has signaled
// that `a.Url` can be loaded directly by an unauthenticated native browser
// fetch — the only case where it is safe to persist `a.Url` into a markdown
// body that will outlive the current session.
func (h *Handler) storageURLIsPubliclyReadable(rawURL string) bool {
if h.Storage == nil || h.CFSigner != nil {
// CFSigner != nil is per-request signing; the CDN domain serves
// private content via signed URLs and `a.Url` is the raw S3 URL.
return false
}
if h.Storage.CdnDomain() == "" {
// No public-facing base URL configured — the storage's URL is
// the raw private object URL (S3 / R2 / MinIO) or a site-relative
// LocalStorage path that doesn't carry an origin.
return false
}
return isDurablePublicURL(rawURL)
}
// isDurablePublicURL is true when `rawURL` is an absolute http(s) URL that
// is safe to persist into long-lived markdown bodies — i.e. it carries no
// CloudFront / S3 signature query that would make it expire.
func isDurablePublicURL(rawURL string) bool {
if rawURL == "" {
return false
}
u, err := url.Parse(rawURL)
if err != nil {
return false
}
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
if u.Host == "" {
return false
}
q := u.Query()
for _, k := range []string{
"Signature",
"X-Amz-Signature",
"Key-Pair-Id",
"Expires",
"X-Amz-Expires",
} {
if q.Get(k) != "" {
return false
}
}
return true
}
func normalizeAttachmentDownloadMode(raw string) (attachmentDownloadMode, bool) {
switch attachmentDownloadMode(strings.ToLower(strings.TrimSpace(raw))) {
case "", attachmentDownloadModeAuto:
return attachmentDownloadModeAuto, true
case attachmentDownloadModeCloudFront:
return attachmentDownloadModeCloudFront, true
case attachmentDownloadModePresign:
return attachmentDownloadModePresign, true
case attachmentDownloadModeProxy:
return attachmentDownloadModeProxy, true
default:
return attachmentDownloadModeAuto, false
}
}
func (h *Handler) attachmentDownloadMode() attachmentDownloadMode {
mode, _ := normalizeAttachmentDownloadMode(h.cfg.AttachmentDownloadMode)
return mode
}
func (h *Handler) attachmentDownloadURLTTL() time.Duration {
if h.cfg.AttachmentDownloadURLTTL > 0 {
return h.cfg.AttachmentDownloadURLTTL
}
return defaultAttachmentDownloadURLTTL
}
// groupAttachments loads attachments for multiple comments and groups them by comment ID.
func (h *Handler) groupAttachments(r *http.Request, commentIDs []pgtype.UUID) map[string][]AttachmentResponse {
if len(commentIDs) == 0 {
return nil
}
workspaceID := h.resolveWorkspaceID(r)
attachments, err := h.Queries.ListAttachmentsByCommentIDs(r.Context(), db.ListAttachmentsByCommentIDsParams{
Column1: commentIDs,
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
slog.Error("failed to load attachments for comments", "error", err)
return nil
}
grouped := make(map[string][]AttachmentResponse, len(commentIDs))
for _, a := range attachments {
cid := uuidToString(a.CommentID)
grouped[cid] = append(grouped[cid], h.attachmentToResponse(a))
}
return grouped
}
// groupChatMessageAttachments loads attachments for multiple chat messages
// and groups them by chat_message_id. Mirrors groupAttachments — used so the
// chat message list can surface attachment metadata to the UI bubble (file
// cards, click-through download) without an N+1 query per message.
func (h *Handler) groupChatMessageAttachments(ctx context.Context, workspaceID string, messageIDs []pgtype.UUID) map[string][]AttachmentResponse {
if len(messageIDs) == 0 {
return nil
}
attachments, err := h.Queries.ListAttachmentsByChatMessageIDs(ctx, db.ListAttachmentsByChatMessageIDsParams{
Column1: messageIDs,
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
slog.Error("failed to load attachments for chat messages", "error", err)
return nil
}
grouped := make(map[string][]AttachmentResponse, len(messageIDs))
for _, a := range attachments {
mid := uuidToString(a.ChatMessageID)
grouped[mid] = append(grouped[mid], h.attachmentToResponse(a))
}
return grouped
}
// ---------------------------------------------------------------------------
// UploadFile — POST /api/upload-file
// ---------------------------------------------------------------------------
func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
if h.Storage == nil {
writeError(w, http.StatusServiceUnavailable, "file upload not configured")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := h.resolveWorkspaceID(r)
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
writeError(w, http.StatusBadRequest, "file too large or invalid multipart form")
return
}
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("missing file field: %v", err))
return
}
defer file.Close()
// Sniff actual content type from file bytes instead of trusting the client header.
buf := make([]byte, 512)
n, err := file.Read(buf)
if err != nil && err != io.EOF {
writeError(w, http.StatusBadRequest, "failed to read file")
return
}
contentType := http.DetectContentType(buf[:n])
// Override with extension-based type when the sniffer gets it wrong.
if ct, ok := extContentTypes[strings.ToLower(path.Ext(header.Filename))]; ok {
contentType = ct
}
// Seek back so the full file is uploaded.
if _, err := file.Seek(0, io.SeekStart); err != nil {
writeError(w, http.StatusInternalServerError, "failed to read file")
return
}
data, err := io.ReadAll(file)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read file")
return
}
// Generate a UUIDv7 to use as both the attachment ID and S3 key.
id, err := uuid.NewV7()
if err != nil {
slog.Error("failed to generate uuid", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
filename := id.String() + path.Ext(header.Filename)
var key string
if workspaceID != "" {
key = "workspaces/" + workspaceID + "/" + filename
} else {
key = "users/" + userID + "/" + filename
}
// If workspace context is available, validate membership before uploading.
if workspaceID != "" {
if _, err := h.getWorkspaceMember(r.Context(), userID, workspaceID); err != nil {
writeError(w, http.StatusForbidden, "not a member of this workspace")
return
}
uploaderType, uploaderID := h.resolveActor(r, userID, workspaceID)
params := db.CreateAttachmentParams{
ID: pgtype.UUID{Bytes: id, Valid: true},
WorkspaceID: parseUUID(workspaceID),
UploaderType: uploaderType,
UploaderID: parseUUID(uploaderID),
Filename: header.Filename,
ContentType: contentType,
SizeBytes: int64(len(data)),
}
if issueID := r.FormValue("issue_id"); issueID != "" {
issueUUID, ok := parseUUIDOrBadRequest(w, issueID, "issue_id")
if !ok {
return
}
issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
ID: issueUUID,
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusForbidden, "invalid issue_id")
return
}
params.IssueID = issue.ID
}
if commentID := r.FormValue("comment_id"); commentID != "" {
commentUUID, ok := parseUUIDOrBadRequest(w, commentID, "comment_id")
if !ok {
return
}
comment, err := h.Queries.GetComment(r.Context(), commentUUID)
if err != nil || uuidToString(comment.WorkspaceID) != workspaceID {
writeError(w, http.StatusForbidden, "invalid comment_id")
return
}
params.CommentID = comment.ID
}
if chatSessionID := r.FormValue("chat_session_id"); chatSessionID != "" {
// Re-use the existing private-agent gate so the user can still
// reach this session — covers role downgrade and agent
// visibility flips. The gate writes 4xx on failure.
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, chatSessionID)
if !ok {
return
}
params.ChatSessionID = session.ID
}
// task_id upload: an agent producing an image/file for its chat reply.
// The row is tagged with the producing task and its chat session so
// CompleteTask can bind it to the assistant message it synthesizes.
// Gate: the request must come from a task-scoped token, the form task_id
// must equal that token's task, the caller must be that task's agent,
// and it must be a chat task (has a chat_session_id).
if taskID := r.FormValue("task_id"); taskID != "" {
// Authoritative task-token boundary (load-bearing, mirrors
// chat_history.go:chatHistorySession). X-Task-ID is only trustworthy
// when the auth middleware set it from a task-scoped `mat_` token —
// that path is also the ONLY one that stamps X-Actor-Source=task_token
// and strips a client-forged X-Task-ID. A normal JWT / `mul_` PAT
// leaves X-Actor-Source empty and does NOT strip a forged X-Task-ID,
// and resolveActor's fallback will accept a real X-Agent-ID +
// X-Task-ID pair. So without this gate a member who learns a task ID
// could forge both headers and inject an attachment onto another chat
// task's assistant reply — a cross-session/privacy leak.
if r.Header.Get("X-Actor-Source") != "task_token" {
writeError(w, http.StatusForbidden, "task_id upload is only available from within an agent task")
return
}
taskUUID, ok := parseUUIDOrBadRequest(w, taskID, "task_id")
if !ok {
return
}
// Pin to the run's own task: the middleware-injected X-Task-ID is the
// single source of truth for which task this token may act on, so a
// run authorized for task A cannot tag an attachment onto task B —
// even another chat task of the same agent, whose session may belong
// to a different user.
boundTaskID := strings.TrimSpace(r.Header.Get("X-Task-ID"))
if boundTaskID == "" || !strings.EqualFold(boundTaskID, strings.TrimSpace(taskID)) {
writeError(w, http.StatusForbidden, "task_id must match the request's task token")
return
}
task, err := h.Queries.GetAgentTaskInWorkspace(r.Context(), db.GetAgentTaskInWorkspaceParams{
ID: taskUUID,
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusForbidden, "invalid task_id")
return
}
if uploaderType != "agent" || uuidToString(task.AgentID) != uploaderID {
writeError(w, http.StatusForbidden, "task_id upload requires the task's own agent")
return
}
if !task.ChatSessionID.Valid {
writeError(w, http.StatusBadRequest, "task_id upload requires a chat task")
return
}
params.TaskID = task.ID
// Bind the session too so reads (groupChatMessageAttachments) and
// GC classify the row consistently before it gains a message id.
params.ChatSessionID = task.ChatSessionID
}
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
if err != nil {
slog.Error("file upload failed", "error", err)
writeError(w, http.StatusInternalServerError, "upload failed")
return
}
params.Url = link
att, err := h.Queries.CreateAttachment(r.Context(), params)
if err != nil {
slog.Error("failed to create attachment record", "error", err)
// S3 upload succeeded but DB record failed — still return the link
// so the file is usable. Log the error for investigation.
} else {
writeJSON(w, http.StatusOK, h.attachmentToResponse(att))
return
}
writeJSON(w, http.StatusOK, map[string]string{
"id": "",
"url": link,
"filename": header.Filename,
})
return
}
// No workspace context (e.g. avatar upload) — upload directly.
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
if err != nil {
slog.Error("file upload failed", "error", err)
writeError(w, http.StatusInternalServerError, "upload failed")
return
}
writeJSON(w, http.StatusOK, map[string]string{
"id": id.String(),
"url": link,
"filename": header.Filename,
})
}
// ---------------------------------------------------------------------------
// ListAttachments — GET /api/issues/{id}/attachments
// ---------------------------------------------------------------------------
func (h *Handler) ListAttachments(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil {
slog.Error("failed to list attachments", "error", err)
writeError(w, http.StatusInternalServerError, "failed to list attachments")
return
}
resp := make([]AttachmentResponse, len(attachments))
for i, a := range attachments {
resp[i] = h.attachmentToResponse(a)
}
writeJSON(w, http.StatusOK, resp)
}
// ---------------------------------------------------------------------------
// GetAttachmentByID — GET /api/attachments/{id}
// ---------------------------------------------------------------------------
func (h *Handler) GetAttachmentByID(w http.ResponseWriter, r *http.Request) {
att, ok := h.loadAttachmentForRequest(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, h.attachmentToResponse(att))
}
func (h *Handler) loadAttachmentForRequest(w http.ResponseWriter, r *http.Request) (db.Attachment, bool) {
attachmentID := chi.URLParam(r, "id")
workspaceID := h.resolveWorkspaceID(r)
if workspaceID == "" {
writeError(w, http.StatusBadRequest, "workspace_id is required")
return db.Attachment{}, false
}
attUUID, ok := parseUUIDOrBadRequest(w, attachmentID, "attachment id")
if !ok {
return db.Attachment{}, false
}
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return db.Attachment{}, false
}
att, err := h.Queries.GetAttachment(r.Context(), db.GetAttachmentParams{
ID: attUUID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "attachment not found")
return db.Attachment{}, false
}
return att, true
}
// loadAttachmentForDownload is a workspace-self-resolving variant used by the
// /api/attachments/{id}/download endpoint. It looks the attachment up by ID
// alone, then enforces that the authenticated user is a member of the
// attachment's workspace.
//
// Why a separate code path: a native browser <img>/<video> resource load on
// /api/attachments/{id}/download cannot attach the X-Workspace-Slug /
// X-Workspace-ID headers that loadAttachmentForRequest relies on. Putting
// the workspace into the URL (?workspace_slug=...) would work mechanically
// but bakes a non-essential identifier into every persisted comment markdown
// link — unnecessary because the attachment row already records its
// workspace. This helper keeps the URL clean (`/api/attachments/{id}/download`)
// and treats the attachment id + cookie/Bearer auth as sufficient.
//
// Membership uses the same 404-on-deny shape as ServeLocalUpload so the
// route does not act as an IDOR oracle for attachment IDs that happen to
// belong to a different workspace. The membership cache fast path mirrors
// canReadWorkspaceUpload exactly.
func (h *Handler) loadAttachmentForDownload(w http.ResponseWriter, r *http.Request) (db.Attachment, bool) {
attachmentID := chi.URLParam(r, "id")
attUUID, ok := parseUUIDOrBadRequest(w, attachmentID, "attachment id")
if !ok {
return db.Attachment{}, false
}
att, err := h.Queries.GetAttachmentByIDOnly(r.Context(), attUUID)
if err != nil {
// 404 (not 403/401) so non-member and non-existent look identical
// to outside callers. Same shape as ServeLocalUpload's
// canReadWorkspaceUpload deny path.
writeError(w, http.StatusNotFound, "attachment not found")
return db.Attachment{}, false
}
userID, ok := requireUserID(w, r)
if !ok {
return db.Attachment{}, false
}
workspaceID := uuidToString(att.WorkspaceID)
if workspaceID == "" {
writeError(w, http.StatusNotFound, "attachment not found")
return db.Attachment{}, false
}
if h.MembershipCache.Get(r.Context(), userID, workspaceID) {
return att, true
}
if _, err := h.getWorkspaceMember(r.Context(), userID, workspaceID); err != nil {
writeError(w, http.StatusNotFound, "attachment not found")
return db.Attachment{}, false
}
h.MembershipCache.Set(r.Context(), userID, workspaceID)
return att, true
}
// ---------------------------------------------------------------------------
// DownloadAttachment — GET /api/attachments/{id}/download
// ---------------------------------------------------------------------------
//
// Workspace context is derived from the attachment row itself, not from
// X-Workspace-Slug / X-Workspace-ID headers. This is what lets a markdown
// `<img src="/api/attachments/{id}/download">` work as a native browser
// resource load: the browser cannot attach those headers to <img>/<video>
// fetches, so resolving via the attachment row is the only way to keep
// the URL stable across reloads (the previous design persisted a 30-min
// signed /uploads URL into the markdown body — that URL stopped working
// the moment the signature expired).
//
// Membership is enforced inside loadAttachmentForDownload with a 404 deny
// shape so the route doesn't IDOR-leak attachment IDs to non-members.
func (h *Handler) DownloadAttachment(w http.ResponseWriter, r *http.Request) {
att, ok := h.loadAttachmentForDownload(w, r)
if !ok {
return
}
if h.Storage == nil {
writeError(w, http.StatusServiceUnavailable, "storage not configured")
return
}
key := h.Storage.KeyFromURL(att.Url)
switch h.resolveAttachmentDownloadMode(att.Url) {
case attachmentDownloadModeCloudFront:
if h.CFSigner == nil {
writeError(w, http.StatusInternalServerError, "cloudfront attachment downloads are not configured")
return
}
h.setAttachmentPreviewSecurityHeaders(w)
http.Redirect(
w,
r,
h.CFSigner.SignedURLWithContentDisposition(
att.Url,
storage.AttachmentContentDisposition(att.Filename),
time.Now().Add(h.attachmentDownloadURLTTL()),
),
http.StatusFound,
)
case attachmentDownloadModePresign:
presigner, ok := h.Storage.(storage.DownloadPresigner)
if !ok {
writeError(w, http.StatusInternalServerError, "attachment storage does not support presigned downloads")
return
}
signedURL, err := presigner.PresignGetWithContentDisposition(
r.Context(),
key,
h.attachmentDownloadURLTTL(),
storage.AttachmentContentDisposition(att.Filename),
)
if err != nil {
slog.Error("failed to presign attachment download", "id", uuidToString(att.ID), "key", key, "error", err)
writeError(w, http.StatusBadGateway, "failed to create download URL")
return
}
h.setAttachmentPreviewSecurityHeaders(w)
http.Redirect(w, r, signedURL, http.StatusFound)
case attachmentDownloadModeProxy:
h.proxyAttachmentDownload(w, r, att, key)
default:
writeError(w, http.StatusInternalServerError, "invalid attachment download mode")
}
}
func (h *Handler) resolveAttachmentDownloadMode(rawURL string) attachmentDownloadMode {
switch h.attachmentDownloadMode() {
case attachmentDownloadModeCloudFront:
return attachmentDownloadModeCloudFront
case attachmentDownloadModePresign:
return attachmentDownloadModePresign
case attachmentDownloadModeProxy:
return attachmentDownloadModeProxy
}
if h.CFSigner != nil {
return attachmentDownloadModeCloudFront
}
if shouldProxyAttachmentURL(rawURL) {
return attachmentDownloadModeProxy
}
if _, ok := h.Storage.(storage.DownloadPresigner); ok {
return attachmentDownloadModePresign
}
return attachmentDownloadModeProxy
}
func shouldProxyAttachmentURL(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil || u.Hostname() == "" {
return true
}
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(u.Hostname()), "."))
if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") {
return true
}
if !strings.Contains(host, ".") {
return true
}
switch {
case strings.HasSuffix(host, ".local"),
strings.HasSuffix(host, ".localdomain"),
strings.HasSuffix(host, ".internal"),
strings.HasSuffix(host, ".lan"),
strings.HasSuffix(host, ".home"),
strings.HasSuffix(host, ".docker"):
return true
}
if addr, err := netip.ParseAddr(host); err == nil {
return addr.IsLoopback() ||
addr.IsPrivate() ||
addr.IsLinkLocalUnicast() ||
addr.IsLinkLocalMulticast() ||
addr.IsUnspecified()
}
return false
}
// ServeLocalUpload serves a local-disk object from the public /uploads/*
// route. It carries the same preview security headers as the authenticated
// download endpoint so self-hosted deployments — same-origin or split
// frontend/backend origins — can inline-render images and iframe-preview
// documents (PDF/HTML) fetched straight from the static route. Without these
// headers the global "frame-ancestors 'none'" policy blocks those previews.
// See MUL-3821 / #4477.
func (h *Handler) ServeLocalUpload(w http.ResponseWriter, r *http.Request) {
local, ok := h.Storage.(*storage.LocalStorage)
if !ok {
http.NotFound(w, r)
return
}
h.setAttachmentPreviewSecurityHeaders(w)
key := strings.TrimPrefix(r.URL.Path, "/uploads/")
local.ServeFile(w, r, key)
}
// proxyAttachmentDownload streams an attachment through the API instead of
// redirecting to a signed storage URL (used for local-disk / private-host
// backends, see resolveAttachmentDownloadMode).
//
// It supports HTTP Range requests so a download interrupted mid-stream can be
// resumed with `Range: bytes=<resume>-` instead of restarting from byte 0
// (RAS-29). Two paths:
//
// - Seekable backend (local disk returns *os.File): delegate to
// http.ServeContent, which implements Range / If-Range / 206 /
// Content-Range / 416 correctly out of the box.
// - Non-seekable backend (S3/MinIO streaming body): advertise
// Accept-Ranges and implement single-range requests by hand
// (serveProxyRange). Multi-range is not implemented on this path; per
// RFC 7233 it is ignored and the full body is served (200), matching the
// seekable path's successful outcome rather than failing with 416.
func (h *Handler) proxyAttachmentDownload(w http.ResponseWriter, r *http.Request, att db.Attachment, key string) {
reader, err := h.Storage.GetReader(r.Context(), key)
if err != nil {
slog.Error("failed to open attachment for download", "id", uuidToString(att.ID), "key", key, "error", err)
writeError(w, http.StatusNotFound, "attachment object not found")
return
}
defer reader.Close()
if att.ContentType != "" {
w.Header().Set("Content-Type", att.ContentType)
} else {
w.Header().Set("Content-Type", "application/octet-stream")
}
w.Header().Set("Content-Disposition", storage.ContentDisposition(att.ContentType, att.Filename))
// no-store predates Range support; keep it. Range/206 semantics are
// independent of caching — clients resume via Content-Range, not the cache.
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
h.setAttachmentPreviewSecurityHeaders(w)
// Seekable backends get full standard-library Range handling. A zero
// modTime disables Last-Modified / If-Modified-Since (we keep no-store);
// ServeContent still honors Range and sets Accept-Ranges / Content-Length /
// Content-Range / status itself, and respects the headers we set above.
if seeker, ok := reader.(io.ReadSeeker); ok {
http.ServeContent(w, r, att.Filename, time.Time{}, seeker)
return
}
// Non-seekable backend: single-range fallback.
h.serveProxyRange(w, r, att, reader)
}
// serveProxyRange streams a (possibly partial) attachment body from a
// forward-only reader. It always advertises Accept-Ranges: bytes and, when the
// request carries a satisfiable single-range `Range` header, replies 206 +
// Content-Range with just that byte interval. Without a Range header (or when
// the size is unknown) it streams the whole body as a 200, preserving the
// pre-RAS-29 behavior. An unsatisfiable range yields 416 + `Content-Range:
// bytes */<total>` per RFC 7233.
func (h *Handler) serveProxyRange(w http.ResponseWriter, r *http.Request, att db.Attachment, reader io.Reader) {
total := att.SizeBytes
w.Header().Set("Accept-Ranges", "bytes")
rangeHeader := strings.TrimSpace(r.Header.Get("Range"))
// Decide how to respond. We serve the full body (200) when there is no Range,
// when the total size is unknown (total < 0), or when parseSingleByteRange
// classifies the Range as unsupported — multi-range, or a well-formed range
// against an empty object (see rangeUnsupported). A well-formed, in-bounds
// single range yields 206; a well-formed but out-of-bounds range on a
// non-empty object is the only case that yields 416.
serveFull := rangeHeader == "" || total < 0
var start, length int64
if !serveFull {
var outcome rangeParseOutcome
start, length, outcome = parseSingleByteRange(rangeHeader, total)
switch outcome {
case rangeUnsupported:
// Unsupported Range form (multi-range). RFC 7233 requires an
// unsupported Range to be *ignored* and the full representation served
// (200), not rejected with 416. This also keeps the non-seekable path
// from diverging from the seekable http.ServeContent path, which
// answers multi-range with a successful 206 multipart: both backends
// now return complete, correctly-labeled data instead of one silently
// turning a resumable download into a hard 416 failure.
serveFull = true
case rangeUnsatisfiable:
// Well-formed bytes range that does not overlap the content. RFC 7233
// §4.4 requires the total in the Content-Range of a 416 response.
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", total))
writeError(w, http.StatusRequestedRangeNotSatisfiable, "requested range not satisfiable")
return
}
}
if serveFull {
if total >= 0 {
w.Header().Set("Content-Length", strconv.FormatInt(total, 10))
}
if _, err := io.Copy(w, reader); err != nil {
slog.Error("failed to stream attachment download", "id", uuidToString(att.ID), "error", err)
}
return
}
// Satisfiable single range → 206. Forward-only reader: discard the bytes
// before `start` before copying the requested interval. Wasteful for large
// offsets but correct; seekable backends never reach here (they take the
// ServeContent path above).
if start > 0 {
if _, err := io.CopyN(io.Discard, reader, start); err != nil {
// The skip failed BEFORE any response header was written. We must send
// an explicit error status here: a bare `return` would let net/http
// emit its default 200 OK + empty body, which a resuming client reads
// as "Range ignored, here is the whole file" — silently turning a
// transient storage read error into a corrupt/empty download and
// defeating the very resume feature this path implements. (A copy
// failure AFTER WriteHeader below can only be logged: the 206 status
// is already on the wire and cannot be revised.)
slog.Error("failed to skip to range start for attachment", "id", uuidToString(att.ID), "error", err)
writeError(w, http.StatusBadGateway, "failed to read attachment range")
return
}
}
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, start+length-1, total))
w.Header().Set("Content-Length", strconv.FormatInt(length, 10))
w.WriteHeader(http.StatusPartialContent)
if _, err := io.CopyN(w, reader, length); err != nil {
slog.Error("failed to stream attachment range", "id", uuidToString(att.ID), "error", err)
}
}
// rangeParseOutcome classifies how serveProxyRange must respond to a `Range`
// header on the forward-only (non-seekable) proxy path. It exists so the three
// distinct HTTP outcomes required by RFC 7233 are not collapsed into a single
// bool — collapsing them is exactly what caused a multi-range request to fail
// with 416 on this path while succeeding (206 multipart) on the seekable path.
type rangeParseOutcome int
const (
// rangeSatisfiable: a single byte range we can serve as 206 + Content-Range.
rangeSatisfiable rangeParseOutcome = iota
// rangeUnsatisfiable: a well-formed request that cannot be served as a range
// we support — a bytes range that does not overlap the content (start at/after
// EOF, reversed), or a malformed / unknown-unit Range. stdlib http.ServeContent
// answers all of these with 416, so replying 416 here keeps the two backends
// aligned; the caller adds `Content-Range: bytes */<total>` (RFC 7233 §4.4).
rangeUnsatisfiable
// rangeUnsupported: a well-formed Range this path answers by ignoring the
// Range and serving the full body (200), per RFC 7233. Two cases:
// - multi-range (`bytes=a-b,c-d`): the seekable path serves it as a 206
// multipart, so a full 200 here keeps both paths returning complete data
// instead of one hard-failing with 416.
// - a well-formed byte range against an EMPTY object (size == 0): there are
// no bytes to satisfy any range, and stdlib http.ServeContent ignores the
// Range and returns an empty 200 rather than 416. Classifying it here as
// unsupported (→ empty 200) keeps the non-seekable path aligned with the
// seekable one; answering 416 would make the same request against a
// 0-byte attachment succeed on a seekable backend but fail on an
// S3/MinIO stream. Genuinely malformed / unknown-unit ranges against an
// empty object stay rangeUnsatisfiable (416), matching ServeContent.
rangeUnsupported
)
// parseSingleByteRange parses a single-range HTTP `Range` header value against
// a known content size and returns the absolute start offset, byte length, and
// an outcome telling the caller which status to send (see rangeParseOutcome).
// start/length are only meaningful when the outcome is rangeSatisfiable.
//
// Supported forms (RFC 7233): `bytes=start-end`, `bytes=start-` (to EOF), and
// `bytes=-suffix` (final suffix bytes). An out-of-range end is clamped to the
// last byte; a start at or past EOF on a non-empty object is unsatisfiable (416).
// Multi-range, and any well-formed range against an empty object (size == 0),
// are valid-but-unsupported forms and yield rangeUnsupported (ignored → full
// body), matching how the seekable http.ServeContent path handles them.
func parseSingleByteRange(header string, size int64) (start, length int64, outcome rangeParseOutcome) {
const prefix = "bytes="
if !strings.HasPrefix(header, prefix) {
// Unknown / missing unit (e.g. "items=0-10", "0-100"). stdlib
// http.ServeContent rejects these with 416; mirror that here.
return 0, 0, rangeUnsatisfiable
}
spec := strings.TrimSpace(header[len(prefix):])
if spec == "" {
return 0, 0, rangeUnsatisfiable
}
// Multi-range is a valid HTTP form we do not implement on this forward-only
// path; ignore it and serve the full body rather than failing with 416.
if strings.Contains(spec, ",") {
return 0, 0, rangeUnsupported
}
dash := strings.IndexByte(spec, '-')
if dash < 0 {
return 0, 0, rangeUnsatisfiable
}
startStr := strings.TrimSpace(spec[:dash])
endStr := strings.TrimSpace(spec[dash+1:])
if startStr == "" {
// Suffix form: bytes=-N → final N bytes.
if endStr == "" {
return 0, 0, rangeUnsatisfiable
}
n, err := strconv.ParseInt(endStr, 10, 64)
if err != nil || n <= 0 {
return 0, 0, rangeUnsatisfiable
}
if size == 0 {
// Well-formed suffix against an empty object: no bytes to satisfy it.
// Ignore the Range and serve an empty 200 (rangeUnsupported) to match
// the seekable path, instead of a divergent 416.
return 0, 0, rangeUnsupported
}
if n > size {
n = size
}
return size - n, n, rangeSatisfiable
}
s, err := strconv.ParseInt(startStr, 10, 64)
if err != nil || s < 0 {
return 0, 0, rangeUnsatisfiable
}
if s >= size {
// The start is at or beyond EOF, so no bytes overlap. For an empty object
// (size == 0) every start is at EOF; stdlib http.ServeContent ignores the
// Range and serves an empty 200 there (it never validates the end once the
// start is out of range), so classify as rangeUnsupported (→ empty 200) to
// stay aligned. For a non-empty object a start past EOF is a genuine 416.
if size == 0 {
return 0, 0, rangeUnsupported
}
return 0, 0, rangeUnsatisfiable
}
if endStr == "" {
return s, size - s, rangeSatisfiable
}
e, err := strconv.ParseInt(endStr, 10, 64)
if err != nil || e < s {
return 0, 0, rangeUnsatisfiable
}
if e >= size {
e = size - 1
}
return s, e - s + 1, rangeSatisfiable
}
func (h *Handler) setAttachmentPreviewSecurityHeaders(w http.ResponseWriter) {
// Attachment preview responses may be loaded by the web app in same-origin
// deployments or split app/api self-hosted deployments. Allow only the API
// origin itself plus configured frontend/CORS origins.
w.Header().Set("Content-Security-Policy", attachmentPreviewCSPHeader(h.cfg.AttachmentFrameAncestors))
}
func attachmentPreviewCSPHeader(frameAncestors []string) string {
ancestors := []string{"'self'"}
seen := map[string]struct{}{"'self'": {}}
for _, raw := range frameAncestors {
source, ok := normalizeFrameAncestorSource(raw)
if !ok {
continue
}
if _, exists := seen[source]; exists {
continue
}
seen[source] = struct{}{}
ancestors = append(ancestors, source)
}
return "default-src 'none'; " +
"img-src 'self' data:; " +
"media-src 'self'; " +
"frame-ancestors " + strings.Join(ancestors, " ") + "; " +
"object-src 'none'; " +
"base-uri 'none'; " +
"form-action 'none'"
}
func normalizeFrameAncestorSource(raw string) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "*" {
return "", false
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", false
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return "", false
}
return scheme + "://" + strings.ToLower(u.Host), true
}
// ---------------------------------------------------------------------------
// GetAttachmentContent — GET /api/attachments/{id}/content
//
// Streams the raw bytes of a text-previewable attachment back to the client.
// Exists to (a) bypass CloudFront CORS (not configured) and (b) bypass
// Content-Disposition: attachment which Chromium honors for iframe document
// loads. Media types (image/video/audio/pdf) intentionally use download_url
// instead. Metadata download_url keeps CloudFront/S3's media preview behavior;
// the explicit /download route signs redirects as attachment downloads and
// proxy mode streams with the same media-type policy as storage uploads.
//
// Hard cap: 2 MB. Larger files return 413. Anything outside the text
// whitelist returns 415.
// ---------------------------------------------------------------------------
func (h *Handler) GetAttachmentContent(w http.ResponseWriter, r *http.Request) {
att, ok := h.loadAttachmentForRequest(w, r)
if !ok {
return
}
attachmentID := uuidToString(att.ID)
if !isTextPreviewable(att.ContentType, att.Filename) {
writeError(w, http.StatusUnsupportedMediaType, "preview not supported for this file type")
return
}
if h.Storage == nil {
writeError(w, http.StatusServiceUnavailable, "storage not configured")
return
}
key := h.Storage.KeyFromURL(att.Url)
reader, err := h.Storage.GetReader(r.Context(), key)
if err != nil {
slog.Error("failed to open attachment for preview", "id", attachmentID, "key", key, "error", err)
writeError(w, http.StatusNotFound, "attachment object not found")
return
}
defer reader.Close()
// LimitReader to maxPreviewTextSize+1 so we can detect "exactly at the
// limit" vs "exceeds the limit" by checking the returned length.
body, err := io.ReadAll(io.LimitReader(reader, maxPreviewTextSize+1))
if err != nil {
slog.Error("failed to read attachment body for preview", "id", attachmentID, "error", err)
writeError(w, http.StatusBadGateway, "failed to read attachment body")
return
}
if len(body) > maxPreviewTextSize {
writeError(w, http.StatusRequestEntityTooLarge, "file too large for inline preview")
return
}
// Always reply as text/plain so a hostile HTML payload can't be
// re-interpreted as a document by the browser. The original MIME is
// surfaced via X-Original-Content-Type for the client-side dispatcher.
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Original-Content-Type", att.ContentType)
// No-store: workspace membership / attachment ACL can change between
// requests (member removed, attachment deleted). A cached body would
// stay readable past the revocation window. The redundant request is
// fine here — bodies are capped at 2 MB and the endpoint is only hit
// when a user explicitly opens a preview.
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
h.setAttachmentPreviewSecurityHeaders(w)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
if _, err := w.Write(body); err != nil {
slog.Error("failed to write attachment preview body", "id", attachmentID, "error", err)
}
}
// isTextPreviewable is the whitelist for the text preview proxy.
//
// IMPORTANT — KEEP IN SYNC with the client-side mirror in
// packages/views/editor/utils/preview.ts (TEXT_EXTENSIONS / TEXT_CONTENT_TYPES
// / TEXT_BASENAMES + extensionToLanguage). If a type is allowed here but not
// mapped client-side the user sees raw unhighlighted text; if mapped client-side
// but rejected here the user sees a 415 fallback.
//
// TODO(follow-up): extract this list to a JSON single-source-of-truth and
// generate the TS side, mirroring the reserved-slugs pattern (see
// server/internal/handler/reserved_slugs.json + scripts/generate-reserved-slugs.mjs).
// Drift severity here is low (worst case: Eye button visible but proxy 415s,
// modal shows the unsupported fallback — still functional, just confusing),
// so it ships as manual hand-sync for v1.
//
// We check both content_type and extension because http.DetectContentType
// regularly returns "text/plain" for Markdown / source code, so a pure
// content-type check would 415 those.
func isTextPreviewable(contentType, filename string) bool {
ct := strings.ToLower(strings.TrimSpace(contentType))
// Strip params (e.g. "text/plain; charset=utf-8")
if idx := strings.Index(ct, ";"); idx >= 0 {
ct = strings.TrimSpace(ct[:idx])
}
if strings.HasPrefix(ct, "text/") {
return true
}
switch ct {
case "application/json",
"application/javascript",
"application/xml",
"application/x-yaml",
"application/yaml",
"application/toml",
"application/x-sh",
"application/x-httpd-php":
return true
}
ext := strings.ToLower(path.Ext(filename))
switch ext {
case ".md", ".markdown",
".txt", ".log",
".csv", ".tsv",
".html", ".htm",
".json", ".xml",
".yml", ".yaml", ".toml", ".ini", ".conf",
".sh", ".bash", ".zsh",
".py", ".rb", ".go", ".rs",
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
".css", ".scss", ".sass", ".less",
".sql",
".java", ".kt", ".swift",
".c", ".cc", ".cpp", ".h", ".hpp",
".cs", ".php", ".lua", ".vim",
".dockerfile", ".makefile", ".gitignore":
return true
}
// Filenames without extension that match well-known build files.
base := strings.ToLower(path.Base(filename))
switch base {
case "dockerfile", "makefile", ".env":
return true
}
return false
}
// ---------------------------------------------------------------------------
// DeleteAttachment — DELETE /api/attachments/{id}
// ---------------------------------------------------------------------------
func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "id")
workspaceID := h.resolveWorkspaceID(r)
if workspaceID == "" {
writeError(w, http.StatusBadRequest, "workspace_id is required")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
attUUID, ok := parseUUIDOrBadRequest(w, attachmentID, "attachment id")
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
att, err := h.Queries.GetAttachment(r.Context(), db.GetAttachmentParams{
ID: attUUID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "attachment not found")
return
}
// Only the uploader (or workspace admin) can delete
uploaderID := uuidToString(att.UploaderID)
isUploader := att.UploaderType == "member" && uploaderID == userID
member, hasMember := ctxMember(r.Context())
isAdmin := hasMember && (member.Role == "admin" || member.Role == "owner")
if !isUploader && !isAdmin {
writeError(w, http.StatusForbidden, "not authorized to delete this attachment")
return
}
if err := h.Queries.DeleteAttachment(r.Context(), db.DeleteAttachmentParams{
ID: att.ID,
WorkspaceID: att.WorkspaceID,
}); err != nil {
slog.Error("failed to delete attachment", "error", err)
writeError(w, http.StatusInternalServerError, "failed to delete attachment")
return
}
h.deleteS3Object(r.Context(), att.Url)
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Attachment linking
// ---------------------------------------------------------------------------
// linkAttachmentsByIssueIDs links the given attachment IDs to an issue.
// Only updates attachments that have no issue_id yet.
func (h *Handler) linkAttachmentsByIssueIDs(ctx context.Context, issueID, workspaceID pgtype.UUID, ids []pgtype.UUID) {
if err := h.Queries.LinkAttachmentsToIssue(ctx, db.LinkAttachmentsToIssueParams{
IssueID: issueID,
WorkspaceID: workspaceID,
Column3: ids,
}); err != nil {
slog.Error("failed to link attachments to issue", "error", err)
}
}
// linkAttachmentsByIDs links the given attachment IDs to a comment.
// Only updates attachments that belong to the same issue and have no comment_id yet.
func (h *Handler) linkAttachmentsByIDs(ctx context.Context, commentID, issueID pgtype.UUID, ids []pgtype.UUID) {
if err := h.Queries.LinkAttachmentsToComment(ctx, db.LinkAttachmentsToCommentParams{
CommentID: commentID,
IssueID: issueID,
Column3: ids,
}); err != nil {
slog.Error("failed to link attachments to comment", "error", err)
}
}
// deleteS3Object removes a single file from S3 by its CDN URL.
func (h *Handler) deleteS3Object(ctx context.Context, url string) {
if h.Storage == nil || url == "" {
return
}
h.Storage.Delete(ctx, h.Storage.KeyFromURL(url))
}
// deleteS3Objects removes multiple files from S3 by their CDN URLs.
func (h *Handler) deleteS3Objects(ctx context.Context, urls []string) {
if h.Storage == nil || len(urls) == 0 {
return
}
keys := make([]string, len(urls))
for i, u := range urls {
keys[i] = h.Storage.KeyFromURL(u)
}
h.Storage.DeleteKeys(ctx, keys)
}