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
/