159 lines
5.1 KiB
Go
159 lines
5.1 KiB
Go
// Copyright 2022 E99p1ant. All rights reserved.
|
||
// Use of this source code is governed by a MIT-style
|
||
// license that can be found in the LICENSE file.
|
||
|
||
package form
|
||
|
||
import (
|
||
"mime"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"reflect"
|
||
|
||
"github.com/flamego/flamego"
|
||
"github.com/flamego/template"
|
||
"github.com/unknwon/com"
|
||
"github.com/wuhan005/govalid"
|
||
|
||
"github.com/tamakyi/TamaBox/internal/context"
|
||
)
|
||
|
||
type ErrorCategory string
|
||
|
||
const (
|
||
ErrorCategoryDeserialization ErrorCategory = "deserialization"
|
||
ErrorCategoryValidation ErrorCategory = "validation"
|
||
)
|
||
|
||
type Error struct {
|
||
Category ErrorCategory
|
||
Error error
|
||
}
|
||
|
||
const (
|
||
multipartMemoryLimit = 10 << 20 // 10 MiB:multipart 内存驻留上限,超出由 mime/multipart 落盘
|
||
|
||
// MaxRequestBodySize 请求体总大小上限(含落盘部分),略高于内存上限以留出开销。
|
||
//
|
||
// ⚠️ 必须显式限制:ParseMultipartForm 的参数只约束内存缓冲,超出部分会无上限写入
|
||
// 临时文件;匿名可达的提问接口即接受 multipart,无总量限制则可持续撑满磁盘。
|
||
// 导出供 internal/route/bodylimit.go 的全局中间件复用——仅靠本 Bind 内限制不够,
|
||
// 组级 CSRF 校验先于 Bind 执行且会整包惰性解析 body,须把上限提前到一切解析之前。
|
||
MaxRequestBodySize = 12 << 20 // 12 MiB
|
||
)
|
||
|
||
func Bind(model interface{}) flamego.Handler {
|
||
if reflect.TypeOf(model).Kind() == reflect.Ptr {
|
||
panic("form: pointer can not be accepted as binding model")
|
||
}
|
||
|
||
return func(c context.Context, data template.Data) {
|
||
obj := reflect.New(reflect.TypeOf(model))
|
||
defer func() { c.Map(obj.Elem().Interface()) }()
|
||
|
||
r := c.Request()
|
||
|
||
// 在任何解析之前封顶请求体总大小。c.Request() 是 flamego 包装类型(Body 是方法
|
||
// 而非字段),须取内嵌 *http.Request 才能替换。超限后 Read 报错,使 ParseForm /
|
||
// ParseMultipartForm 失败并走反序列化错误分支,避免超大 body 落盘或读入内存。
|
||
httpReq := r.Request
|
||
httpReq.Body = http.MaxBytesReader(c.ResponseWriter(), httpReq.Body, MaxRequestBodySize)
|
||
|
||
contentType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||
switch contentType {
|
||
case "application/x-www-form-urlencoded", "":
|
||
if err := r.ParseForm(); err != nil {
|
||
// ⚠️ 必须 SetError:flamego 的 handler 链只要响应未写入就会自动执行下一 handler,
|
||
// 仅 Map(Error) 不中断链,零值结构体会照常进入业务动作(静默清空屏蔽词、解除
|
||
// 页面密码等)。SetError 置位 HasError,action 开头的 abortOnFormError 守卫会早退。
|
||
c.SetError(err)
|
||
c.Map(Error{Category: ErrorCategoryDeserialization, Error: err})
|
||
return
|
||
}
|
||
case "multipart/form-data":
|
||
if err := r.ParseMultipartForm(multipartMemoryLimit); err != nil {
|
||
c.SetError(err)
|
||
c.Map(Error{Category: ErrorCategoryDeserialization, Error: err})
|
||
return
|
||
}
|
||
}
|
||
|
||
typ := reflect.TypeOf(obj.Interface())
|
||
val := reflect.ValueOf(obj.Interface())
|
||
if typ.Kind() == reflect.Ptr {
|
||
typ = typ.Elem()
|
||
val = val.Elem()
|
||
}
|
||
for i := 0; i < typ.NumField(); i++ {
|
||
field := typ.Field(i)
|
||
fieldName := typ.Field(i).Tag.Get("form")
|
||
if fieldName == "" {
|
||
fieldName = com.ToSnakeCase(field.Name)
|
||
}
|
||
|
||
if r.MultipartForm != nil {
|
||
fhType := reflect.TypeOf((*multipart.FileHeader)(nil))
|
||
|
||
value, ok := r.MultipartForm.Value[fieldName]
|
||
if ok {
|
||
if val.Field(i).Kind() == reflect.Slice && val.Field(i).Type().Elem() == fhType {
|
||
continue
|
||
}
|
||
|
||
// FIXME: We don't implement the slice type yet.
|
||
val.Field(i).Set(reflect.ValueOf(value[0]))
|
||
} else {
|
||
value, ok := r.MultipartForm.File[fieldName]
|
||
if ok {
|
||
numElems := len(value)
|
||
|
||
if val.Field(i).Kind() == reflect.Slice && numElems > 0 && val.Field(i).Type().Elem() == fhType {
|
||
slice := reflect.MakeSlice(val.Field(i).Type(), numElems, numElems)
|
||
for i := 0; i < numElems; i++ {
|
||
slice.Index(i).Set(reflect.ValueOf(value[i]))
|
||
}
|
||
val.Field(i).Set(slice)
|
||
} else if val.Field(i).Type() == fhType {
|
||
val.Field(i).Set(reflect.ValueOf(value[0]))
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// 非 multipart 请求仅绑定 string 字段,其余类型(如 []*multipart.FileHeader
|
||
// 图片字段)保持零值,否则 reflect.Set 会因类型不匹配 panic。
|
||
// 带 enctype 的提问表单走上面分支从未触发,纯文本提交(如私聊)会命中此路径。
|
||
if val.Field(i).Kind() == reflect.String {
|
||
val.Field(i).Set(reflect.ValueOf(r.Form.Get(fieldName)))
|
||
}
|
||
}
|
||
}
|
||
|
||
errors, ok := govalid.Check(obj.Interface())
|
||
if !ok {
|
||
Assign(obj.Interface(), data)
|
||
|
||
c.SetError(errors[0])
|
||
c.Map(Error{Category: ErrorCategoryValidation, Error: errors[0]})
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// Assign assigns form values back to the template data.
|
||
func Assign(form interface{}, data map[string]interface{}) {
|
||
typ := reflect.TypeOf(form)
|
||
val := reflect.ValueOf(form)
|
||
|
||
if typ.Kind() == reflect.Ptr {
|
||
typ = typ.Elem()
|
||
val = val.Elem()
|
||
}
|
||
|
||
for i := 0; i < typ.NumField(); i++ {
|
||
field := typ.Field(i)
|
||
fieldName := com.ToSnakeCase(field.Name)
|
||
|
||
data[fieldName] = val.Field(i).Interface()
|
||
}
|
||
}
|