This commit is contained in:
tamakyi
2026-09-08 10:10:44 +08:00
commit 2ed27dd55e
288 changed files with 67061 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
// 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
type Register struct {
Email string `valid:"required;email;maxlen:100" label:"电子邮箱" msg:"form.register.email"`
Domain string `valid:"required;alphadash;minlen:3;maxlen:20" label:"个性域名" msg:"form.register.domain"`
Name string `valid:"required;maxlen:20" label:"昵称" msg:"form.register.name"`
Password string `valid:"required;minlen:8;maxlen:30" label:"密码" msg:"form.register.password"`
RepeatPassword string `valid:"required;equal:Password" label:"重复密码" msg:"form.register.repeatPassword"`
Recaptcha string `form:"g-recaptcha-response" valid:"required" label:"Recaptcha" msg:"form.register.recaptcha"`
Language string `form:"language" label:"界面语言"` // 可选:注册时选择界面语言,留空使用站点默认
}
type Login struct {
Email string `valid:"required;email;maxlen:100" label:"电子邮箱" msg:"form.email.required"`
Password string `valid:"required" label:"密码" msg:"form.login.password"`
Recaptcha string `form:"g-recaptcha-response" valid:"required" label:"Recaptcha" msg:"form.recaptcha"`
}
type ForgotPassword struct {
Email string `valid:"required;email;maxlen:100" label:"电子邮箱" msg:"form.email.required"`
Recaptcha string `form:"g-recaptcha-response" valid:"required" label:"Recaptcha" msg:"form.recaptcha"`
}
type RecoverPassword struct {
NewPassword string `valid:"required;minlen:8;maxlen:30" label:"新密码" msg:"form.recover.newPassword"`
RepeatPassword string `valid:"required;equal:NewPassword" label:"重复密码" msg:"form.recover.repeatPassword"`
}
+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()
}
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright 2026 tamakyi. 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 (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/flamego/flamego"
"github.com/flamego/template"
"github.com/tamakyi/TamaBox/internal/context"
)
// TestBindURLEncodedSkipsFileHeaderField 回归测试:application/x-www-form-urlencoded 提交
// 到带 []*multipart.FileHeader 字段的结构体时不应 panic。
//
// 背景:Bind 的非 multipart 分支曾对所有字段无条件 Set(string),遇到 NewQuestion.Images
// ([]*multipart.FileHeader)触发 reflect.Set 类型不匹配 panic(提问表单因带
// enctype="multipart/form-data" 走 multipart 分支而从未触发;独立私聊表单等纯文本提交
// 会命中此路径,导致提问关闭时提交私聊 500)。
func TestBindURLEncodedSkipsFileHeaderField(t *testing.T) {
f := flamego.New()
// 最小依赖注入:提供项目 context.Context 与 template.Data(Bind 仅用到这两个参数)。
f.Use(func(ctx flamego.Context) {
data := template.Data{}
ctx.Map(context.Context{Context: ctx, Data: data})
ctx.Map(data)
})
var bound NewQuestion
f.Post("/", Bind(NewQuestion{}), func(q NewQuestion) {
bound = q
})
req := httptest.NewRequest(http.MethodPost, "/",
strings.NewReader("content=你好&start_chat_val=true"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
f.ServeHTTP(rec, req)
if rec.Code >= http.StatusInternalServerError {
t.Fatalf("unexpected status %d, want < 500 (reflect.Set panic 不应发生)", rec.Code)
}
if bound.Content != "你好" {
t.Fatalf("Content = %q, want %q", bound.Content, "你好")
}
if bound.StartChat != "true" {
t.Fatalf("StartChat = %q, want %q", bound.StartChat, "true")
}
if bound.Images != nil {
t.Fatalf("Images = %v, want nil(非 multipart 请求应跳过图片字段)", bound.Images)
}
}
// TestBindMultipartStillBindsImages 确认 multipart 分支(提问表单路径)不受影响:
// 文件字段仍能正常绑定。
func TestBindMultipartStillBindsImages(t *testing.T) {
f := flamego.New()
f.Use(func(ctx flamego.Context) {
data := template.Data{}
ctx.Map(context.Context{Context: ctx, Data: data})
ctx.Map(data)
})
var bound NewQuestion
f.Post("/", Bind(NewQuestion{}), func(q NewQuestion) {
bound = q
})
// multipart 请求,携带一个普通字段,无文件
body := "--BOUND\r\n" +
"Content-Disposition: form-data; name=\"content\"\r\n\r\n" +
"hello\r\n" +
"--BOUND--\r\n"
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "multipart/form-data; boundary=BOUND")
rec := httptest.NewRecorder()
f.ServeHTTP(rec, req)
if rec.Code >= http.StatusInternalServerError {
t.Fatalf("unexpected status %d, want < 500", rec.Code)
}
if bound.Content != "hello" {
t.Fatalf("Content = %q, want %q", bound.Content, "hello")
}
if bound.Images != nil {
t.Fatalf("Images = %v, want nil", bound.Images)
}
}
+43
View File
@@ -0,0 +1,43 @@
// 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/multipart"
)
// 注意:以下结构体的 Content / Answer 字段刻意不再带 govalid 的 maxlen 标签 ——
// struct tag 是编译期常量,无法跟随 conf/app.ini 的 [limits] 段调整(历史上写死 2000,
// 与前端 maxlength 不一致且无法调参)。长度上限改由各路由 handler 读取
// conf.Limits.QuestionContentLen / AnswerContentLen / FollowUpContentLen 做 rune 校验。
type NewQuestion struct {
Content string `form:"content" valid:"required" label:"问题内容" msg:"form.question.content"`
ReceiveReplyViaEmail string
ReceiveReplyEmail string `valid:"email;maxlen:254" label:"接收回复的电子邮箱" msg:"form.question.receiveReplyEmail"`
Recaptcha string `form:"g-recaptcha-response" valid:"required" label:"Recaptcha" msg:"form.recaptcha"`
Images []*multipart.FileHeader `form:"images" label:"图片"`
IsPrivate string `form:"private"`
AskPassword string `form:"ask_password" label:"提问密码"`
StartChat string `form:"start_chat_val"`
}
type PublishAnswerQuestion struct {
Answer string `form:"answer" valid:"required" label:"回答内容" msg:"form.answer.content"`
Images []*multipart.FileHeader `form:"images" label:"图片"`
}
type UpdateAnswerQuestion struct {
Answer string `form:"answer" valid:"required" label:"回答内容" msg:"form.answer.content"`
Images []*multipart.FileHeader `form:"images" label:"图片"`
}
type PublishFollowUpQuestion struct {
Content string `form:"content" valid:"required" label:"追问内容" msg:"form.followup.content"`
Token string `form:"token"`
}
type AnswerFollowUpQuestion struct {
Answer string `form:"answer" valid:"required" label:"追问回答" msg:"form.followup.answer"`
}
+53
View File
@@ -0,0 +1,53 @@
// 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
type UpdateProfile struct {
Name string `valid:"required;maxlen:20" label:"昵称" msg:"form.profile.name"`
OldPassword string `label:"旧密码"`
NewPassword string `valid:"maxlen:30" label:"新密码" msg:"form.profile.passwordMax"`
Intro string `valid:"required;maxlen:100" label:"介绍" msg:"form.profile.intro"`
Usernamecolor string `valid:"required;minlen:7;maxlen:7" label:"用户名颜色" msg:"form.profile.colorHex"`
Introcolor string `valid:"required;minlen:7;maxlen:7" label:"个人说明颜色" msg:"form.profile.colorHex"`
Qrcodebackcolor string `valid:"required;minlen:7;maxlen:7" label:"二维码背景色" msg:"form.profile.colorHex"`
Qrcodecolor string `valid:"required;minlen:7;maxlen:7" label:"二维码前景色" msg:"form.profile.colorHex"`
Dotscale string `valid:"required" label:"二维码点大小" msg:"form.profile.dotscale"`
Backgroundimagealpha string `valid:"required" label:"二维码背景透明度" msg:"form.profile.backgroundimagealpha"`
Qrcodepdpcolor string `valid:"required;minlen:7;maxlen:7" label:"二维码探测图形颜色" msg:"form.profile.colorHex"`
NotifyEmail string `label:"开启邮箱通知"`
Acfunlink string `valid:"maxlen:30" label:"AcFun主页" msg:"form.profile.linkMax"`
Bililink string `valid:"maxlen:30" label:"Bili主页" msg:"form.profile.linkMax"`
Weibolink string `valid:"maxlen:30" label:"微博主页" msg:"form.profile.linkMax"`
Bloglink string `valid:"maxlen:30" label:"博客主页" msg:"form.profile.linkMax"`
Xiaohongshulink string `valid:"maxlen:30" label:"小红书主页" msg:"form.profile.linkMax"`
UseCustomQRColor string `form:"use_custom_qr_color"`
Language string `form:"language"`
}
// UpdateHarassment 仅「允许注册用户向我提问」开关(防骚扰类型)。
// 全局屏蔽词为独立入口 UpdateBlockWords:二者分处不同设置面板保存,
// 若仍共用一个结构体会互相覆盖对方字段(见 route/user/harassment.go 注释)。
type UpdateHarassment struct {
RegisterOnly string `label:"仅允许注册用户"`
}
type UpdateBlockWords struct {
BlockWords string `label:"屏蔽词"`
}
type UpdateTags struct {
Tags string `form:"tags"`
ShowInHome string `form:"show_in_home"`
}
// 新增:页面访问密码表单
type UpdatePagePassword struct {
Password string `valid:"maxlen:30" label:"页面访问密码" msg:"form.profile.passwordMax"` // 留空表示关闭
}
// 新增:提问密码表单
type UpdateAskPassword struct {
Password string `valid:"maxlen:30" label:"提问密码" msg:"form.profile.passwordMax"` // 留空表示关闭
}