From e6ffbea8888aed41f79e56c961dcdcd276e8d727 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 2 Oct 2026 15:37:41 +0200 Subject: [PATCH] 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 Co-authored-by: bircni --- assets/go-licenses.json | 5 - go.mod | 1 - go.sum | 2 - modules/structs/admin_user.go | 2 +- modules/structs/form.go | 3 +- modules/structs/org_team.go | 4 +- modules/structs/repo.go | 2 +- modules/structs/user.go | 2 +- modules/validation/binding.go | 34 +-- modules/web/binding/binding.go | 391 ++++++++++++++++++++++++++++ modules/web/binding/binding_test.go | 323 +++++++++++++++++++++++ modules/web/middleware/binding.go | 25 +- options/locale/locale_en-US.json | 2 - services/forms/admin.go | 2 +- services/forms/org.go | 2 +- services/forms/repo_form.go | 8 +- services/forms/user_form.go | 12 +- 17 files changed, 748 insertions(+), 72 deletions(-) create mode 100644 modules/web/binding/binding.go create mode 100644 modules/web/binding/binding_test.go diff --git a/assets/go-licenses.json b/assets/go-licenses.json index 73a00dc9822..3f6c1afb126 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -14,11 +14,6 @@ "path": "filippo.io/edwards25519/LICENSE", "licenseText": "Copyright (c) 2009 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, - { - "name": "gitea.com/go-chi/binding", - "path": "gitea.com/go-chi/binding/LICENSE", - "licenseText": "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n\"License\" shall mean the terms and conditions for use, reproduction, and\ndistribution as defined by Sections 1 through 9 of this document.\n\n\"Licensor\" shall mean the copyright owner or entity authorized by the copyright\nowner that is granting the License.\n\n\"Legal Entity\" shall mean the union of the acting entity and all other entities\nthat control, are controlled by, or are under common control with that entity.\nFor the purposes of this definition, \"control\" means (i) the power, direct or\nindirect, to cause the direction or management of such entity, whether by\ncontract or otherwise, or (ii) ownership of fifty percent (50%) or more of the\noutstanding shares, or (iii) beneficial ownership of such entity.\n\n\"You\" (or \"Your\") shall mean an individual or Legal Entity exercising\npermissions granted by this License.\n\n\"Source\" form shall mean the preferred form for making modifications, including\nbut not limited to software source code, documentation source, and configuration\nfiles.\n\n\"Object\" form shall mean any form resulting from mechanical transformation or\ntranslation of a Source form, including but not limited to compiled object code,\ngenerated documentation, and conversions to other media types.\n\n\"Work\" shall mean the work of authorship, whether in Source or Object form, made\navailable under the License, as indicated by a copyright notice that is included\nin or attached to the work (an example is provided in the Appendix below).\n\n\"Derivative Works\" shall mean any work, whether in Source or Object form, that\nis based on (or derived from) the Work and for which the editorial revisions,\nannotations, elaborations, or other modifications represent, as a whole, an\noriginal work of authorship. For the purposes of this License, Derivative Works\nshall not include works that remain separable from, or merely link (or bind by\nname) to the interfaces of, the Work and Derivative Works thereof.\n\n\"Contribution\" shall mean any work of authorship, including the original version\nof the Work and any modifications or additions to that Work or Derivative Works\nthereof, that is intentionally submitted to Licensor for inclusion in the Work\nby the copyright owner or by an individual or Legal Entity authorized to submit\non behalf of the copyright owner. For the purposes of this definition,\n\"submitted\" means any form of electronic, verbal, or written communication sent\nto the Licensor or its representatives, including but not limited to\ncommunication on electronic mailing lists, source code control systems, and\nissue tracking systems that are managed by, or on behalf of, the Licensor for\nthe purpose of discussing and improving the Work, but excluding communication\nthat is conspicuously marked or otherwise designated in writing by the copyright\nowner as \"Not a Contribution.\"\n\n\"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf\nof whom a Contribution has been received by Licensor and subsequently\nincorporated within the Work.\n\n2. Grant of Copyright License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable copyright license to reproduce, prepare Derivative Works of,\npublicly display, publicly perform, sublicense, and distribute the Work and such\nDerivative Works in Source or Object form.\n\n3. Grant of Patent License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable (except as stated in this section) patent license to make, have\nmade, use, offer to sell, sell, import, and otherwise transfer the Work, where\nsuch license applies only to those patent claims licensable by such Contributor\nthat are necessarily infringed by their Contribution(s) alone or by combination\nof their Contribution(s) with the Work to which such Contribution(s) was\nsubmitted. If You institute patent litigation against any entity (including a\ncross-claim or counterclaim in a lawsuit) alleging that the Work or a\nContribution incorporated within the Work constitutes direct or contributory\npatent infringement, then any patent licenses granted to You under this License\nfor that Work shall terminate as of the date such litigation is filed.\n\n4. Redistribution.\n\nYou may reproduce and distribute copies of the Work or Derivative Works thereof\nin any medium, with or without modifications, and in Source or Object form,\nprovided that You meet the following conditions:\n\nYou must give any other recipients of the Work or Derivative Works a copy of\nthis License; and\nYou must cause any modified files to carry prominent notices stating that You\nchanged the files; and\nYou must retain, in the Source form of any Derivative Works that You distribute,\nall copyright, patent, trademark, and attribution notices from the Source form\nof the Work, excluding those notices that do not pertain to any part of the\nDerivative Works; and\nIf the Work includes a \"NOTICE\" text file as part of its distribution, then any\nDerivative Works that You distribute must include a readable copy of the\nattribution notices contained within such NOTICE file, excluding those notices\nthat do not pertain to any part of the Derivative Works, in at least one of the\nfollowing places: within a NOTICE text file distributed as part of the\nDerivative Works; within the Source form or documentation, if provided along\nwith the Derivative Works; or, within a display generated by the Derivative\nWorks, if and wherever such third-party notices normally appear. The contents of\nthe NOTICE file are for informational purposes only and do not modify the\nLicense. You may add Your own attribution notices within Derivative Works that\nYou distribute, alongside or as an addendum to the NOTICE text from the Work,\nprovided that such additional attribution notices cannot be construed as\nmodifying the License.\nYou may add Your own copyright statement to Your modifications and may provide\nadditional or different license terms and conditions for use, reproduction, or\ndistribution of Your modifications, or for any such Derivative Works as a whole,\nprovided Your use, reproduction, and distribution of the Work otherwise complies\nwith the conditions stated in this License.\n\n5. Submission of Contributions.\n\nUnless You explicitly state otherwise, any Contribution intentionally submitted\nfor inclusion in the Work by You to the Licensor shall be under the terms and\nconditions of this License, without any additional terms or conditions.\nNotwithstanding the above, nothing herein shall supersede or modify the terms of\nany separate license agreement you may have executed with Licensor regarding\nsuch Contributions.\n\n6. Trademarks.\n\nThis License does not grant permission to use the trade names, trademarks,\nservice marks, or product names of the Licensor, except as required for\nreasonable and customary use in describing the origin of the Work and\nreproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty.\n\nUnless required by applicable law or agreed to in writing, Licensor provides the\nWork (and each Contributor provides its Contributions) on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,\nincluding, without limitation, any warranties or conditions of TITLE,\nNON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are\nsolely responsible for determining the appropriateness of using or\nredistributing the Work and assume any risks associated with Your exercise of\npermissions under this License.\n\n8. Limitation of Liability.\n\nIn no event and under no legal theory, whether in tort (including negligence),\ncontract, or otherwise, unless required by applicable law (such as deliberate\nand grossly negligent acts) or agreed to in writing, shall any Contributor be\nliable to You for damages, including any direct, indirect, special, incidental,\nor consequential damages of any character arising as a result of this License or\nout of the use or inability to use the Work (including but not limited to\ndamages for loss of goodwill, work stoppage, computer failure or malfunction, or\nany and all other commercial damages or losses), even if such Contributor has\nbeen advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability.\n\nWhile redistributing the Work or Derivative Works thereof, You may choose to\noffer, and charge a fee for, acceptance of support, warranty, indemnity, or\nother liability obligations and/or rights consistent with this License. However,\nin accepting such obligations, You may act only on Your own behalf and on Your\nsole responsibility, not on behalf of any other Contributor, and only if You\nagree to indemnify, defend, and hold each Contributor harmless for any liability\nincurred by, or claims asserted against, such Contributor by reason of your\naccepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work\n\nTo apply the Apache License to your work, attach the following boilerplate\nnotice, with the fields enclosed by brackets \"[]\" replaced with your own\nidentifying information. (Don't include the brackets!) The text should be\nenclosed in the appropriate comment syntax for the file format. We also\nrecommend that a file or class name and description of purpose be included on\nthe same \"printed page\" as the copyright notice for easier identification within\nthird-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License." - }, { "name": "gitea.com/go-chi/cache", "path": "gitea.com/go-chi/cache/LICENSE", diff --git a/go.mod b/go.mod index 9c474172478..6a231b34b0a 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index aba0eba9fc6..48dbbf3e271 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/modules/structs/admin_user.go b/modules/structs/admin_user.go index c237977bf6a..0c669cd1f40 100644 --- a/modules/structs/admin_user.go +++ b/modules/structs/admin_user.go @@ -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 diff --git a/modules/structs/form.go b/modules/structs/form.go index 1b245769ad5..a2fa6d07587 100644 --- a/modules/structs/form.go +++ b/modules/structs/form.go @@ -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 diff --git a/modules/structs/org_team.go b/modules/structs/org_team.go index f80a6eebee7..564bcd75e14 100644 --- a/modules/structs/org_team.go +++ b/modules/structs/org_team.go @@ -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)"` } diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 63e244cd98b..a2a2ef155b9 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -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. diff --git a/modules/structs/user.go b/modules/structs/user.go index fe52fb8334d..62e3df35603 100644 --- a/modules/structs/user.go +++ b/modules/structs/user.go @@ -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"` diff --git a/modules/validation/binding.go b/modules/validation/binding.go index e784c8876ca..219b0da7b65 100644 --- a/modules/validation/binding.go +++ b/modules/validation/binding.go @@ -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 }) diff --git a/modules/web/binding/binding.go b/modules/web/binding/binding.go new file mode 100644 index 00000000000..fb243092265 --- /dev/null +++ b/modules/web/binding/binding.go @@ -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") + } +} diff --git a/modules/web/binding/binding_test.go b/modules/web/binding/binding_test.go new file mode 100644 index 00000000000..0c0b1e6da85 --- /dev/null +++ b/modules/web/binding/binding_test.go @@ -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{}) + }) +} diff --git a/modules/web/middleware/binding.go b/modules/web/middleware/binding.go index e19a309ea96..346cc1cf3f7 100644 --- a/modules/web/middleware/binding.go +++ b/modules/web/middleware/binding.go @@ -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: diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index b66f4b3dc07..aae5547e2c5 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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.", diff --git a/services/forms/admin.go b/services/forms/admin.go index 54e6b721c64..ca167b82a09 100644 --- a/services/forms/admin.go +++ b/services/forms/admin.go @@ -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 } diff --git a/services/forms/org.go b/services/forms/org.go index 57a9cbf10ca..bfc4679cff3 100644 --- a/services/forms/org.go +++ b/services/forms/org.go @@ -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)"` } diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 75efc8e74a1..49f64bea63c 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -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 diff --git a/services/forms/user_form.go b/services/forms/user_form.go index 5f5eb8ca1f3..72147dc0bf2 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -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 }