From 0cf6c8742be087ec36d181e4fe96d59ac0bcd01f Mon Sep 17 00:00:00 2001 From: Jiale Lin <63439129+ljluestc@users.noreply.github.com> Date: Sun, 31 May 2026 13:25:44 -0700 Subject: [PATCH] fix(binding): support multipart/mixed form parsing (#2547) --- binding/binding.go | 3 +- binding/binding_nomsgpack.go | 3 +- binding/binding_test.go | 36 +++++++++++++++++++++++ binding/form.go | 4 +-- binding/multipart_form.go | 56 ++++++++++++++++++++++++++++++++++++ context.go | 7 +++-- context_test.go | 52 +++++++++++++++++++++++++++++++++ multipart_form.go | 56 ++++++++++++++++++++++++++++++++++++ 8 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 binding/multipart_form.go create mode 100644 multipart_form.go diff --git a/binding/binding.go b/binding/binding.go index eced0ae2..f85c6916 100644 --- a/binding/binding.go +++ b/binding/binding.go @@ -17,6 +17,7 @@ const ( MIMEPlain = "text/plain" MIMEPOSTForm = "application/x-www-form-urlencoded" MIMEMultipartPOSTForm = "multipart/form-data" + MIMEMultipartMixed = "multipart/mixed" MIMEPROTOBUF = "application/x-protobuf" MIMEMSGPACK = "application/x-msgpack" MIMEMSGPACK2 = "application/msgpack" @@ -110,7 +111,7 @@ func Default(method, contentType string) Binding { return YAML case MIMETOML: return TOML - case MIMEMultipartPOSTForm: + case MIMEMultipartPOSTForm, MIMEMultipartMixed: return FormMultipart case MIMEBSON: return BSON diff --git a/binding/binding_nomsgpack.go b/binding/binding_nomsgpack.go index ae364d79..3b2dce21 100644 --- a/binding/binding_nomsgpack.go +++ b/binding/binding_nomsgpack.go @@ -17,6 +17,7 @@ const ( MIMEPlain = "text/plain" MIMEPOSTForm = "application/x-www-form-urlencoded" MIMEMultipartPOSTForm = "multipart/form-data" + MIMEMultipartMixed = "multipart/mixed" MIMEPROTOBUF = "application/x-protobuf" MIMEYAML = "application/x-yaml" MIMEYAML2 = "application/yaml" @@ -102,7 +103,7 @@ func Default(method, contentType string) Binding { return ProtoBuf case MIMEYAML, MIMEYAML2: return YAML - case MIMEMultipartPOSTForm: + case MIMEMultipartPOSTForm, MIMEMultipartMixed: return FormMultipart case MIMETOML: return TOML diff --git a/binding/binding_test.go b/binding/binding_test.go index f90488cd..ba0a490b 100644 --- a/binding/binding_test.go +++ b/binding/binding_test.go @@ -162,6 +162,8 @@ func TestBindingDefault(t *testing.T) { assert.Equal(t, FormMultipart, Default(http.MethodPost, MIMEMultipartPOSTForm)) assert.Equal(t, FormMultipart, Default(http.MethodPut, MIMEMultipartPOSTForm)) + assert.Equal(t, FormMultipart, Default(http.MethodPost, MIMEMultipartMixed)) + assert.Equal(t, FormMultipart, Default(http.MethodPut, MIMEMultipartMixed)) assert.Equal(t, ProtoBuf, Default(http.MethodPost, MIMEPROTOBUF)) assert.Equal(t, ProtoBuf, Default(http.MethodPut, MIMEPROTOBUF)) @@ -593,6 +595,21 @@ func createFormMultipartRequest(t *testing.T) *http.Request { return req } +func createFormMultipartMixedRequest(t *testing.T) *http.Request { + boundary := "--testboundary" + body := new(bytes.Buffer) + mw := multipart.NewWriter(body) + defer mw.Close() + + require.NoError(t, mw.SetBoundary(boundary)) + require.NoError(t, mw.WriteField("foo", "bar")) + require.NoError(t, mw.WriteField("bar", "foo")) + req, err := http.NewRequest(http.MethodPost, "/?foo=getfoo&bar=getbar", body) + require.NoError(t, err) + req.Header.Set("Content-Type", MIMEMultipartMixed+"; boundary="+boundary) + return req +} + func createFormMultipartRequestForMap(t *testing.T) *http.Request { boundary := "--testboundary" body := new(bytes.Buffer) @@ -631,6 +648,16 @@ func TestBindingFormPost(t *testing.T) { assert.Equal(t, "foo", obj.Bar) } +func TestBindingFormMultipartMixed(t *testing.T) { + req := createFormMultipartMixedRequest(t) + var obj FooBarStruct + require.NoError(t, FormMultipart.Bind(req, &obj)) + + assert.Equal(t, "multipart/form-data", FormMultipart.Name()) + assert.Equal(t, "bar", obj.Foo) + assert.Equal(t, "foo", obj.Bar) +} + func TestBindingDefaultValueFormPost(t *testing.T) { req := createDefaultFormPostRequest(t) var obj FooDefaultBarStruct @@ -932,6 +959,15 @@ func TestFormBindingMultipartFail(t *testing.T) { require.Error(t, err) } +func TestFormBindingMultipartMixed(t *testing.T) { + obj := FooBarStruct{} + req := createFormMultipartMixedRequest(t) + err := Form.Bind(req, &obj) + require.NoError(t, err) + assert.Equal(t, "getfoo", obj.Foo) + assert.Equal(t, "getbar", obj.Bar) +} + func TestFormPostBindingFail(t *testing.T) { b := FormPost assert.Equal(t, "form-urlencoded", b.Name()) diff --git a/binding/form.go b/binding/form.go index 06732e97..440fb194 100644 --- a/binding/form.go +++ b/binding/form.go @@ -25,7 +25,7 @@ func (formBinding) Bind(req *http.Request, obj any) error { if err := req.ParseForm(); err != nil { return err } - if err := req.ParseMultipartForm(defaultMemory); err != nil && !errors.Is(err, http.ErrNotMultipart) { + if err := parseMultipartForm(req, defaultMemory); err != nil && !errors.Is(err, http.ErrNotMultipart) { return err } if err := mapForm(obj, req.Form); err != nil { @@ -53,7 +53,7 @@ func (formMultipartBinding) Name() string { } func (formMultipartBinding) Bind(req *http.Request, obj any) error { - if err := req.ParseMultipartForm(defaultMemory); err != nil { + if err := parseMultipartForm(req, defaultMemory); err != nil { return err } if err := mappingByPtr(obj, (*multipartRequest)(req), "form"); err != nil { diff --git a/binding/multipart_form.go b/binding/multipart_form.go new file mode 100644 index 00000000..2906532e --- /dev/null +++ b/binding/multipart_form.go @@ -0,0 +1,56 @@ +// Copyright 2026 Gin Core Team. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package binding + +import ( + "errors" + "mime" + "net/http" + "net/url" +) + +func parseMultipartForm(req *http.Request, maxMemory int64) error { + err := req.ParseMultipartForm(maxMemory) + if err == nil || !errors.Is(err, http.ErrNotMultipart) { + return err + } + + mediaType, _, parseErr := mime.ParseMediaType(req.Header.Get("Content-Type")) + if parseErr != nil || mediaType != MIMEMultipartMixed { + return err + } + + reader, readerErr := req.MultipartReader() + if readerErr != nil { + return readerErr + } + + form, readErr := reader.ReadForm(maxMemory) + if readErr != nil { + return readErr + } + req.MultipartForm = form + + if req.PostForm == nil { + req.PostForm = make(url.Values) + } + for key, values := range form.Value { + req.PostForm[key] = append(req.PostForm[key], values...) + } + + if req.Form == nil { + req.Form = make(url.Values, len(req.PostForm)) + for key, values := range req.PostForm { + req.Form[key] = append(req.Form[key], values...) + } + } + if req.URL != nil { + for key, values := range req.URL.Query() { + req.Form[key] = append(req.Form[key], values...) + } + } + + return nil +} diff --git a/context.go b/context.go index 5174033e..fff0c2fe 100644 --- a/context.go +++ b/context.go @@ -36,6 +36,7 @@ const ( MIMEPlain = binding.MIMEPlain MIMEPOSTForm = binding.MIMEPOSTForm MIMEMultipartPOSTForm = binding.MIMEMultipartPOSTForm + MIMEMultipartMixed = binding.MIMEMultipartMixed MIMEYAML = binding.MIMEYAML MIMEYAML2 = binding.MIMEYAML2 MIMETOML = binding.MIMETOML @@ -639,7 +640,7 @@ func (c *Context) initFormCache() { if c.formCache == nil { c.formCache = make(url.Values) req := c.Request - if err := req.ParseMultipartForm(c.engine.MaxMultipartMemory); err != nil { + if err := parseMultipartForm(req, c.engine.MaxMultipartMemory); err != nil { if !errors.Is(err, http.ErrNotMultipart) { debugPrint("error on parse multipart form array: %v", err) } @@ -697,7 +698,7 @@ func getMapFromFormData(m map[string][]string, key string) (map[string]string, b // FormFile returns the first file for the provided form key. func (c *Context) FormFile(name string) (*multipart.FileHeader, error) { if c.Request.MultipartForm == nil { - if err := c.Request.ParseMultipartForm(c.engine.MaxMultipartMemory); err != nil { + if err := parseMultipartForm(c.Request, c.engine.MaxMultipartMemory); err != nil { return nil, err } } @@ -711,7 +712,7 @@ func (c *Context) FormFile(name string) (*multipart.FileHeader, error) { // MultipartForm is the parsed multipart form, including file uploads. func (c *Context) MultipartForm() (*multipart.Form, error) { - err := c.Request.ParseMultipartForm(c.engine.MaxMultipartMemory) + err := parseMultipartForm(c.Request, c.engine.MaxMultipartMemory) return c.Request.MultipartForm, err } diff --git a/context_test.go b/context_test.go index ef60379d..11d4299a 100644 --- a/context_test.go +++ b/context_test.go @@ -199,6 +199,58 @@ func TestContextMultipartForm(t *testing.T) { require.NoError(t, c.SaveUploadedFile(f.File["file"][0], "test")) } +func TestContextFormFileMixed(t *testing.T) { + buf := new(bytes.Buffer) + mw := multipart.NewWriter(buf) + w, err := mw.CreateFormFile("file", "mixed-test.txt") + require.NoError(t, err) + _, err = w.Write([]byte("mixed-content")) + require.NoError(t, err) + require.NoError(t, mw.Close()) + + c, _ := CreateTestContext(httptest.NewRecorder()) + c.Request, _ = http.NewRequest(http.MethodPost, "/", buf) + c.Request.Header.Set("Content-Type", MIMEMultipartMixed+"; boundary="+mw.Boundary()) + + f, err := c.FormFile("file") + require.NoError(t, err) + assert.Equal(t, "mixed-test.txt", f.Filename) +} + +func TestContextMultipartFormMixed(t *testing.T) { + buf := new(bytes.Buffer) + mw := multipart.NewWriter(buf) + require.NoError(t, mw.WriteField("foo", "bar")) + w, err := mw.CreateFormFile("file", "mixed-test.txt") + require.NoError(t, err) + _, err = w.Write([]byte("mixed-content")) + require.NoError(t, err) + require.NoError(t, mw.Close()) + + c, _ := CreateTestContext(httptest.NewRecorder()) + c.Request, _ = http.NewRequest(http.MethodPost, "/", buf) + c.Request.Header.Set("Content-Type", MIMEMultipartMixed+"; boundary="+mw.Boundary()) + + f, err := c.MultipartForm() + require.NoError(t, err) + assert.NotNil(t, f) + assert.Equal(t, []string{"bar"}, f.Value["foo"]) + assert.Len(t, f.File["file"], 1) +} + +func TestContextPostFormMixed(t *testing.T) { + buf := new(bytes.Buffer) + mw := multipart.NewWriter(buf) + require.NoError(t, mw.WriteField("type", "image")) + require.NoError(t, mw.Close()) + + c, _ := CreateTestContext(httptest.NewRecorder()) + c.Request, _ = http.NewRequest(http.MethodPost, "/", buf) + c.Request.Header.Set("Content-Type", MIMEMultipartMixed+"; boundary="+mw.Boundary()) + + assert.Equal(t, "image", c.PostForm("type")) +} + func TestSaveUploadedOpenFailed(t *testing.T) { buf := new(bytes.Buffer) mw := multipart.NewWriter(buf) diff --git a/multipart_form.go b/multipart_form.go new file mode 100644 index 00000000..f730856f --- /dev/null +++ b/multipart_form.go @@ -0,0 +1,56 @@ +// Copyright 2026 Gin Core Team. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package gin + +import ( + "errors" + "mime" + "net/http" + "net/url" +) + +func parseMultipartForm(req *http.Request, maxMemory int64) error { + err := req.ParseMultipartForm(maxMemory) + if err == nil || !errors.Is(err, http.ErrNotMultipart) { + return err + } + + mediaType, _, parseErr := mime.ParseMediaType(req.Header.Get("Content-Type")) + if parseErr != nil || mediaType != MIMEMultipartMixed { + return err + } + + reader, readerErr := req.MultipartReader() + if readerErr != nil { + return readerErr + } + + form, readErr := reader.ReadForm(maxMemory) + if readErr != nil { + return readErr + } + req.MultipartForm = form + + if req.PostForm == nil { + req.PostForm = make(url.Values) + } + for key, values := range form.Value { + req.PostForm[key] = append(req.PostForm[key], values...) + } + + if req.Form == nil { + req.Form = make(url.Values, len(req.PostForm)) + for key, values := range req.PostForm { + req.Form[key] = append(req.Form[key], values...) + } + } + if req.URL != nil { + for key, values := range req.URL.Query() { + req.Form[key] = append(req.Form[key], values...) + } + } + + return nil +}