0904
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"` // 留空表示关闭
|
||||
}
|
||||
Reference in New Issue
Block a user