chore: baseline snapshot before moderation-strictness feature

Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
This commit is contained in:
tamakyi
2026-09-10 10:56:27 +08:00
commit e3e59e7c69
296 changed files with 67458 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
// 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()
}
}