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"` 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), 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" } 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 /