332 lines
9.4 KiB
Go
332 lines
9.4 KiB
Go
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), "<22>"))
|
||
}
|
||
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
|
||
}
|