Files
go-omni/contract/multipart.go

332 lines
9.4 KiB
Go
Raw Blame History

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
}