package handler import ( "context" "fmt" "io" "log/slog" "net/http" "net/netip" "net/url" "path" "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//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 } 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 /