Files

126 lines
3.5 KiB
Go

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
}