fix(12.2): close code-review blockers on uploads, JSON caps, and pivot fill

Keep form save behind in-flight uploads, make retries idempotent via X-Upload-Id, cap remaining JSON bodies, and surface pending pivot type errors instead of zeroing them.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-02 23:10:48 +02:00
parent 6bfc0faa8a
commit 516f9c9025
22 changed files with 533 additions and 96 deletions

View File

@@ -364,20 +364,21 @@ func compileFileFields(pluginID string, cc *CompiledController) error {
return nil
}
// checkFileLimits refuses a maxFilesize above http.body_limits.upload_bytes,
// as WinterCMS refuses one above upload_max_filesize.
// checkFileLimits refuses a maxFilesize whose file plus multipart framing
// cannot fit in http.body_limits.upload_bytes. Equality is not enough:
// the request cap is the whole multipart body.
func checkFileLimits(reg *Registry, uploadBytes int64) error {
if reg == nil || uploadBytes <= 0 {
return nil
}
for _, cc := range reg.byID {
for _, cf := range cc.files {
if cf.maxBytes > uploadBytes {
if cf.maxBytes > 0 && cf.maxBytes+multipartOverhead > uploadBytes {
path := ""
if cc.Form != nil {
path = cc.Form.fieldsPath
}
return bootErr(cc.PluginID, controllerID(cc), path, fmt.Errorf("field %s: maxFilesize exceeds http.body_limits.upload_bytes", cf.name))
return bootErr(cc.PluginID, controllerID(cc), path, fmt.Errorf("field %s: maxFilesize plus multipart overhead exceeds http.body_limits.upload_bytes", cf.name))
}
}
}
@@ -510,6 +511,10 @@ func parentFileScope(ctx context.Context, tx *gorm.DB, r *http.Request, cc *Comp
// attached to the owner minus the session's pending removals, plus the
// session's pending uploads, in sort_order then id order.
func (sc *fileScope) visibleFiles(tx *gorm.DB) ([]attach.File, map[uint]bool, error) {
return sc.visibleFilesLocked(tx, false)
}
func (sc *fileScope) visibleFilesLocked(tx *gorm.DB, lock bool) ([]attach.File, map[uint]bool, error) {
q := tx.Session(&gorm.Session{NewDB: true}).Model(&attach.File{})
var attached *gorm.DB
if sc.ownerID > 0 {
@@ -534,6 +539,9 @@ func (sc *fileScope) visibleFiles(tx *gorm.DB) ([]attach.File, map[uint]bool, er
default:
return nil, nil, nil
}
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
var files []attach.File
if err := q.Order("sort_order").Order("id").Find(&files).Error; err != nil {
return nil, nil, err
@@ -547,6 +555,60 @@ func (sc *fileScope) visibleFiles(tx *gorm.DB) ([]attach.File, map[uint]bool, er
return files, isPending, nil
}
const uploadIDHeader = "X-Upload-Id"
const maxUploadIDLen = 64
func parseUploadID(r *http.Request) string {
raw := strings.TrimSpace(r.Header.Get(uploadIDHeader))
if raw == "" || len(raw) > maxUploadIDLen {
return ""
}
for _, c := range raw {
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '-' && c != '_' {
return ""
}
}
return raw
}
// fileForUploadID returns the pending file already stored for this
// session, field and client upload id, or nil when none exists.
func (sc *fileScope) fileForUploadID(ctx context.Context, tx *gorm.DB, uploadID string) (*attach.File, error) {
if uploadID == "" || !sc.hasKey {
return nil, nil
}
rows, err := lagoon.DeferredBindings(ctx, tx, sc.key, []string{sc.file.name})
if err != nil {
return nil, err
}
for _, row := range rows {
if !row.IsBind || row.SlaveType != lagoon.DeferredFileType {
continue
}
env, err := row.Envelope()
if err != nil {
return nil, err
}
if env.UploadID != uploadID {
continue
}
id, err := strconv.ParseUint(row.SlaveID, 10, 64)
if err != nil {
return nil, err
}
var f attach.File
err = tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id = ?", uint(id)).Take(&f).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &f, nil
}
return nil, nil
}
// fileItem projects a stored file. Public URLs are emitted only for a
// public relation (never for a protected file, D-10).
func fileItem(ctx context.Context, bucket *blob.Bucket, cf *compiledFile, f *attach.File, pending bool) FileItem {
@@ -643,6 +705,7 @@ func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *Compi
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
uploadID := parseUploadID(r)
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))}
r.Body = io.NopCloser(body)
mr, err := r.MultipartReader()
@@ -667,6 +730,12 @@ func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *Compi
if err != nil {
return err
}
if existing, err := sc.fileForUploadID(ctx, tx, uploadID); err != nil {
return lifecycleFailure(cc, err)
} else if existing != nil {
item = fileItem(ctx, bucket, cf, existing, true)
return nil
}
if cf.relation.Many && cf.maxFiles > 0 {
files, _, err := sc.visibleFiles(tx)
if err != nil {
@@ -688,7 +757,11 @@ func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *Compi
}
return err
}
if err := lagoon.DeferredBind(ctx, tx, sc.key, cf.name, lagoon.DeferredFileType, uitoa(f.ID), nil); err != nil {
var env *lagoon.DeferredEnvelope
if uploadID != "" {
env = &lagoon.DeferredEnvelope{UploadID: uploadID}
}
if err := lagoon.DeferredBind(ctx, tx, sc.key, cf.name, lagoon.DeferredFileType, uitoa(f.ID), env); err != nil {
return lifecycleFailure(cc, err)
}
item = fileItem(ctx, bucket, cf, f, true)
@@ -1245,7 +1318,7 @@ func (s *service) fileReorderOn(w http.ResponseWriter, r *http.Request, cc *Comp
bucket := s.bucket()
var items []FileItem
ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
files, _, err := sc.visibleFiles(tx)
files, _, err := sc.visibleFilesLocked(tx, true)
if err != nil {
return err
}