package chat import ( "context" "fmt" "net/http" "path/filepath" "strings" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/infra/storage" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" ) // Uploader handles multipart file uploads for chat attachments. // It validates the file size and MIME type, stores the file via Storage, // and records the attachment in the repository. If storage succeeds but DB // insert fails, the stored blob is deleted to prevent orphaned objects. type Uploader struct { repo Repository storage storage.Storage mimeWhite map[string]bool maxFile int64 } // NewUploader constructs an Uploader. // mimeList is a whitelist of allowed MIME types; "text/*" is a wildcard prefix. // maxFile is the maximum allowed file size in bytes. func NewUploader(repo Repository, st storage.Storage, mimeList []string, maxFile int64) *Uploader { mw := make(map[string]bool, len(mimeList)) for _, m := range mimeList { mw[m] = true } return &Uploader{repo: repo, storage: st, mimeWhite: mw, maxFile: maxFile} } // Handle implements POST /api/v1/uploads. func (u *Uploader) Handle(w http.ResponseWriter, r *http.Request) { uid, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeErr(w, r, errs.New(errs.CodeUnauthorized, "unauthorized")) return } // Parse the multipart form; allow a 1 MiB overhead beyond maxFile for // form boundary / metadata. if err := r.ParseMultipartForm(u.maxFile + 1<<20); err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "parse multipart")) return } file, header, err := r.FormFile("file") if err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "no file field")) return } defer file.Close() if header.Size > u.maxFile { writeErr(w, r, errs.New(errs.CodeChatAttachmentTooLarge, fmt.Sprintf("size %d exceeds limit %d", header.Size, u.maxFile))) return } mt := header.Header.Get("Content-Type") if !u.mimeAllowed(mt) { writeErr(w, r, errs.New(errs.CodeChatAttachmentUnsupportedMime, mt)) return } now := time.Now().UTC() id, err := uuid.NewV7() if err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeInternal, "generate uuid")) return } ext := filepath.Ext(header.Filename) key := fmt.Sprintf("%04d/%02d/%s%s", now.Year(), int(now.Month()), id.String(), ext) res, err := u.storage.Put(r.Context(), key, file, mt) if err != nil { writeErr(w, r, errs.Wrap(err, errs.CodeStoragePutFailed, "storage put")) return } att, err := u.repo.InsertAttachment(r.Context(), InsertAttachmentParams{ ID: id, UserID: uid, Filename: header.Filename, MimeType: mt, SizeBytes: res.Size, SHA256: res.SHA256, StoragePath: res.StoragePath, }) if err != nil { // Best-effort blob cleanup to prevent orphaned storage objects. _ = u.storage.Delete(context.Background(), res.StoragePath) writeErr(w, r, err) return } writeJSON(w, http.StatusOK, AttachmentDTO{ ID: att.ID, Filename: att.Filename, MimeType: att.MimeType, SizeBytes: att.SizeBytes, SHA256: att.SHA256, CreatedAt: att.CreatedAt, }) } // mimeAllowed reports whether the given MIME type is in the whitelist. // "text/*" in the whitelist acts as a wildcard for any text/ subtype. func (u *Uploader) mimeAllowed(mt string) bool { if u.mimeWhite[mt] { return true } if u.mimeWhite["text/*"] && strings.HasPrefix(mt, "text/") { return true } return false }