Files
tamabox/internal/form/form.go
T
2026-09-08 10:10:44 +08:00

173 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 是 multipart 解析时驻留内存的上限,超出部分由
// mime/multipart 落盘到临时文件。
multipartMemoryLimit = 10 << 20 // 10 MiB
// MaxRequestBodySize 是单次请求体的**总**大小上限(含落盘部分)。
//
// ⚠️ 必须显式限制:ParseMultipartForm 的参数只约束内存缓冲,超出部分会无上限地
// 写入临时文件。匿名可达的提问接口即接受 multipart,若不加总量限制,攻击者可持续
// POST 超大 body 撑满磁盘(http.Server 的 ReadTimeout 在高带宽下不足以形成约束)。
// 略高于内存上限,为 multipart 边界与其他表单字段留出开销。
// 导出供 internal/route 的全局 requestBodyLimitMiddleware 复用同一上限:
// 仅靠本 Bind 内的 MaxBytesReader 不够——Contexter 组级 CSRF 校验先于 Bind 执行,
// 无 X-CSRF-Token 头时它会整包惰性解析 body,超大 multipart 会在 Bind 限制生效前
// 落盘;全局中间件把上限提前到一切解析之前(见 internal/route/bodylimit.go)。
MaxRequestBodySize = 12 << 20 // 12 MiB
)
func Bind(model interface{}) flamego.Handler {
// Ensure not pointer.
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 才能替换 Body。
// MaxBytesReader 在超限时让后续 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 开头若检查 ctx.HasError() 即会早退;
// 不检查的 action 也已在 route/user 各 handler 补了 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
}
}
// Bind the form data to the given struct.
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 请求(application/x-www-form-urlencoded):仅绑定 string 字段。
// 其余类型(如 []*multipart.FileHeader 图片字段)保持零值,
// 否则 reflect.Set 会因类型不匹配 panic(string → []*multipart.FileHeader)。
// 此前提问表单因带 enctype="multipart/form-data" 走上面分支而从未触发;
// 独立私聊表单等纯文本提交会命中此路径,必须先做类型判断。
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()
}
}