refactor: move go-chi/binding into Gitea (#39528)

The `gitea.com/go-chi/binding` package only exists for Gitea, so it
moves into `modules/web/binding` to fix its bugs directly. Split out of
https://github.com/go-gitea/gitea/pull/39504.

- GET and HEAD always bind the query
- JSON `null` slice elements and nested `TrimSpace` fields bind
correctly
- Integer fields reject out-of-range values instead of wrapping
- An empty JSON body binds nothing and an unknown binding rule is an
error

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
silverwind
2026-10-02 15:37:41 +02:00
committed by GitHub
parent 2f01ec38bd
commit e6ffbea888
17 changed files with 748 additions and 72 deletions
-5
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -6,7 +6,6 @@ toolchain go1.27.1
require (
connectrpc.com/connect v1.21.0
gitea.com/go-chi/binding v0.0.0-20260819122636-082915a69981
gitea.com/go-chi/cache v0.2.1
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6
-2
View File
@@ -8,8 +8,6 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
gitea.com/go-chi/binding v0.0.0-20260819122636-082915a69981 h1:LmdlwGbzgZFZA3bK3R1q8QrNabaSz3KpnOJBLmzhN6E=
gitea.com/go-chi/binding v0.0.0-20260819122636-082915a69981/go.mod h1:q1SSPpkC9A0gfNnoqqZ3My6kEHIpKN6QsTI+Zx73B/o=
gitea.com/go-chi/cache v0.2.1 h1:bfAPkvXlbcZxPCpcmDVCWoHgiBSBmZN/QosnZvEC0+g=
gitea.com/go-chi/cache v0.2.1/go.mod h1:Qic0HZ8hOHW62ETGbonpwz8WYypj9NieU9659wFUJ8Q=
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 h1:p2ki+WK0cIeNQuqjR98IP2KZQKRzJJiV7aTeMAFwaWo=
+1 -1
View File
@@ -53,7 +53,7 @@ type EditUserOption struct {
// Whether the user must change password on next login
MustChangePassword *bool `json:"must_change_password"`
// The user's personal website URL
Website *string `json:"website" binding:"OmitEmpty;ValidUrl;MaxSize(255)"`
Website *string `json:"website" binding:"ValidUrl;MaxSize(255)"`
// The user's location or address
Location *string `json:"location" binding:"MaxSize(50)"`
// The user's personal description or bio
+1 -2
View File
@@ -5,8 +5,7 @@ package structs
import (
"gitea.dev/modules/translation/i18n"
"gitea.com/go-chi/binding" //nolint:depguard // avoid cycle import
"gitea.dev/modules/web/binding"
)
// ValidateContext is a special context for form validation middleware
+2 -2
View File
@@ -63,7 +63,7 @@ type CreateTeamOption struct {
// Whether the team can create repositories in the organization
CanCreateOrgRepo bool `json:"can_create_org_repo"`
// Team visibility within the organization. Defaults to "private".
Visibility TeamVisibility `json:"visibility" binding:"OmitEmpty;In(public,limited,private)"`
Visibility TeamVisibility `json:"visibility" binding:"In(public,limited,private)"`
}
// EditTeamOption options for editing a team
@@ -85,5 +85,5 @@ type EditTeamOption struct {
CanCreateOrgRepo *bool `json:"can_create_org_repo"`
// Team visibility within the organization. When omitted, visibility is
// left unchanged.
Visibility *TeamVisibility `json:"visibility" binding:"OmitEmpty;In(public,limited,private)"`
Visibility *TeamVisibility `json:"visibility" binding:"In(public,limited,private)"`
}
+1 -1
View File
@@ -173,7 +173,7 @@ type CreateRepoOption struct {
type EditRepoOption struct {
// name of the repository
// unique: true
Name *string `json:"name,omitempty" binding:"OmitEmpty;AlphaDashDot;MaxSize(100);"`
Name *string `json:"name,omitempty" binding:"AlphaDashDot;MaxSize(100)"`
// a short description of the repository.
Description *string `json:"description,omitempty" binding:"MaxSize(2048)"`
// a URL with more information about the repository.
+1 -1
View File
@@ -89,7 +89,7 @@ type UserSettings struct {
// swagger:model
type UserSettingsOptions struct {
FullName *string `json:"full_name" binding:"MaxSize(100)"`
Website *string `json:"website" binding:"OmitEmpty;ValidUrl;MaxSize(255)"`
Website *string `json:"website" binding:"ValidUrl;MaxSize(255)"`
Description *string `json:"description" binding:"MaxSize(255)"`
Location *string `json:"location" binding:"MaxSize(50)"`
Language *string `json:"language"`
+8 -26
View File
@@ -5,7 +5,6 @@ package validation
import (
"context"
"io"
"reflect"
"regexp"
"strings"
@@ -14,14 +13,13 @@ import (
"gitea.dev/modules/auth"
"gitea.dev/modules/git"
"gitea.dev/modules/glob"
"gitea.dev/modules/json"
"gitea.dev/modules/util"
"gitea.com/go-chi/binding" //nolint:depguard // this package wraps it
"gitea.dev/modules/web/binding"
)
const (
ErrCustomMessage = "CustomMessage"
ErrEmail = "EmailError"
ErrURL = "UrlError"
ErrGitRefName = "GitRefNameError"
ErrGlobPattern = "GlobPattern"
ErrRegexPattern = "RegexPattern"
@@ -30,35 +28,19 @@ const (
ErrInvalidBadgeSlug = "InvalidBadgeSlug"
)
type jsonProvider struct{}
func (j jsonProvider) Marshal(v any) ([]byte, error) { return json.Marshal(v) }
func (j jsonProvider) Unmarshal(data []byte, v any) error { return json.Unmarshal(data, v) }
func (j jsonProvider) NewDecoder(reader io.Reader) binding.JSONDecoder {
return json.NewDecoder(reader)
}
func (j jsonProvider) NewEncoder(writer io.Writer) binding.JSONEncoder {
return json.NewEncoder(writer)
}
func newFieldError(field reflect.StructField, cls, msg string) *BindingError {
return &BindingError{FieldNames: []string{field.Name}, Classification: cls, Message: msg}
}
func AddValidationError(errs BindingErrors, fieldName, errorMsg string) BindingErrors {
errs.Add([]string{fieldName}, ErrCustomMessage, errorMsg)
return errs
return append(errs, BindingError{FieldNames: []string{fieldName}, Classification: ErrCustomMessage, Message: errorMsg})
}
// AddBindingRules adds additional binding rules
func AddBindingRules(b *binding.Binder) {
b.ClearRules("Email")
b.AddRuleNonZero("Email", func(_ context.Context, f *binding.ValidationField) *binding.Error {
if !IsEmailAddressValid(f.ValueMustString()) {
return newFieldError(f.StructField, binding.ERR_EMAIL, "invalid email")
return newFieldError(f.StructField, ErrEmail, "invalid email")
}
return nil
})
@@ -71,13 +53,13 @@ func AddBindingRules(b *binding.Binder) {
})
b.AddRuleNonZero("ValidUrl", func(ctx context.Context, f *binding.ValidationField) *binding.Error {
if !IsValidURL(f.ValueMustString()) {
return newFieldError(f.StructField, binding.ERR_URL, "Url")
return newFieldError(f.StructField, ErrURL, "Url")
}
return nil
})
b.AddRuleNonZero("ValidSiteUrl", func(ctx context.Context, f *binding.ValidationField) *binding.Error {
if !IsValidSiteURL(f.ValueMustString()) {
return newFieldError(f.StructField, binding.ERR_URL, "Url")
return newFieldError(f.StructField, ErrURL, "Url")
}
return nil
})
@@ -152,7 +134,7 @@ func validPort(p string) bool {
}
var Binder = sync.OnceValue(func() *binding.Binder {
b := binding.NewBinder().WithJSONProvider(jsonProvider{}).WithDefaultRules().WithNameMapper(util.ToSnakeCase)
b := binding.NewBinder()
AddBindingRules(b)
return b
})
+391
View File
@@ -0,0 +1,391 @@
// Copyright 2014 Martini Authors
// Copyright 2014 The Macaron Authors
// Copyright 2020 The Gitea Authors
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Package binding binds form, multipart and JSON request data to structs and validates them by their "binding" tags.
package binding
import (
"cmp"
"context"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"unicode/utf8"
"gitea.dev/modules/json"
"gitea.dev/modules/util"
)
const (
errContentType = "ContentTypeError"
errDeserialization = "DeserializationError"
errTypeCast = "TypeCastError"
errRule = "RuleError"
ErrRequired = "RequiredError"
ErrAlphaDashDot = "AlphaDashDotError"
ErrMinSize = "MinSizeError"
ErrMaxSize = "MaxSizeError"
ErrRange = "RangeError"
ErrIn = "InError"
ErrInclude = "IncludeError"
)
const multipartMaxMemory = 10 * 1024 * 1024
type (
Errors []Error
Error struct {
FieldNames []string
Classification string
Message string
}
)
func (e Error) Error() string {
return e.Message
}
func (e *Errors) addDeserializationError(err error) {
*e = append(*e, Error{Classification: errDeserialization, Message: err.Error()})
}
func (e *Errors) addOptional(err *Error) {
if err != nil {
*e = append(*e, *err)
}
}
func newFieldError(field reflect.StructField, classification, message string) *Error {
return &Error{FieldNames: []string{field.Name}, Classification: classification, Message: message}
}
type ValidationField struct {
StructField reflect.StructField
reflectValue reflect.Value
ruleArgs []string
}
func (f *ValidationField) valueAsString() string {
return fmt.Sprint(reflect.Indirect(f.reflectValue).Interface())
}
func (f *ValidationField) ValueMustString() string {
value := reflect.Indirect(f.reflectValue)
if value.Kind() != reflect.String {
panic("field value must be a string")
}
return value.String()
}
func (f *ValidationField) valueSize() int {
value := reflect.Indirect(f.reflectValue)
switch value.Kind() {
case reflect.String:
return utf8.RuneCountInString(value.String())
case reflect.Slice:
return value.Len()
}
panic("unsupported type: " + value.Kind().String())
}
func (f *ValidationField) assignValue(newValue any) {
if f.reflectValue.Kind() == reflect.Pointer {
ptr := reflect.New(f.StructField.Type.Elem())
ptr.Elem().Set(reflect.ValueOf(newValue).Convert(ptr.Elem().Type()))
f.reflectValue.Set(ptr)
return
}
f.reflectValue.Set(reflect.ValueOf(newValue).Convert(f.reflectValue.Type()))
}
type RuleValidator func(ctx context.Context, field *ValidationField) *Error
type ruleValidatorItem struct {
forZeroValue bool
validatorFn RuleValidator
}
type Binder struct {
rules map[string]ruleValidatorItem
}
func NewBinder() *Binder {
binder := &Binder{rules: map[string]ruleValidatorItem{}}
binder.AddRuleNonZero("TrimSpace", func(_ context.Context, field *ValidationField) *Error {
stringType := reflect.TypeFor[string]()
value := reflect.Indirect(field.reflectValue)
if !value.CanConvert(stringType) {
return newFieldError(field.StructField, errTypeCast, "TrimSpace")
}
field.assignValue(strings.TrimSpace(value.Convert(stringType).String()))
return nil
})
binder.rules["Required"] = ruleValidatorItem{forZeroValue: true, validatorFn: func(_ context.Context, field *ValidationField) *Error {
// a pointer field is optional, so "Required" only applies once a value is provided
if field.reflectValue.Kind() == reflect.Pointer && field.reflectValue.IsNil() {
return nil
}
return newFieldError(field.StructField, ErrRequired, "Required")
}}
binder.AddRuleNonZero("AlphaDashDot", func(_ context.Context, field *ValidationField) *Error {
if nonAlphaDashDotPattern().MatchString(field.ValueMustString()) {
return newFieldError(field.StructField, ErrAlphaDashDot, "AlphaDashDot")
}
return nil
})
binder.AddRuleNonZero("MinSize", func(_ context.Context, field *ValidationField) *Error {
minSize, _ := strconv.Atoi(field.ruleArgs[0])
if field.valueSize() < minSize {
return newFieldError(field.StructField, ErrMinSize, "MinSize")
}
return nil
})
binder.AddRuleNonZero("MaxSize", func(_ context.Context, field *ValidationField) *Error {
maxSize, _ := strconv.Atoi(field.ruleArgs[0])
if field.valueSize() > maxSize {
return newFieldError(field.StructField, ErrMaxSize, "MaxSize")
}
return nil
})
binder.AddRuleNonZero("Range", func(_ context.Context, field *ValidationField) *Error {
value, _ := strconv.Atoi(field.valueAsString())
minValue, _ := strconv.Atoi(field.ruleArgs[0])
maxValue, _ := strconv.Atoi(field.ruleArgs[1])
if value < minValue || value > maxValue {
return newFieldError(field.StructField, ErrRange, "Range")
}
return nil
})
binder.AddRuleNonZero("In", func(_ context.Context, field *ValidationField) *Error {
if !slices.Contains(field.ruleArgs, field.valueAsString()) {
return newFieldError(field.StructField, ErrIn, "In")
}
return nil
})
binder.AddRuleNonZero("Include", func(_ context.Context, field *ValidationField) *Error {
if !strings.Contains(field.ValueMustString(), strings.Join(field.ruleArgs, ",")) {
return newFieldError(field.StructField, ErrInclude, "Include")
}
return nil
})
return binder
}
func (b *Binder) AddRuleNonZero(name string, ruleValidator RuleValidator) {
b.rules[name] = ruleValidatorItem{validatorFn: ruleValidator}
}
func (b *Binder) Bind(req *http.Request, obj any) Errors {
ensurePointer(obj)
contentType := req.Header.Get("Content-Type")
if req.Method == http.MethodGet || req.Method == http.MethodHead ||
(contentType == "" && req.Method != http.MethodPost && req.Method != http.MethodPut) {
return b.bindForm(req, obj)
}
switch {
case strings.Contains(contentType, "form-urlencoded"):
return b.bindForm(req, obj)
case strings.Contains(contentType, "multipart/form-data"):
return b.bindMultipartForm(req, obj)
case strings.Contains(contentType, "json"):
return b.bindJSON(req, obj)
}
return Errors{{Classification: errContentType, Message: "Unsupported Content-Type"}}
}
func (b *Binder) bindForm(req *http.Request, formStruct any) (errs Errors) {
if err := req.ParseForm(); err != nil {
errs.addDeserializationError(err)
}
errs = mapForm(reflect.ValueOf(formStruct), req.Form, nil, errs)
return append(errs, b.Validate(req.Context(), formStruct)...)
}
func (b *Binder) bindMultipartForm(req *http.Request, formStruct any) (errs Errors) {
if err := req.ParseMultipartForm(multipartMaxMemory); err != nil {
errs.addDeserializationError(err)
}
if req.MultipartForm != nil {
errs = mapForm(reflect.ValueOf(formStruct), req.MultipartForm.Value, req.MultipartForm.File, errs)
}
return append(errs, b.Validate(req.Context(), formStruct)...)
}
func (b *Binder) bindJSON(req *http.Request, jsonStruct any) (errs Errors) {
err := json.NewDecoder(req.Body).Decode(jsonStruct)
if err != nil && !errors.Is(err, io.EOF) { // an empty body binds nothing
errs.addDeserializationError(err)
}
return append(errs, b.Validate(req.Context(), jsonStruct)...)
}
func (b *Binder) Validate(ctx context.Context, obj any) Errors {
ensurePointer(obj)
return b.validateStruct(ctx, nil, reflect.ValueOf(obj).Elem())
}
func (b *Binder) validateStruct(ctx context.Context, errs Errors, structValue reflect.Value) Errors {
structType := structValue.Type()
for i := range structType.NumField() {
field := structType.Field(i)
fieldValue := structValue.Field(i)
if field.Tag.Get("form") == "-" || !fieldValue.CanInterface() {
continue
}
if field.Type.Kind() == reflect.Struct ||
(field.Type.Kind() == reflect.Pointer && !fieldValue.IsNil() && field.Type.Elem().Kind() == reflect.Struct) {
errs = b.validateStruct(ctx, errs, reflect.Indirect(fieldValue))
continue
}
errs = b.validateField(ctx, errs, &ValidationField{StructField: field, reflectValue: fieldValue})
}
return errs
}
func (b *Binder) validateField(ctx context.Context, errs Errors, field *ValidationField) Errors {
if field.reflectValue.Kind() == reflect.Slice {
for i := range field.reflectValue.Len() {
if elem := reflect.Indirect(field.reflectValue.Index(i)); elem.Kind() == reflect.Struct {
errs = b.validateStruct(ctx, errs, elem)
}
}
}
for rule := range strings.SplitSeq(field.StructField.Tag.Get("binding"), ";") {
rule = strings.TrimSpace(rule)
if rule == "" {
continue
}
ruleName, ruleArgs, _ := strings.Cut(rule, "(")
field.ruleArgs = nil
if ruleArgs != "" {
field.ruleArgs = strings.Split(strings.TrimSuffix(ruleArgs, ")"), ",")
}
item, ok := b.rules[ruleName]
if !ok {
panic(fmt.Sprintf("Invalid binding rule: %q", ruleName))
}
value := reflect.Indirect(field.reflectValue)
if item.forZeroValue != (!value.IsValid() || value.IsZero()) {
continue
}
if err := item.validatorFn(ctx, field); err != nil {
return append(errs, *err)
}
}
return errs
}
var nonAlphaDashDotPattern = sync.OnceValue(func() *regexp.Regexp {
return regexp.MustCompile(`[^\w-.]`)
})
func mapForm(formStruct reflect.Value, form map[string][]string, formFiles map[string][]*multipart.FileHeader, errs Errors) Errors {
formStruct = reflect.Indirect(formStruct)
structType := formStruct.Type()
for fieldIdx := range structType.NumField() {
typeField := structType.Field(fieldIdx)
fieldValue := formStruct.Field(fieldIdx)
if typeField.Type.Kind() == reflect.Pointer && typeField.Anonymous {
fieldValue.Set(reflect.New(typeField.Type.Elem()))
errs = mapForm(fieldValue.Elem(), form, formFiles, errs)
if fieldValue.Elem().IsZero() {
fieldValue.SetZero()
}
} else if typeField.Type.Kind() == reflect.Struct {
errs = mapForm(fieldValue, form, formFiles, errs)
}
inputFieldName := typeField.Tag.Get("form")
if inputFieldName == "-" || !typeField.IsExported() {
continue
}
if inputFieldName == "" {
inputFieldName = util.ToSnakeCase(typeField.Name)
}
if inputValues, exists := form[inputFieldName]; exists {
if fieldValue.Kind() == reflect.Slice && len(inputValues) > 0 {
slice := reflect.MakeSlice(fieldValue.Type(), len(inputValues), len(inputValues))
for elemIdx, inputValue := range inputValues {
errs.addOptional(setWithProperType(typeField, inputValue, slice.Index(elemIdx)))
}
fieldValue.Set(slice)
} else {
errs.addOptional(setWithProperType(typeField, inputValues[0], fieldValue))
}
continue
}
inputFiles, exists := formFiles[inputFieldName]
if !exists {
continue
}
fileHeaderType := reflect.TypeFor[*multipart.FileHeader]()
if fieldValue.Kind() == reflect.Slice && len(inputFiles) > 0 && fieldValue.Type().Elem() == fileHeaderType {
fieldValue.Set(reflect.ValueOf(slices.Clone(inputFiles)))
} else if fieldValue.Type() == fileHeaderType {
fieldValue.Set(reflect.ValueOf(inputFiles[0]))
}
}
return errs
}
func setWithProperType(structField reflect.StructField, val string, fieldValue reflect.Value) *Error {
switch fieldValue.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
intVal, err := strconv.ParseInt(cmp.Or(val, "0"), 10, fieldValue.Type().Bits())
if err != nil {
return newFieldError(structField, errTypeCast, "Value could not be parsed as integer")
}
fieldValue.SetInt(intVal)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
uintVal, err := strconv.ParseUint(cmp.Or(val, "0"), 10, fieldValue.Type().Bits())
if err != nil {
return newFieldError(structField, errTypeCast, "Value could not be parsed as unsigned integer")
}
fieldValue.SetUint(uintVal)
case reflect.Bool:
if val == "on" {
fieldValue.SetBool(true)
break
}
boolVal, err := strconv.ParseBool(cmp.Or(val, "false"))
if err != nil {
return newFieldError(structField, errTypeCast, "Value could not be parsed as boolean")
}
fieldValue.SetBool(boolVal)
case reflect.String:
fieldValue.SetString(val)
case reflect.Pointer:
newValue := reflect.New(fieldValue.Type().Elem())
if err := setWithProperType(structField, val, newValue.Elem()); err != nil {
return err
}
fieldValue.Set(newValue)
default:
return newFieldError(structField, errDeserialization, "unsupported type: "+fieldValue.Kind().String())
}
return nil
}
func ensurePointer(obj any) {
if reflect.TypeOf(obj).Kind() != reflect.Pointer {
panic("Pointers are only accepted as binding models")
}
}
+323
View File
@@ -0,0 +1,323 @@
// Copyright 2014 Martini Authors
// Copyright 2014 The Macaron Authors
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package binding
import (
"bytes"
"cmp"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type (
Post struct {
Title string `form:"title" json:"title" binding:"Required"`
Content string `form:"content" json:"content"`
}
Person struct {
Name string `form:"name" json:"name" binding:"Required"`
Email string `form:"email" json:"email"`
}
BlogPost struct {
Post
ID int `json:"id" binding:"Required"`
Ignored string `form:"-" json:"-"`
Ratings []int `form:"rating" json:"ratings"`
Author Person `json:"author"`
Coauthor *Person `json:"coauthor"`
HeaderImage *multipart.FileHeader `json:"-"`
Pictures []*multipart.FileHeader `form:"picture" json:"-"`
}
EmbedPerson struct {
*Person
}
Group struct {
Name string `json:"name" binding:"Required"`
People []*Person `json:"people" binding:"MinSize(1)"`
}
Everything struct {
Int int
Int8 int8
Int16 int16
Uint uint
Uint8 uint8
Uint64 uint64
Bool bool
BoolOn bool
String string
IntPointer *int
Map map[string]string
}
Rules struct {
AlphaDashDot string `binding:"AlphaDashDot"`
MinSize string `binding:"MinSize(3)"`
MinSizeSlice []string `binding:"MinSize(2)"`
MaxSize string `binding:"MaxSize(1)"`
MaxSizeSlice []string `binding:"MaxSize(1)"`
Range int `binding:"Range(1,2)"`
In string `binding:"In(a,b)"`
Include string `binding:"Include(x)"`
Pointer *string `binding:"MaxSize(1)"`
}
RequiredForm struct {
String string `binding:"Required"`
Int int `binding:"Required"`
Bool bool `binding:"Required"`
Slice []int `binding:"Required"`
Pointer *string `binding:"Required"`
PointerToZero *string `binding:"Required"`
}
AnyString string
Trimmed struct {
Value AnyString `binding:"TrimSpace;Required"`
Pointer *AnyString `binding:"TrimSpace;Required"`
}
TrimForm struct {
Trimmed
Nested Trimmed
Items []Trimmed
PointerItems []*Trimmed
}
InvalidRule struct {
Field string `binding:"InvalidRule"`
}
)
func TestBind(t *testing.T) {
const formType, jsonType = "application/x-www-form-urlencoded", "application/json"
requiredTitle := Error{FieldNames: []string{"Title"}, Classification: ErrRequired, Message: "Required"}
unsupported := Errors{{Classification: errContentType, Message: "Unsupported Content-Type"}}
assert.Equal(t, "Required", requiredTitle.Error())
cases := []struct {
name string
method string
target string
contentType string
body string
expected any
errs Errors
}{
{name: "form missing required", contentType: formType, body: "content=C", expected: Post{Content: "C"}, errs: Errors{requiredTitle}},
{
name: "form malformed", contentType: formType, body: "title=%2", expected: Post{},
errs: Errors{{Classification: errDeserialization, Message: `invalid URL escape "%2"`}, requiredTitle},
},
{
name: "form nested and embedded", contentType: formType, body: "title=T&content=C&id=1&name=N&rating=4&rating=3&-=x&ignored=x",
expected: BlogPost{Post: Post{Title: "T", Content: "C"}, ID: 1, Ratings: []int{4, 3}, Author: Person{Name: "N"}},
},
{name: "query on POST", target: "/?title=T", contentType: formType, expected: Post{Title: "T"}},
{name: "query on GET", method: http.MethodGet, target: "/?title=T&content=C", expected: Post{Title: "T", Content: "C"}},
{name: "query on GET with content type", method: http.MethodGet, target: "/?title=T", contentType: jsonType, body: `{"title":"B"}`, expected: Post{Title: "T"}},
{name: "query on HEAD with content type", method: http.MethodHead, target: "/?title=T", contentType: jsonType, body: `{"title":"B"}`, expected: Post{Title: "T"}},
{name: "embedded pointer", method: http.MethodGet, target: "/?name=N&email=E", expected: EmbedPerson{&Person{Name: "N", Email: "E"}}},
{name: "embedded pointer not bound", method: http.MethodGet, target: "/", expected: EmbedPerson{}},
{name: "DELETE without content type", method: http.MethodDelete, target: "/?title=T", expected: Post{Title: "T"}},
{name: "POST without content type", target: "/?title=T", expected: Post{}, errs: unsupported},
{name: "unsupported content type", method: http.MethodPatch, contentType: "text/plain", body: "title=T", expected: Post{}, errs: unsupported},
{
name: "json ignores form tags", method: http.MethodPut, contentType: jsonType, body: `{"title":"T","content":"C","id":1,"rating":[1],"ratings":[4,3],"author":{"name":"N"}}`,
expected: BlogPost{Post: Post{Title: "T", Content: "C"}, ID: 1, Ratings: []int{4, 3}, Author: Person{Name: "N"}},
},
{
name: "json malformed", contentType: jsonType, body: `{"title":"T"`, expected: Post{Title: "T"},
errs: Errors{{Classification: errDeserialization, Message: "jsontext: unexpected EOF after offset 12"}},
},
{
name: "json null slice element", contentType: jsonType, body: `{"name":"G","people":[null,{"name":"N"}]}`,
expected: Group{Name: "G", People: []*Person{nil, {Name: "N"}}},
},
{
name: "json slice element required", contentType: jsonType, body: `{"name":"G","people":[{"email":"E"}]}`,
expected: Group{Name: "G", People: []*Person{{Email: "E"}}},
errs: Errors{{FieldNames: []string{"Name"}, Classification: ErrRequired, Message: "Required"}},
},
{
name: "form type conversion", contentType: formType,
body: "int=-1&int8=-8&int16=&uint=1&uint8=8&uint64=64&bool=true&bool_on=on&string=s&int_pointer=7",
expected: Everything{Int: -1, Int8: -8, Uint: 1, Uint8: 8, Uint64: 64, Bool: true, BoolOn: true, String: "s", IntPointer: new(7)},
},
{
name: "form type conversion errors", contentType: formType,
body: "int=x&int8=128&uint=-1&uint8=256&bool=maybe&int_pointer=x&map=x",
expected: Everything{},
errs: Errors{
{FieldNames: []string{"Int"}, Classification: errTypeCast, Message: "Value could not be parsed as integer"},
{FieldNames: []string{"Int8"}, Classification: errTypeCast, Message: "Value could not be parsed as integer"},
{FieldNames: []string{"Uint"}, Classification: errTypeCast, Message: "Value could not be parsed as unsigned integer"},
{FieldNames: []string{"Uint8"}, Classification: errTypeCast, Message: "Value could not be parsed as unsigned integer"},
{FieldNames: []string{"Bool"}, Classification: errTypeCast, Message: "Value could not be parsed as boolean"},
{FieldNames: []string{"IntPointer"}, Classification: errTypeCast, Message: "Value could not be parsed as integer"},
{FieldNames: []string{"Map"}, Classification: errDeserialization, Message: "unsupported type: map"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(cmp.Or(tc.method, http.MethodPost), cmp.Or(tc.target, "/"), strings.NewReader(tc.body))
if tc.contentType != "" {
req.Header.Set("Content-Type", tc.contentType)
}
actual := reflect.New(reflect.TypeOf(tc.expected))
assert.Equal(t, tc.errs, NewBinder().Bind(req, actual.Interface()))
assert.Equal(t, tc.expected, actual.Elem().Interface())
})
}
}
func TestBindMultipartForm(t *testing.T) {
readFile := func(t *testing.T, fileHeader *multipart.FileHeader) string {
file, err := fileHeader.Open()
require.NoError(t, err)
defer file.Close()
content, err := io.ReadAll(file)
require.NoError(t, err)
return string(content)
}
for _, parsedBefore := range []bool{false, true} {
t.Run(fmt.Sprintf("parsed before %v", parsedBefore), func(t *testing.T) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
for _, field := range [][2]string{{"title", "T"}, {"id", "1"}, {"rating", "3"}, {"rating", "5"}, {"name", "N"}} {
require.NoError(t, writer.WriteField(field[0], field[1]))
}
for _, file := range [][2]string{{"header_image", "header.txt"}, {"picture", "a.txt"}, {"picture", "b.txt"}} {
fileWriter, err := writer.CreateFormFile(file[0], file[1])
require.NoError(t, err)
_, err = fileWriter.Write([]byte("content of " + file[1]))
require.NoError(t, err)
}
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
if parsedBefore {
assert.Equal(t, "T", req.FormValue("title"))
}
var actual BlogPost
assert.Empty(t, NewBinder().Bind(req, &actual))
assert.Equal(t, Post{Title: "T"}, actual.Post)
assert.Equal(t, 1, actual.ID)
assert.Equal(t, []int{3, 5}, actual.Ratings)
assert.Equal(t, Person{Name: "N"}, actual.Author)
assert.Equal(t, "content of header.txt", readFile(t, actual.HeaderImage))
require.Len(t, actual.Pictures, 2)
assert.Equal(t, "content of a.txt", readFile(t, actual.Pictures[0]))
assert.Equal(t, "content of b.txt", readFile(t, actual.Pictures[1]))
})
}
for name, contentType := range map[string]string{"malformed body": "multipart/form-data; boundary=x", "missing boundary": "multipart/form-data"} {
t.Run(name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("--x\r\nbroken"))
req.Header.Set("Content-Type", contentType)
var actual Post
errs := NewBinder().Bind(req, &actual)
require.Len(t, errs, 2)
assert.Equal(t, errDeserialization, errs[0].Classification)
assert.Equal(t, ErrRequired, errs[1].Classification)
})
}
}
func TestValidate(t *testing.T) {
cases := []struct {
name string
data any
expected any
errs Errors
}{
{name: "rules skip zero values", data: &Rules{}},
{
name: "rules with valid values",
data: &Rules{AlphaDashDot: "a-b_c.d", MinSize: "abc", MinSizeSlice: []string{"a", "b"}, MaxSize: "é", MaxSizeSlice: []string{"a"}, Range: 2, In: "b", Include: "axb", Pointer: new("a")},
},
{
name: "rules with invalid values",
data: &Rules{AlphaDashDot: "a,b", MinSize: "ab", MinSizeSlice: []string{"a"}, MaxSize: "ab", MaxSizeSlice: []string{"a", "b"}, Range: 3, In: "c", Include: "abc", Pointer: new("ab")},
errs: Errors{
{FieldNames: []string{"AlphaDashDot"}, Classification: ErrAlphaDashDot, Message: "AlphaDashDot"},
{FieldNames: []string{"MinSize"}, Classification: ErrMinSize, Message: "MinSize"},
{FieldNames: []string{"MinSizeSlice"}, Classification: ErrMinSize, Message: "MinSize"},
{FieldNames: []string{"MaxSize"}, Classification: ErrMaxSize, Message: "MaxSize"},
{FieldNames: []string{"MaxSizeSlice"}, Classification: ErrMaxSize, Message: "MaxSize"},
{FieldNames: []string{"Range"}, Classification: ErrRange, Message: "Range"},
{FieldNames: []string{"In"}, Classification: ErrIn, Message: "In"},
{FieldNames: []string{"Include"}, Classification: ErrInclude, Message: "Include"},
{FieldNames: []string{"Pointer"}, Classification: ErrMaxSize, Message: "MaxSize"},
},
},
{
name: "required",
data: &RequiredForm{PointerToZero: new("")},
errs: Errors{
{FieldNames: []string{"String"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Int"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Bool"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Slice"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"PointerToZero"}, Classification: ErrRequired, Message: "Required"},
},
},
{
name: "nested embedded and pointer structs",
data: &BlogPost{Coauthor: &Person{}},
errs: Errors{
{FieldNames: []string{"Title"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"ID"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Name"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Name"}, Classification: ErrRequired, Message: "Required"},
},
},
{
name: "TrimSpace in nested embedded and slice elements",
data: &TrimForm{Trimmed: Trimmed{Value: " a ", Pointer: new(AnyString(" b "))}, Nested: Trimmed{Value: " "}, Items: []Trimmed{{Value: " c "}}, PointerItems: []*Trimmed{{Value: " d ", Pointer: new(AnyString(" "))}}},
expected: &TrimForm{Trimmed: Trimmed{Value: "a", Pointer: new(AnyString("b"))}, Nested: Trimmed{Value: ""}, Items: []Trimmed{{Value: "c"}}, PointerItems: []*Trimmed{{Value: "d", Pointer: new(AnyString(""))}}},
errs: Errors{
{FieldNames: []string{"Value"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Pointer"}, Classification: ErrRequired, Message: "Required"},
},
},
{
name: "TrimSpace on non-string",
data: &struct {
Float float64 `binding:"TrimSpace"`
}{Float: 1.5},
errs: Errors{{FieldNames: []string{"Float"}, Classification: errTypeCast, Message: "TrimSpace"}},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.errs, NewBinder().Validate(t.Context(), tc.data))
if tc.expected != nil {
assert.Equal(t, tc.expected, tc.data)
}
})
}
assert.PanicsWithValue(t, `Invalid binding rule: "InvalidRule"`, func() {
NewBinder().Validate(t.Context(), &InvalidRule{})
})
}
+11 -14
View File
@@ -14,8 +14,7 @@ import (
"gitea.dev/modules/translation"
"gitea.dev/modules/util"
"gitea.dev/modules/validation"
"gitea.com/go-chi/binding" //nolint:depguard // this package wraps it
"gitea.dev/modules/web/binding"
)
type (
@@ -125,7 +124,7 @@ func getFieldDisplayNameForMessage(f any, l translation.Locale, fieldNames []str
}
func BuildValidationErrorForUser(f any, l translation.Locale, bindingErrs validation.BindingErrors) (errorMessage, errorFieldName string, fieldNames []string) {
if bindingErrs.Len() == 0 {
if len(bindingErrs) == 0 {
return "", "", nil
}
bindingErr := bindingErrs[0]
@@ -137,30 +136,28 @@ func BuildValidationErrorForUser(f any, l translation.Locale, bindingErrs valida
errorFieldName = field.Name
switch classification {
case binding.ERR_REQUIRED:
case binding.ErrRequired:
errorMessage = l.TrString("form.require_error", fieldDisplayName)
case binding.ERR_ALPHA_DASH:
errorMessage = l.TrString("form.alpha_dash_error", fieldDisplayName)
case binding.ERR_ALPHA_DASH_DOT:
case binding.ErrAlphaDashDot:
errorMessage = l.TrString("form.alpha_dash_dot_error", fieldDisplayName)
case binding.ERR_MIN_SIZE:
case binding.ErrMinSize:
errorMessage = l.TrString("form.min_size_error", fieldDisplayName, getRuleBody(field, "MinSize"))
case binding.ERR_MAX_SIZE:
case binding.ErrMaxSize:
errorMessage = l.TrString("form.max_size_error", fieldDisplayName, getRuleBody(field, "MaxSize"))
case binding.ERR_RANGE:
case binding.ErrRange:
rangeMin, rangeMax, _ := strings.Cut(getRuleBody(field, "Range"), ",")
errorMessage = l.TrString("form.range_error", fieldDisplayName, rangeMin, rangeMax)
case binding.ERR_EMAIL:
case validation.ErrEmail:
errorMessage = l.TrString("form.email_error", fieldDisplayName)
case binding.ERR_URL:
case validation.ErrURL:
errorMessage = l.TrString("form.url_error", fieldDisplayName)
case binding.ERR_IN:
case binding.ErrIn:
ruleBody := getRuleBody(field, "In")
if strings.HasPrefix(ruleBody, ",") {
ruleBody = "(empty)" + ruleBody
}
errorMessage = l.TrString("form.in_error", fieldDisplayName, ruleBody)
case binding.ERR_INCLUDE:
case binding.ErrInclude:
errorMessage = l.TrString("form.include_error", fieldDisplayName, getRuleBody(field, "Include"))
case validation.ErrCustomMessage:
-2
View File
@@ -527,10 +527,8 @@
"form.SSPISeparatorReplacement": "Separator",
"form.SSPIDefaultLanguage": "Default Language",
"form.require_error": "%s cannot be empty.",
"form.alpha_dash_error": "%s should contain only alphanumeric, dash ('-') and underscore ('_') characters.",
"form.alpha_dash_dot_error": "%s should contain only alphanumeric, dash ('-'), underscore ('_') and dot ('.') characters.",
"form.git_ref_name_error": "%s must be a well-formed Git reference name.",
"form.size_error": "%[1]s must be size %[2]s.",
"form.min_size_error": "%[1]s must contain at least %[2]s characters.",
"form.max_size_error": "%[1]s must contain at most %[2]s characters.",
"form.range_error": "%[1]s must be a number from %[2]s to %[3]s.",
+1 -1
View File
@@ -65,6 +65,6 @@ type AdminEditUserForm struct {
// AdminDashboardForm form for admin dashboard operations
type AdminDashboardForm struct {
middleware.FormDefaultValidator
Op string `binding:"required"`
Op string `binding:"Required"`
From string
}
+1 -1
View File
@@ -57,5 +57,5 @@ type CreateTeamForm struct {
Permission string
RepoAccess string
CanCreateOrgRepo bool
Visibility string `binding:"OmitEmpty;In(public,limited,private)"`
Visibility string `binding:"In(public,limited,private)"`
}
+1 -7
View File
@@ -350,7 +350,7 @@ type CreateIssueForm struct {
type CreateCommentForm struct {
middleware.FormDefaultValidator
Content string
Status string `binding:"OmitEmpty;In(reopen,close)"`
Status string `binding:"In(reopen,close)"`
Files []string
}
@@ -571,12 +571,6 @@ type AddTimeManuallyForm struct {
Minutes int `binding:"Range(0,1000)"`
}
// SaveTopicForm form for save topics for repository
type SaveTopicForm struct {
middleware.FormDefaultValidator
Topics []string `binding:"topics;Required;"`
}
// AddDeployTokenForm form for adding a deploy token to a repository
type AddDeployTokenForm struct {
middleware.FormDefaultValidator
+6 -6
View File
@@ -38,7 +38,7 @@ type InstallForm struct {
SMTPAddr string `binding:"TrimSpace"`
SMTPPort string `binding:"TrimSpace"`
SMTPFrom string `binding:"TrimSpace"`
SMTPUser string `binding:"TrimSpace;OmitEmpty;MaxSize(254)" locale:"install.mailer_user"`
SMTPUser string `binding:"TrimSpace;MaxSize(254)" locale:"install.mailer_user"`
SMTPPasswd string
RegisterConfirm bool
MailNotify bool
@@ -183,7 +183,7 @@ type AvatarForm struct {
middleware.FormDefaultValidator
Source string
Avatar *multipart.FileHeader
Gravatar string `binding:"OmitEmpty;Email;MaxSize(254)"`
Gravatar string `binding:"Email;MaxSize(254)"`
}
// AddEmailForm form for adding new email
@@ -215,12 +215,12 @@ type AddOpenIDForm struct {
// AddKeyForm form for adding SSH/GPG key
type AddKeyForm struct {
middleware.FormDefaultValidator
Type string `binding:"OmitEmpty"`
Type string
Title string `binding:"Required;MaxSize(50)"`
Content string `binding:"Required"`
Signature string `binding:"OmitEmpty"`
KeyID string `binding:"OmitEmpty"`
Fingerprint string `binding:"OmitEmpty"`
Signature string
KeyID string
Fingerprint string
IsWritable bool
}