You've already forked agentic-coding-workflow
126 lines
3.5 KiB
Go
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
|
|
}
|