feat(backend): gate request bodies with go-playground/validator

Add a generic BindAndValidate helper in web/middleware that wraps gin's
content-aware binder with an explicit validator.Struct call and emits a
structured `entity.Msg{Obj: ValidationPayload{Issues...}}` on failure so
the frontend can map each issue to an i18n key.

Tag the user-facing fields on model.Inbound, model.Node, and
entity.AllSetting with the range/enum constraints they were previously
relying on hand-rolled CheckValid logic (or nothing) to enforce, and
wire the helper into the inbound/node/settings controllers that bind
those structs directly. Promotes validator/v10 from indirect to direct
require, plus six unit tests covering valid payloads, range violations,
enum violations, malformed JSON, in-place binding, and JSON-only strict
mode.

This is PR1 of a planned end-to-end Zod rollout — controllers using
local form structs (custom_geo, setEnable, fallbacks, client) keep
their existing handling and will be migrated as their schemas firm up.
This commit is contained in:
MHSanaei
2026-05-25 19:17:54 +02:00
parent 9cf35234a5
commit 7fda988fb2
8 changed files with 362 additions and 49 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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