You've already forked agentic-coding-workflow
feat(chat): SSE writer + multipart upload endpoint with mime whitelist
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user