refactor: http request binding (#38971)

Better than before, still not good enough (more work can be done in the
future)

And add the missing error handling in the PrivateContext "bind"
middleware.

By the way, picked some "TrimSpace" changes from "fix: trim whitespace
from SMTP address and port - #38934" (fix #38926)
This commit is contained in:
wxiaoguang
2026-08-19 14:15:42 +08:00
committed by GitHub
parent 6c425fae6e
commit 6904f6480c
21 changed files with 265 additions and 419 deletions
+76 -165
View File
@@ -4,17 +4,20 @@
package validation
import (
"fmt"
"context"
"io"
"reflect"
"regexp"
"strings"
"sync"
"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"
"gitea.com/go-chi/binding" //nolint:depguard // this package wraps it
)
const (
@@ -41,178 +44,75 @@ func (j jsonProvider) NewEncoder(writer io.Writer) binding.JSONEncoder {
return json.NewEncoder(writer)
}
func newFieldError(field reflect.StructField, cls, msg string) *BindingError {
return &BindingError{[]string{field.Name}, cls, msg} //nolint:govet // make sure no missing fields
}
// AddBindingRules adds additional binding rules
func AddBindingRules() {
func AddBindingRules(b *binding.Binder) {
binding.JSONProvider = jsonProvider{}
addGitRefNameBindingRule()
addValidURLBindingRule()
addValidSiteURLBindingRule()
addGlobPatternRule()
addRegexPatternRule()
addGlobOrRegexPatternRule()
addUsernamePatternRule()
addValidGroupTeamMapRule()
addSlugPatternRule()
}
func addGitRefNameBindingRule() {
// Git ref name validation rule
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return rule == "GitRefName"
},
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val)
if !git.IsValidRefPattern(str) {
errs.Add([]string{name}, ErrGitRefName, "GitRefName")
return false, errs
}
return true, errs
},
})
}
func addValidURLBindingRule() {
// URL validation rule
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return rule == "ValidUrl"
},
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val)
if len(str) != 0 && !IsValidURL(str) {
errs.Add([]string{name}, binding.ERR_URL, "Url")
return false, errs
}
return true, errs
},
})
}
func addValidSiteURLBindingRule() {
// URL validation rule
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return rule == "ValidSiteUrl"
},
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val)
if len(str) != 0 && !IsValidSiteURL(str) {
errs.Add([]string{name}, binding.ERR_URL, "Url")
return false, errs
}
return true, errs
},
})
}
func addSlugPatternRule() {
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return rule == "BadgeSlug"
},
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val)
if !IsValidBadgeSlug(str) {
errs.Add([]string{name}, ErrInvalidBadgeSlug, "invalid badge slug")
return false, errs
}
return true, errs
},
})
}
func addGlobPatternRule() {
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return rule == "GlobPattern"
},
IsValid: globPatternValidator,
})
}
func globPatternValidator(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val)
if len(str) != 0 {
if _, err := glob.Compile(str); err != nil {
errs.Add([]string{name}, ErrGlobPattern, err.Error())
return false, errs
b.AddRuleNonZero("GitRefName", func(ctx context.Context, f *binding.ValidationField) *binding.Error {
if !git.IsValidRefPattern(f.ValueMustString()) {
return newFieldError(f.StructField, ErrGitRefName, "GitRefName")
}
return nil
})
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 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 nil
})
b.AddRuleNonZero("BadgeSlug", func(ctx context.Context, f *binding.ValidationField) *binding.Error {
if !IsValidBadgeSlug(f.ValueMustString()) {
return newFieldError(f.StructField, ErrInvalidBadgeSlug, "invalid badge slug")
}
return nil
})
ruleGlobPattern := func(_ context.Context, f *binding.ValidationField) *binding.Error {
if _, err := glob.Compile(f.ValueMustString()); err != nil {
return newFieldError(f.StructField, ErrGlobPattern, err.Error())
}
return nil
}
return true, errs
}
func addRegexPatternRule() {
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return rule == "RegexPattern"
},
IsValid: regexPatternValidator,
})
}
func regexPatternValidator(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val)
if _, err := regexp.Compile(str); err != nil {
errs.Add([]string{name}, ErrRegexPattern, err.Error())
return false, errs
b.AddRuleNonZero("GlobPattern", ruleGlobPattern)
ruleRegexPattern := func(_ context.Context, f *binding.ValidationField, val string) *binding.Error {
if _, err := regexp.Compile(val); err != nil {
return newFieldError(f.StructField, ErrRegexPattern, err.Error())
}
return nil
}
return true, errs
}
func addGlobOrRegexPatternRule() {
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return rule == "GlobOrRegexPattern"
},
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := strings.TrimSpace(fmt.Sprintf("%v", val))
if len(str) >= 2 && strings.HasPrefix(str, "/") && strings.HasSuffix(str, "/") {
return regexPatternValidator(errs, name, str[1:len(str)-1])
}
return globPatternValidator(errs, name, val)
},
b.AddRuleNonZero("RegexPattern", func(ctx context.Context, f *binding.ValidationField) *binding.Error {
return ruleRegexPattern(ctx, f, f.ValueMustString())
})
}
func addUsernamePatternRule() {
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return rule == "Username"
},
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val)
if !IsValidUsername(str) {
errs.Add([]string{name}, ErrUsername, "invalid username")
return false, errs
}
return true, errs
},
b.AddRuleNonZero("GlobOrRegexPattern", func(ctx context.Context, f *binding.ValidationField) *binding.Error {
str := f.ValueMustString()
if len(str) >= 2 && strings.HasPrefix(str, "/") && strings.HasSuffix(str, "/") {
return ruleRegexPattern(ctx, f, str[1:len(str)-1])
}
return ruleGlobPattern(ctx, f)
})
}
func addValidGroupTeamMapRule() {
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return rule == "ValidGroupTeamMap"
},
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
_, err := auth.UnmarshalGroupTeamMapping(fmt.Sprintf("%v", val))
if err != nil {
errs.Add([]string{name}, ErrInvalidGroupTeamMap, err.Error())
return false, errs
}
b.AddRuleNonZero("Username", func(ctx context.Context, f *binding.ValidationField) *binding.Error {
if !IsValidUsername(f.ValueMustString()) {
return newFieldError(f.StructField, ErrUsername, "invalid username")
}
return nil
})
return true, errs
},
b.AddRuleNonZero("ValidGroupTeamMap", func(ctx context.Context, f *binding.ValidationField) *binding.Error {
_, err := auth.UnmarshalGroupTeamMapping(f.ValueMustString())
if err != nil {
return newFieldError(f.StructField, ErrInvalidGroupTeamMap, err.Error())
}
return nil
})
}
@@ -238,3 +138,14 @@ func validPort(p string) bool {
}
return true
}
var Binder = sync.OnceValue(func() *binding.Binder {
b := binding.NewBinder().WithDefaultRules().WithNameMapper(util.ToSnakeCase)
AddBindingRules(b)
return b
})
type (
BindingErrors = binding.Errors
BindingError = binding.Error
)
+2 -29
View File
@@ -4,24 +4,16 @@
package validation
import (
"net/http"
"net/http/httptest"
"testing"
"gitea.com/go-chi/binding"
chi "github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
)
const (
testRoute = "/test"
)
type (
validationTestCase struct {
description string
data any
expectedErrors binding.Errors
expectedErrors BindingErrors
}
TestForm struct {
@@ -33,24 +25,5 @@ type (
)
func performValidationTest(t *testing.T, testCase validationTestCase) {
httpRecorder := httptest.NewRecorder()
m := chi.NewRouter()
m.Post(testRoute, func(resp http.ResponseWriter, req *http.Request) {
assert.Equal(t, testCase.expectedErrors, binding.Validate(req, testCase.data))
})
req, err := http.NewRequest(http.MethodPost, testRoute, nil)
if err != nil {
panic(err)
}
req.Header.Add("Content-Type", "x-www-form-urlencoded")
m.ServeHTTP(httpRecorder, req)
switch httpRecorder.Code {
case http.StatusNotFound:
panic("Routing is messed up in test fixture (got 404): check methods and paths")
case http.StatusInternalServerError:
panic("Something bad happened on '" + testCase.description + "'")
}
assert.Equal(t, testCase.expectedErrors, Binder().Validate(t.Context(), testCase.data))
}
+5 -9
View File
@@ -7,8 +7,6 @@ import (
"testing"
"gitea.dev/modules/glob"
"gitea.com/go-chi/binding"
)
func getGlobPatternErrorString(pattern string) string {
@@ -21,29 +19,27 @@ func getGlobPatternErrorString(pattern string) string {
}
func Test_GlobPatternValidation(t *testing.T) {
AddBindingRules()
globValidationTestCases := []validationTestCase{
{
description: "Empty glob pattern",
data: TestForm{
data: &TestForm{
GlobPattern: "",
},
},
{
description: "Valid glob",
data: TestForm{
data: &TestForm{
GlobPattern: "{master,release*}",
},
},
{
description: "Invalid glob",
data: TestForm{
data: &TestForm{
GlobPattern: "[a-",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"GlobPattern"},
Classification: ErrGlobPattern,
Message: getGlobPatternErrorString("[a-"),
+54 -57
View File
@@ -5,38 +5,35 @@ package validation
import (
"testing"
"gitea.com/go-chi/binding"
)
func Test_GitRefNameValidation(t *testing.T) {
AddBindingRules()
gitRefNameValidationTestCases := []validationTestCase{
{
description: "Reference name contains only characters",
data: TestForm{
data: &TestForm{
BranchName: "test",
},
},
{
description: "Reference name contains single slash",
data: TestForm{
data: &TestForm{
BranchName: "feature/test",
},
},
{
description: "Reference name has allowed special characters",
data: TestForm{
data: &TestForm{
BranchName: "debian/1%1.6.0-2",
},
},
{
description: "Reference name contains backslash",
data: TestForm{
data: &TestForm{
BranchName: "feature\\test",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -45,11 +42,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name starts with dot",
data: TestForm{
data: &TestForm{
BranchName: ".test",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -58,11 +55,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name ends with dot",
data: TestForm{
data: &TestForm{
BranchName: "test.",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -71,11 +68,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name starts with slash",
data: TestForm{
data: &TestForm{
BranchName: "/test",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -84,11 +81,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name ends with slash",
data: TestForm{
data: &TestForm{
BranchName: "test/",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -97,11 +94,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name ends with .lock",
data: TestForm{
data: &TestForm{
BranchName: "test.lock",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -110,11 +107,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name contains multiple consecutive dots",
data: TestForm{
data: &TestForm{
BranchName: "te..st",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -123,11 +120,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name contains multiple consecutive slashes",
data: TestForm{
data: &TestForm{
BranchName: "te//st",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -136,11 +133,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name is single @",
data: TestForm{
data: &TestForm{
BranchName: "@",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -149,11 +146,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name has @{",
data: TestForm{
data: &TestForm{
BranchName: "branch@{",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -162,11 +159,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name has unallowed special character ~",
data: TestForm{
data: &TestForm{
BranchName: "~debian/1%1.6.0-2",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -175,11 +172,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name has unallowed special character *",
data: TestForm{
data: &TestForm{
BranchName: "*debian/1%1.6.0-2",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -188,11 +185,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name has unallowed special character ?",
data: TestForm{
data: &TestForm{
BranchName: "?debian/1%1.6.0-2",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -201,11 +198,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name has unallowed special character ^",
data: TestForm{
data: &TestForm{
BranchName: "^debian/1%1.6.0-2",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -214,11 +211,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name has unallowed special character :",
data: TestForm{
data: &TestForm{
BranchName: "debian:jessie",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -227,11 +224,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name has unallowed special character (whitespace)",
data: TestForm{
data: &TestForm{
BranchName: "debian jessie",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
@@ -240,11 +237,11 @@ func Test_GitRefNameValidation(t *testing.T) {
},
{
description: "Reference name has unallowed special character [",
data: TestForm{
data: &TestForm{
BranchName: "debian[jessie",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"BranchName"},
Classification: ErrGitRefName,
Message: "GitRefName",
+5 -9
View File
@@ -6,8 +6,6 @@ package validation
import (
"regexp"
"testing"
"gitea.com/go-chi/binding"
)
func getRegexPatternErrorString(pattern string) string {
@@ -18,29 +16,27 @@ func getRegexPatternErrorString(pattern string) string {
}
func Test_RegexPatternValidation(t *testing.T) {
AddBindingRules()
regexValidationTestCases := []validationTestCase{
{
description: "Empty regex pattern",
data: TestForm{
data: &TestForm{
RegexPattern: "",
},
},
{
description: "Valid regex",
data: TestForm{
data: &TestForm{
RegexPattern: `(\d{1,3})+`,
},
},
{
description: "Invalid regex",
data: TestForm{
data: &TestForm{
RegexPattern: "[a-",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"RegexPattern"},
Classification: ErrRegexPattern,
Message: getRegexPatternErrorString("[a-"),
+21 -25
View File
@@ -5,92 +5,88 @@ package validation
import (
"testing"
"gitea.com/go-chi/binding"
)
func Test_ValidURLValidation(t *testing.T) {
AddBindingRules()
urlValidationTestCases := []validationTestCase{
{
description: "Empty URL",
data: TestForm{
data: &TestForm{
URL: "",
},
},
{
description: "URL without port",
data: TestForm{
data: &TestForm{
URL: "http://test.lan/",
},
},
{
description: "URL with port",
data: TestForm{
data: &TestForm{
URL: "http://test.lan:3000/",
},
},
{
description: "URL with IPv6 address without port",
data: TestForm{
data: &TestForm{
URL: "http://[::1]/",
},
},
{
description: "URL with IPv6 address with port",
data: TestForm{
data: &TestForm{
URL: "http://[::1]:3000/",
},
},
{
description: "Invalid URL",
data: TestForm{
data: &TestForm{
URL: "http//test.lan/",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"URL"},
Classification: binding.ERR_URL,
Classification: "UrlError",
Message: "Url",
},
},
},
{
description: "Invalid schema",
data: TestForm{
data: &TestForm{
URL: "ftp://test.lan/",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"URL"},
Classification: binding.ERR_URL,
Classification: "UrlError",
Message: "Url",
},
},
},
{
description: "Invalid port",
data: TestForm{
data: &TestForm{
URL: "http://test.lan:3x4/",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"URL"},
Classification: binding.ERR_URL,
Classification: "UrlError",
Message: "Url",
},
},
},
{
description: "Invalid port with IPv6 address",
data: TestForm{
data: &TestForm{
URL: "http://[::1]:3x4/",
},
expectedErrors: binding.Errors{
binding.Error{
expectedErrors: BindingErrors{
BindingError{
FieldNames: []string{"URL"},
Classification: binding.ERR_URL,
Classification: "UrlError",
Message: "Url",
},
},