mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-02 19:38:30 +00:00
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:
Generated
-5
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"`
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user