package contract import ( "encoding/json" "errors" "fmt" "io" "mime" "net/http" "os" "path" "reflect" "strconv" "strings" "sync" "unicode/utf8" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/openapi" ) var uploadType = reflect.TypeOf(File{}) // MultipartOptions limits raw request bytes (including headers), part count, // and individual decoded part sizes. Zero selects bounded defaults. Files are // spooled to 0600 temporary files; text/JSON parts are buffered up to MaxFieldBytes. type MultipartOptions struct { MaxBodyBytes int64 MaxParts int MaxFieldBytes int64 MaxFileBytes int64 TempDir string Policy RequestPolicy } // Normalized returns validated effective protocol limits without allocating resources. func (o MultipartOptions) Normalized() (MultipartOptions, error) { if o.MaxBodyBytes == 0 { o.MaxBodyBytes = DefaultMaxBodyBytes } if o.MaxParts == 0 { o.MaxParts = 128 } if o.MaxFieldBytes == 0 { o.MaxFieldBytes = 64 << 10 } if o.MaxFileBytes == 0 { o.MaxFileBytes = o.MaxBodyBytes } if o.MaxBodyBytes < 1 || o.MaxBodyBytes > 1<<40 || o.MaxParts < 1 || o.MaxParts > 10000 || o.MaxFieldBytes < 1 || o.MaxFieldBytes > 1<<30 || o.MaxFileBytes < 1 || o.MaxFileBytes > 1<<40 { return o, errors.New("invalid multipart limits") } return o, o.Policy.Validate() } func (b *Binder) HasMultipart() bool { for _, f := range b.root.fields { if multipartSource(f.source) { return true } } return false } func multipartSource(s string) bool { return s == "file" || s == "multipart" || s == "multipartJSON" } func jsonSource(s string) bool { return s == "json" || s == "multipartJSON" } // MultipartSchema describes form parts. JSON fields get an encoding declaration // in generated OpenAPI; they are decoded with the ordinary strict JSON binder. func (b *Binder) MultipartSchema() *openapi.Schema { s := &openapi.Schema{Type: "object", Properties: map[string]*openapi.Schema{}} no := false s.AdditionalPropertiesAllowed = &no for _, f := range b.root.fields { if multipartSource(f.source) { s.Properties[f.name] = copySchema(f.value.schema) if !f.optional && !f.hasDefault { s.Required = append(s.Required, f.name) } } } return s } func (b *Binder) MultipartJSONFields() []string { var out []string for _, f := range b.root.fields { if f.source == "multipartJSON" { out = append(out, f.name) } } return out } // BindMultipart validates every part before publishing the DTO or entering Logic. // On success the caller must defer cleanup; on failure all created files/readers // are cleaned here. The request's Body remains owned by the HTTP server. func (b *Binder) BindMultipart(r *http.Request, params map[string]string, target any, options MultipartOptions) (cleanup func() error, err error) { if b == nil || r == nil || b.schemaOnly { return nil, apierrors.Internal(errors.New("invalid multipart binder")) } dst := reflect.ValueOf(target) if dst.Kind() != reflect.Pointer || dst.IsNil() || dst.Elem().Type() != b.typ { return nil, apierrors.Internal(errors.New("multipart target mismatch")) } o, err := options.Normalized() if err != nil { return nil, apierrors.Internal(err) } if b.BodySchema() != nil { return nil, apierrors.Internal(errors.New("multipart request cannot contain json source fields; use multipartJSON")) } if r.ContentLength > o.MaxBodyBytes { return nil, tooLarge() } var files []File var once sync.Once var closeErr error release := func() error { once.Do(func() { for i := len(files) - 1; i >= 0; i-- { closeErr = errors.Join(closeErr, files[i].cleanup()) } }) return closeErr } success := false defer func() { if !success { err = errors.Join(err, release()) } }() // Bind query/path/header once, independently of multipart body contents. ordinary := *b root := *b.root root.fields = nil ordinary.root = &root for _, f := range b.root.fields { if !multipartSource(f.source) { root.fields = append(root.fields, f) } } fresh := reflect.New(b.typ) policy := o.Policy policy.Body = "auto" if err = ordinary.BindWithPolicy(r, params, fresh.Interface(), policy); err != nil { return nil, err } request := *r input := r.Body if input == nil { input = http.NoBody } request.Body = http.MaxBytesReader(nil, input, o.MaxBodyBytes) body, err := httpx.NewBody(&request, "multipart") if err != nil { return nil, err } fields := map[string]field{} for _, f := range b.root.fields { if multipartSource(f.source) { fields[f.name] = f } } text := map[string][]string{} uploaded := map[string][]File{} seen := map[string]int{} policy = MergePolicy(RequestPolicyFromContext(r.Context()), o.Policy) for count := 0; ; count++ { part, e := body.Parts.NextRawPart() if e == io.EOF { break } if e != nil { return nil, multipartFailure(e) } if count >= o.MaxParts { return nil, tooLarge() } f, ok := fields[part.FormName()] if !ok { return nil, invalid(errors.New("unknown multipart field")) } seen[f.name]++ // Reject encodings that would make declared byte limits ambiguous. if part.Header.Get("Content-Transfer-Encoding") != "" { return nil, invalid(errors.New("encoded multipart parts are unsupported")) } _, disposition, e := mime.ParseMediaType(part.Header.Get("Content-Disposition")) if e != nil { return nil, invalid(e) } _, isFile := disposition["filename"] if (f.source == "file") != isFile { return nil, invalid(errors.New("multipart field kind mismatch")) } limit := o.MaxFieldBytes if f.source == "file" { limit = o.MaxFileBytes if f.maxBytes > 0 && f.maxBytes < limit { limit = f.maxBytes } maxFiles := f.maxFiles if maxFiles == 0 { maxFiles = 16 } if f.value.typ.Kind() != reflect.Slice { maxFiles = 1 } if seen[f.name] > maxFiles { return nil, invalid(errors.New("too many files for multipart field")) } name := path.Base(strings.ReplaceAll(part.FileName(), "\\", "/")) if name == "" || name == "." || len(name) > 255 || !utf8.ValidString(name) || strings.ContainsAny(name, "\x00\r\n") { return nil, invalid(errors.New("invalid upload filename")) } temp, e := os.CreateTemp(o.TempDir, "omni-upload-*") if e != nil { return nil, apierrors.Internal(e) } file := File{Name: name, ContentType: part.Header.Get("Content-Type"), state: &uploadState{path: temp.Name(), readers: map[*uploadReader]bool{}}} files = append(files, file) size, e := io.Copy(temp, io.LimitReader(part, limit+1)) e = errors.Join(e, temp.Close()) if size > limit { return nil, tooLarge() } if e != nil { return nil, multipartFailure(e) } file.Size = size files[len(files)-1] = file uploaded[f.name] = append(uploaded[f.name], file) } else { if f.maxBytes > 0 && f.maxBytes < limit { limit = f.maxBytes } if seen[f.name] > 1 && (f.source == "multipartJSON" || base(f.value).typ.Kind() != reflect.Slice) { return nil, invalid(errors.New("duplicate multipart field")) } data, e := io.ReadAll(io.LimitReader(part, limit+1)) if int64(len(data)) > limit { return nil, tooLarge() } if e != nil { return nil, multipartFailure(e) } if policy.UTF8 == "reject" && !utf8.Valid(data) { return nil, invalid(errUTF8) } if policy.Charset == "utf8" && part.Header.Get("Content-Type") != "" { _, p, e := mime.ParseMediaType(part.Header.Get("Content-Type")) if e != nil || (p["charset"] != "" && !strings.EqualFold(p["charset"], "utf-8")) { return nil, invalid(errors.New("invalid multipart field charset")) } } text[f.name] = append(text[f.name], strings.ToValidUTF8(string(data), "�")) } if err = part.Close(); err != nil { return nil, multipartFailure(err) } } // Consume epilogue as well; it counts against the total request cap. if err = body.Complete(); err != nil { return nil, multipartFailure(err) } for _, f := range b.root.fields { if !multipartSource(f.source) { continue } present := seen[f.name] > 0 dest := fresh.Elem().Field(f.index) if f.source == "file" { if !present { if !f.optional { return nil, invalid(fmt.Errorf("%s: file required", f.name)) } continue } if f.value.typ.Kind() == reflect.Slice { dest.Set(reflect.ValueOf(uploaded[f.name])) } else { dest.Set(reflect.ValueOf(uploaded[f.name][0])) } continue } var value any if present { if f.source == "multipartJSON" { d := json.NewDecoder(strings.NewReader(text[f.name][0])) d.UseNumber() value, err = decodeValue(d, 0) if err == nil { _, err = d.Token() if err == io.EOF { err = nil } else if err == nil { err = errors.New("multiple JSON values") } } } else { value, err = fromStrings(f.value, text[f.name]) } if err != nil { return nil, invalid(fmt.Errorf("%s: %w", f.name, err)) } } if err = assignField(f, dest, value, present); err != nil { return nil, invalid(fmt.Errorf("%s: %w", f.name, err)) } } dst.Elem().Set(fresh.Elem()) success = true return release, nil } func multipartFailure(err error) error { var max *http.MaxBytesError if errors.As(err, &max) { return tooLarge() } var api *apierrors.APIError if errors.As(err, &api) { return err } return invalid(err) } func fieldLimit(raw string) (int64, error) { v, err := strconv.ParseInt(raw, 10, 64) if err != nil || v < 1 || v > 1<<40 { return 0, errors.New("maxBytes must be 1..1099511627776") } return v, nil }