package handler import ( "context" "errors" "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" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/storage" "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" ) // 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"` // AttachmentDownloadURL is a credential-free URL that forces a // Content-Disposition: attachment across every storage mode, for the // download BUTTON — unlike DownloadURL, which is load-intent and keeps // serving media inline so the preview path (resolvePreviewMediaUrl) can // render it. Like DownloadURL it can be short-lived (a 60s proxy capability, // a presigned URL) and therefore MUST NOT be persisted, and it is emitted // ONLY by the single-attachment endpoint (GetAttachmentByID), never in list // responses. Empty when the server cannot mint one for the object's storage // mode; clients fall back to DownloadURL. (MUL follow-up to #6092 / #6713.) AttachmentDownloadURL string `json:"attachment_download_url,omitempty"` // 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//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"` } // attachmentURLMode selects how DownloadURL is rendered on a response. // // MUL-5372 / GitHub #5999. A CloudFront-signed DownloadURL is ~800 chars, of // which ~630 are a Policy+Signature pair that is re-minted on every request // (the policy embeds now+TTL at second granularity). Emitting it for every // attachment of every list response is expensive three times over: raw payload, // a fresh RSA sign per attachment per request, and — because the bytes differ on // each read — it defeats any cache keyed on response content. Agents pay all // three and use none of it: they fetch files through the single-attachment // endpoint, which needs only the id. // // The mode is a caller capability declaration, never a server-side default // flip, so a client that does not know about it is served byte-identically to // before. See attachmentURLModeFromRequest. type attachmentURLMode int const ( // attachmentURLModeSigned pre-binds authorization into DownloadURL so the // caller can hand it straight to a native resource load (browser , // Linking.openURL) that cannot attach an Authorization header. This is the // default for every caller that does not opt out. attachmentURLModeSigned attachmentURLMode = iota // attachmentURLModeStable renders DownloadURL as the stable // /api/attachments/{id}/download path. That endpoint re-signs and 302s on // every hit, so the value stays correct forever and costs ~95 chars instead // of ~800. Callers that pick this mode must be able to follow an // authenticated redirect, or fetch a fresh signature from the // single-attachment endpoint before handing a URL to a native loader. attachmentURLModeStable ) // ClientCapabilityStableAttachmentURLs is the X-Client-Capabilities token a // caller advertises to receive stable attachment paths instead of pre-signed // URLs in bulk responses. Reusing the existing capability header (rather than a // query parameter) keeps the declaration client-wide: it is a property of what // the caller can process, not of the resource being requested. const ClientCapabilityStableAttachmentURLs = "stable_attachment_urls" // attachmentURLModeFromRequest resolves the mode a request asked for. Absent or // unrecognized declarations resolve to attachmentURLModeSigned, which is what // makes this change safe to ship ahead of any client: the server default never // moves, callers migrate on their own release cadence. func attachmentURLModeFromRequest(r *http.Request) attachmentURLMode { if r != nil && requestHasClientCapability(r, ClientCapabilityStableAttachmentURLs) { return attachmentURLModeStable } return attachmentURLModeSigned } func (h *Handler) attachmentToResponse(a db.Attachment, mode attachmentURLMode) 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: util.AttachmentDownloadPath(id), MarkdownURL: h.buildMarkdownURL(a, id), ContentType: a.ContentType, SizeBytes: a.SizeBytes, CreatedAt: a.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), } // Only CloudFront mode overrides the stable path here; the presign and proxy // modes already leave DownloadURL as the stable path and resolve it at // download time, so stable mode is a no-op for them. if h.CFSigner != nil && mode != attachmentURLModeStable { 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 } // 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 := util.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 } mode := attachmentURLModeFromRequest(r) grouped := make(map[string][]AttachmentResponse, len(commentIDs)) for _, a := range attachments { cid := uuidToString(a.CommentID) grouped[cid] = append(grouped[cid], h.attachmentToResponse(a, mode)) } 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, attachmentURLModeSigned)) } return grouped } // UploadFile — POST /api/upload-file func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { r = h.withWakeupActor(r) if h.Storage == nil { writeFeatureDisabled(w, "file_upload_not_configured", "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) // A deleted comment's tombstone takes no attachments. if err != nil || uuidToString(comment.WorkspaceID) != workspaceID || comment.DeletedAt.Valid { writeError(w, http.StatusForbidden, "invalid comment_id") return } params.CommentID = comment.ID } if chatSessionID := r.FormValue("chat_session_id"); chatSessionID != "" { // Require the member-visible Chat projection as well as private-agent // access. A cached command-only session id must not accept uploads that // could later be attached by an old client and resurrect the session. session, ok := h.gatePublicChatSessionForUser(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 is the only branch that stamps it, because the middleware // deletes any client-supplied agent/task identity first (MUL-3428). // The gate is kept explicit because of what it protects — an // attachment injected onto another chat task's assistant reply is a // cross-session privacy leak, and this endpoint should say which // credential it requires rather than rely on a distant strip. 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 var att db.CreateAttachmentRow if params.CommentID.Valid { // A comment attachment is written under the comment's lock, so a // delete that commits while the object uploaded cannot leave it on // a tombstone. A refused upload takes its stored object with it. err = h.withLiveCommentLock(r.Context(), params.CommentID, params.WorkspaceID, func(qtx *db.Queries) error { var createErr error att, createErr = qtx.CreateAttachment(r.Context(), params) return createErr }) if errors.Is(err, pgx.ErrNoRows) { h.deleteS3Objects(r.Context(), []string{link}) writeError(w, http.StatusForbidden, "invalid comment_id") return } } else { att, err = wakeupWrite(h, r, func(q *db.Queries) (db.CreateAttachmentRow, error) { return q.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 { if att.IssueRevision > 0 && att.IssueID.Valid { h.publish(protocol.EventIssueAttachmentsChanged, workspaceID, uploaderType, uploaderID, map[string]any{ "issue_id": uuidToString(att.IssueID), "issue_revision": att.IssueRevision, }) } if att.CommentRevision > 0 && att.CommentID.Valid { if comment, loadErr := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{ ID: att.CommentID, WorkspaceID: att.WorkspaceID, }); loadErr == nil { commentID := uuidToString(comment.ID) reactions := h.groupReactions(r, []pgtype.UUID{comment.ID}) attachments := h.groupAttachments(r, []pgtype.UUID{comment.ID}) h.publish(protocol.EventCommentUpdated, workspaceID, uploaderType, uploaderID, map[string]any{ "comment": commentToResponse(comment, reactions[commentID], attachments[commentID]), }) } } writeJSON(w, http.StatusOK, h.attachmentToResponse(att.Attachment(), attachmentURLModeFromRequest(r))) 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 } mode := attachmentURLModeFromRequest(r) resp := make([]AttachmentResponse, len(attachments)) for i, a := range attachments { resp[i] = h.attachmentToResponse(a, mode) } 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 } h.writeSignedAttachmentMetadata(w, r, att) } // GetAttachmentForDownload resolves the workspace from the attachment, as the // durable Markdown download URL does. A link can be pasted into a different // workspace; the current workspace header must not change its target. The // membership check is the same one used by the protected download endpoint. func (h *Handler) GetAttachmentForDownload(w http.ResponseWriter, r *http.Request) { att, ok := h.loadAttachmentForDownload(w, r) if !ok { return } h.writeSignedAttachmentMetadata(w, r, att) } func (h *Handler) writeSignedAttachmentMetadata(w http.ResponseWriter, r *http.Request, att db.Attachment) { // Always signed, regardless of what the caller advertised: this endpoint is // the single source of fresh, natively-loadable URLs. Stable-mode callers // (CLI `attachment download`, the web inline-media re-sign hook) exchange a // stable path for a signature HERE, so honoring the capability would break // the very flow that makes stable mode safe elsewhere. resp := h.attachmentToResponse(att, attachmentURLModeSigned) // Token-mode clients use this authenticated endpoint to replace the // auth-gated API path with a URL that native media elements can load. // Assert the same storage.DownloadPresigner that resolveAttachmentDownloadMode // keys its presign decision on, so the mode resolution and the presign call // can never disagree. An empty content disposition inherits the object's // stored Content-Disposition (inline for media, attachment otherwise), which // keeps images renderable inline while preserving the original download // filename. switch mode := h.resolveAttachmentDownloadMode(att.Url); mode { case attachmentDownloadModeCloudFront: // CloudFront mode: attachmentToResponse already set DownloadURL to an // inline-intent signed URL. The download button needs the forced- // attachment sibling. response-content-disposition is folded into the // signed Resource (SignedURLWithContentDisposition), so a client cannot // strip or alter it without invalidating the signature. Keying on the // resolved mode (not h.CFSigner != nil) lets an explicit proxy/presign // override take effect even when a signer is configured; the nil guard // keeps an explicit cloudfront mode without a configured signer from // panicking — the field is left empty and the client falls back to // download_url, the same graceful degrade attachmentToResponse uses. if h.CFSigner != nil { resp.AttachmentDownloadURL = h.CFSigner.SignedURLWithContentDisposition( att.Url, storage.AttachmentContentDisposition(att.Filename), time.Now().Add(h.attachmentDownloadURLTTL()), ) } case attachmentDownloadModePresign: if presigner, ok := h.Storage.(storage.DownloadPresigner); ok { key := h.Storage.KeyFromURL(att.Url) signedURL, err := presigner.PresignGetWithContentDisposition(r.Context(), key, h.attachmentDownloadURLTTL(), "") if err != nil { slog.Warn("failed to presign inline attachment URL", "id", uuidToString(att.ID), "key", key, "error", err) } else { resp.DownloadURL = signedURL } // Download-intent sibling: the same presigned object, but with a // forced attachment disposition so the download button saves the // file instead of previewing it. Independent of DownloadURL's inline // signature above; either may be present without the other. if dlURL, err := presigner.PresignGetWithContentDisposition(r.Context(), key, h.attachmentDownloadURLTTL(), storage.AttachmentContentDisposition(att.Filename)); err != nil { slog.Warn("failed to presign attachment download URL", "id", uuidToString(att.ID), "key", key, "error", err) } else { resp.AttachmentDownloadURL = dlURL } } case attachmentDownloadModeProxy: // Proxy mode has no signed storage URL to offer, so this response // would otherwise hand back the auth-gated API path — which a // native download on a token-mode client cannot authenticate, // leaving the user with no file (MUL-5292). Mint a // single-attachment, 60-second capability instead, so the same // "replace the auth-gated path with something a native loader can // fetch" contract holds in all three modes. // // Only here, never in attachmentToResponse: list responses are held // far longer than the TTL, so a capability embedded in one would be // expired by the time anything used it. resp.DownloadURL = attachmentCapabilityPath(resp.ID, time.Now()) // Download-intent sibling capability (dl=1): the redemption route turns // it into a Content-Disposition: attachment, for the download button. resp.AttachmentDownloadURL = attachmentDownloadCapabilityPath(resp.ID, time.Now()) } writeJSON(w, http.StatusOK, resp) } 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 /