diff --git a/database/model/model.go b/database/model/model.go index 99566d9d..787e5572 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -53,14 +53,14 @@ type Inbound struct { Remark string `json:"remark" form:"remark"` // Human-readable remark Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp - TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` // Traffic reset schedule + TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics // Xray configuration fields Listen string `json:"listen" form:"listen"` - Port int `json:"port" form:"port"` - Protocol Protocol `json:"protocol" form:"protocol"` + Port int `json:"port" form:"port" validate:"gte=1,lte=65535"` + Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria hysteria2 http mixed tunnel"` Settings string `json:"settings" form:"settings"` StreamSettings string `json:"streamSettings" form:"streamSettings"` Tag string `json:"tag" form:"tag" gorm:"unique"` @@ -247,13 +247,13 @@ type Setting struct { // status fields below. type Node struct { Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` - Name string `json:"name" form:"name" gorm:"uniqueIndex"` + Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required"` Remark string `json:"remark" form:"remark"` - Scheme string `json:"scheme" form:"scheme"` - Address string `json:"address" form:"address"` - Port int `json:"port" form:"port"` + Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"` + Address string `json:"address" form:"address" validate:"required"` + Port int `json:"port" form:"port" validate:"gte=1,lte=65535"` BasePath string `json:"basePath" form:"basePath"` - ApiToken string `json:"apiToken" form:"apiToken"` + ApiToken string `json:"apiToken" form:"apiToken" validate:"required"` Enable bool `json:"enable" form:"enable" gorm:"default:true"` AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"` diff --git a/go.mod b/go.mod index c2183fe1..f4637edb 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/gin-contrib/sessions v1.1.0 github.com/gin-gonic/gin v1.12.0 github.com/go-ldap/ldap/v3 v3.4.13 + github.com/go-playground/validator/v10 v10.30.2 github.com/goccy/go-json v0.10.6 github.com/goccy/go-yaml v1.19.2 github.com/google/uuid v1.6.0 @@ -48,7 +49,6 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.30.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect diff --git a/web/controller/inbound.go b/web/controller/inbound.go index a50648e3..eaeddb88 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/mhsanaei/3x-ui/v3/database/model" + "github.com/mhsanaei/3x-ui/v3/web/middleware" "github.com/mhsanaei/3x-ui/v3/web/service" "github.com/mhsanaei/3x-ui/v3/web/session" "github.com/mhsanaei/3x-ui/v3/web/websocket" @@ -129,10 +130,8 @@ func (a *InboundController) getInbound(c *gin.Context) { // addInbound creates a new inbound configuration. func (a *InboundController) addInbound(c *gin.Context) { - inbound := &model.Inbound{} - err := c.ShouldBind(inbound) - if err != nil { - jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err) + inbound, ok := middleware.BindAndValidate[model.Inbound](c) + if !ok { return } user := session.GetLoginUser(c) @@ -200,9 +199,7 @@ func (a *InboundController) updateInbound(c *gin.Context) { inbound := &model.Inbound{ Id: id, } - err = c.ShouldBind(inbound) - if err != nil { - jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) + if !middleware.BindAndValidateInto(c, inbound) { return } // Same NodeID=0 → nil normalisation as addInbound. UpdateInbound diff --git a/web/controller/node.go b/web/controller/node.go index d12db5f8..1e161b77 100644 --- a/web/controller/node.go +++ b/web/controller/node.go @@ -8,6 +8,7 @@ import ( "time" "github.com/mhsanaei/3x-ui/v3/database/model" + "github.com/mhsanaei/3x-ui/v3/web/middleware" "github.com/mhsanaei/3x-ui/v3/web/service" "github.com/gin-gonic/gin" @@ -61,9 +62,8 @@ func (a *NodeController) get(c *gin.Context) { } func (a *NodeController) add(c *gin.Context) { - n := &model.Node{} - if err := c.ShouldBind(n); err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err) + n, ok := middleware.BindAndValidate[model.Node](c) + if !ok { return } if err := a.nodeService.Create(n); err != nil { @@ -79,9 +79,8 @@ func (a *NodeController) update(c *gin.Context) { jsonMsg(c, I18nWeb(c, "get"), err) return } - n := &model.Node{} - if err := c.ShouldBind(n); err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err) + n, ok := middleware.BindAndValidate[model.Node](c) + if !ok { return } if err := a.nodeService.Update(id, n); err != nil { diff --git a/web/controller/setting.go b/web/controller/setting.go index 1003f783..fedd1062 100644 --- a/web/controller/setting.go +++ b/web/controller/setting.go @@ -7,6 +7,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/util/crypto" "github.com/mhsanaei/3x-ui/v3/web/entity" + "github.com/mhsanaei/3x-ui/v3/web/middleware" "github.com/mhsanaei/3x-ui/v3/web/service" "github.com/mhsanaei/3x-ui/v3/web/session" @@ -74,14 +75,12 @@ func (a *SettingController) getDefaultSettings(c *gin.Context) { // updateSetting updates all settings with the provided data. func (a *SettingController) updateSetting(c *gin.Context) { - allSetting := &entity.AllSetting{} - err := c.ShouldBind(allSetting) - if err != nil { - jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) + allSetting, ok := middleware.BindAndValidate[entity.AllSetting](c) + if !ok { return } oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable() - err = a.settingService.UpdateAllSetting(allSetting) + err := a.settingService.UpdateAllSetting(allSetting) if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable { if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil { err = bumpErr diff --git a/web/entity/entity.go b/web/entity/entity.go index 82c33d10..3ad6b0d7 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -21,21 +21,21 @@ type Msg struct { // AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings. type AllSetting struct { // Web server settings - WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address - WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation - WebPort int `json:"webPort" form:"webPort"` // Web server port number - WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server - WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server - WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs - SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes - TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"` // Trusted reverse proxy IPs/CIDRs for forwarded headers + WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address + WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation + WebPort int `json:"webPort" form:"webPort" validate:"gte=1,lte=65535"` // Web server port number + WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server + WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server + WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs + SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=0,lte=525600"` // Session maximum age in minutes (cap at one year) + TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"` // Trusted reverse proxy IPs/CIDRs for forwarded headers // UI settings - PageSize int `json:"pageSize" form:"pageSize"` // Number of items per page in lists - ExpireDiff int `json:"expireDiff" form:"expireDiff"` // Expiration warning threshold in days - TrafficDiff int `json:"trafficDiff" form:"trafficDiff"` // Traffic warning threshold percentage - RemarkModel string `json:"remarkModel" form:"remarkModel"` // Remark model pattern for inbounds - Datepicker string `json:"datepicker" form:"datepicker"` // Date picker format + PageSize int `json:"pageSize" form:"pageSize" validate:"gte=1,lte=1000"` // Number of items per page in lists + ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"` // Expiration warning threshold in days + TrafficDiff int `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`// Traffic warning threshold percentage + RemarkModel string `json:"remarkModel" form:"remarkModel"` // Remark model pattern for inbounds + Datepicker string `json:"datepicker" form:"datepicker"` // Date picker format // Telegram bot settings TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"` // Enable Telegram bot notifications @@ -45,9 +45,9 @@ type AllSetting struct { TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"` // Telegram chat ID for notifications TgRunTime string `json:"tgRunTime" form:"tgRunTime"` // Cron schedule for Telegram notifications TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"` // Enable database backup via Telegram - TgBotLoginNotify bool `json:"tgBotLoginNotify" form:"tgBotLoginNotify"` // Send login notifications - TgCpu int `json:"tgCpu" form:"tgCpu"` // CPU usage threshold for alerts - TgLang string `json:"tgLang" form:"tgLang"` // Telegram bot language + TgBotLoginNotify bool `json:"tgBotLoginNotify" form:"tgBotLoginNotify"` // Send login notifications + TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` // CPU usage threshold for alerts (percent) + TgLang string `json:"tgLang" form:"tgLang"` // Telegram bot language // Security settings TimeLocation string `json:"timeLocation" form:"timeLocation"` // Time zone location @@ -64,12 +64,12 @@ type AllSetting struct { SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` // Enable routing for subscription SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"` // Subscription global routing rules (Only for Happ) SubListen string `json:"subListen" form:"subListen"` // Subscription server listen IP - SubPort int `json:"subPort" form:"subPort"` // Subscription server port + SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"` // Subscription server port SubPath string `json:"subPath" form:"subPath"` // Base path for subscription URLs SubDomain string `json:"subDomain" form:"subDomain"` // Domain for subscription server validation SubCertFile string `json:"subCertFile" form:"subCertFile"` // SSL certificate file for subscription server SubKeyFile string `json:"subKeyFile" form:"subKeyFile"` // SSL private key file for subscription server - SubUpdates int `json:"subUpdates" form:"subUpdates"` // Subscription update interval in minutes + SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"` // Subscription update interval in minutes ExternalTrafficInformEnable bool `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"` // Enable external traffic reporting ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"` // URI for external traffic reporting RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` // Restart Xray when clients are auto-disabled by expiry/traffic limit @@ -90,7 +90,7 @@ type AllSetting struct { // LDAP settings LdapEnable bool `json:"ldapEnable" form:"ldapEnable"` LdapHost string `json:"ldapHost" form:"ldapHost"` - LdapPort int `json:"ldapPort" form:"ldapPort"` + LdapPort int `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"` LdapUseTLS bool `json:"ldapUseTLS" form:"ldapUseTLS"` LdapBindDN string `json:"ldapBindDN" form:"ldapBindDN"` LdapPassword string `json:"ldapPassword" form:"ldapPassword"` @@ -106,9 +106,9 @@ type AllSetting struct { LdapInboundTags string `json:"ldapInboundTags" form:"ldapInboundTags"` LdapAutoCreate bool `json:"ldapAutoCreate" form:"ldapAutoCreate"` LdapAutoDelete bool `json:"ldapAutoDelete" form:"ldapAutoDelete"` - LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"` - LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"` - LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"` + LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB" validate:"gte=0"` + LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"` + LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"` // JSON subscription routing rules } diff --git a/web/middleware/validate.go b/web/middleware/validate.go new file mode 100644 index 00000000..e4530848 --- /dev/null +++ b/web/middleware/validate.go @@ -0,0 +1,111 @@ +package middleware + +import ( + "errors" + "net/http" + "reflect" + "strings" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/go-playground/validator/v10" + + "github.com/mhsanaei/3x-ui/v3/web/entity" +) + +var validate = validator.New(validator.WithRequiredStructEnabled()) + +func BindAndValidate[T any](c *gin.Context) (*T, bool) { + var dst T + if err := c.ShouldBind(&dst); err != nil { + writeBindFailure(c, err) + return nil, false + } + if err := validate.Struct(&dst); err != nil { + writeBindFailure(c, err) + return nil, false + } + return &dst, true +} + +func BindAndValidateInto(c *gin.Context, dst any) bool { + if err := c.ShouldBind(dst); err != nil { + writeBindFailure(c, err) + return false + } + if err := validate.Struct(dst); err != nil { + writeBindFailure(c, err) + return false + } + return true +} + +func BindJSONAndValidate[T any](c *gin.Context) (*T, bool) { + var dst T + if err := c.ShouldBindWith(&dst, binding.JSON); err != nil { + writeBindFailure(c, err) + return nil, false + } + if err := validate.Struct(&dst); err != nil { + writeBindFailure(c, err) + return nil, false + } + return &dst, true +} + +func BindJSONAndValidateInto(c *gin.Context, dst any) bool { + if err := c.ShouldBindWith(dst, binding.JSON); err != nil { + writeBindFailure(c, err) + return false + } + if err := validate.Struct(dst); err != nil { + writeBindFailure(c, err) + return false + } + return true +} + +type FieldIssue struct { + Field string `json:"field"` + Rule string `json:"rule"` + Param string `json:"param,omitempty"` + Message string `json:"message"` +} + +type ValidationPayload struct { + Issues []FieldIssue `json:"issues"` + Message string `json:"message"` +} + +func writeBindFailure(c *gin.Context, err error) { + payload := ValidationPayload{Issues: []FieldIssue{}, Message: err.Error()} + + var ve validator.ValidationErrors + if errors.As(err, &ve) { + payload.Issues = make([]FieldIssue, 0, len(ve)) + for _, fe := range ve { + payload.Issues = append(payload.Issues, FieldIssue{ + Field: fe.Field(), + Rule: fe.Tag(), + Param: fe.Param(), + Message: fe.Error(), + }) + } + } + + c.AbortWithStatusJSON(http.StatusOK, entity.Msg{ + Success: false, + Msg: "request body failed validation", + Obj: payload, + }) +} + +func init() { + validate.RegisterTagNameFunc(func(fld reflect.StructField) string { + name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] + if name == "-" || name == "" { + return fld.Name + } + return name + }) +} diff --git a/web/middleware/validate_test.go b/web/middleware/validate_test.go new file mode 100644 index 00000000..ff69bc2c --- /dev/null +++ b/web/middleware/validate_test.go @@ -0,0 +1,207 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/mhsanaei/3x-ui/v3/web/entity" +) + +type sampleBody struct { + Port int `json:"port" form:"port" validate:"gte=1,lte=65535"` + Protocol string `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan"` + Tag string `json:"tag" form:"tag"` +} + +func newRouter(handler gin.HandlerFunc) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/submit", handler) + return r +} + +func decodeMsg(t *testing.T, body string) entity.Msg { + t.Helper() + var msg entity.Msg + if err := json.Unmarshal([]byte(body), &msg); err != nil { + t.Fatalf("decode msg: %v (body=%q)", err, body) + } + return msg +} + +func TestBindAndValidate_ValidPayloadPassesThrough(t *testing.T) { + r := newRouter(func(c *gin.Context) { + got, ok := BindAndValidate[sampleBody](c) + if !ok { + t.Fatalf("expected ok=true, got false (body should be valid)") + } + if got.Port != 443 || got.Protocol != "vless" || got.Tag != "inbound-443" { + t.Fatalf("decoded payload mismatch: %+v", got) + } + c.JSON(http.StatusOK, entity.Msg{Success: true, Msg: "ok"}) + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/submit", + strings.NewReader(`{"port":443,"protocol":"vless","tag":"inbound-443"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String()) + } + if msg := decodeMsg(t, rec.Body.String()); !msg.Success { + t.Fatalf("expected Success=true; got %+v", msg) + } +} + +func TestBindAndValidate_PortOutOfRangeIsRejected(t *testing.T) { + r := newRouter(func(c *gin.Context) { + if _, ok := BindAndValidate[sampleBody](c); ok { + t.Fatal("expected ok=false on invalid port; got true") + } + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/submit", + strings.NewReader(`{"port":70000,"protocol":"vless"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(rec, req) + + msg := decodeMsg(t, rec.Body.String()) + if msg.Success { + t.Fatalf("expected Success=false; got %+v", msg) + } + payload, err := payloadFromObj(msg.Obj) + if err != nil { + t.Fatalf("payload extraction: %v", err) + } + found := false + for _, issue := range payload.Issues { + if issue.Field == "port" && issue.Rule == "lte" { + found = true + break + } + } + if !found { + t.Fatalf("expected an Issue for field=port rule=lte; got %+v", payload.Issues) + } +} + +func TestBindAndValidate_ProtocolEnumIsRejected(t *testing.T) { + r := newRouter(func(c *gin.Context) { + if _, ok := BindAndValidate[sampleBody](c); ok { + t.Fatal("expected ok=false on invalid protocol; got true") + } + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/submit", + strings.NewReader(`{"port":443,"protocol":"unknown"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(rec, req) + + msg := decodeMsg(t, rec.Body.String()) + payload, err := payloadFromObj(msg.Obj) + if err != nil { + t.Fatalf("payload extraction: %v", err) + } + found := false + for _, issue := range payload.Issues { + if issue.Field == "protocol" && issue.Rule == "oneof" { + found = true + } + } + if !found { + t.Fatalf("expected an Issue for field=protocol rule=oneof; got %+v", payload.Issues) + } +} + +func TestBindAndValidate_MalformedJSONReturnsMessageButNoIssues(t *testing.T) { + r := newRouter(func(c *gin.Context) { + if _, ok := BindAndValidate[sampleBody](c); ok { + t.Fatal("expected ok=false on malformed JSON; got true") + } + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/submit", + strings.NewReader(`{"port":}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(rec, req) + + msg := decodeMsg(t, rec.Body.String()) + if msg.Success { + t.Fatal("expected Success=false on malformed JSON") + } + payload, err := payloadFromObj(msg.Obj) + if err != nil { + t.Fatalf("payload extraction: %v", err) + } + if len(payload.Issues) != 0 { + t.Fatalf("expected empty Issues for parse error; got %+v", payload.Issues) + } + if payload.Message == "" { + t.Fatal("expected non-empty Message describing the parse error") + } +} + +func TestBindAndValidateInto_PreservesPrePopulatedFields(t *testing.T) { + r := newRouter(func(c *gin.Context) { + dst := &sampleBody{Tag: "preset"} + if !BindAndValidateInto(c, dst) { + t.Fatal("expected ok=true; got false") + } + if dst.Tag != "inbound-443" { + t.Fatalf("expected payload Tag to overwrite preset; got %q", dst.Tag) + } + if dst.Port != 443 { + t.Fatalf("expected Port=443; got %d", dst.Port) + } + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/submit", + strings.NewReader(`{"port":443,"protocol":"trojan","tag":"inbound-443"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestBindJSONAndValidate_RejectsFormEncodedBody(t *testing.T) { + r := newRouter(func(c *gin.Context) { + if _, ok := BindJSONAndValidate[sampleBody](c); ok { + t.Fatal("expected ok=false for form-encoded request to a JSON-only endpoint") + } + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/submit", + strings.NewReader("port=443&protocol=vless")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.ServeHTTP(rec, req) + + if msg := decodeMsg(t, rec.Body.String()); msg.Success { + t.Fatalf("expected Success=false; got %+v", msg) + } +} + +func payloadFromObj(obj any) (ValidationPayload, error) { + raw, err := json.Marshal(obj) + if err != nil { + return ValidationPayload{}, err + } + var payload ValidationPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return ValidationPayload{}, err + } + return payload, nil +}