mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-26 07:08:01 +00:00
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:
@@ -53,14 +53,14 @@ type Inbound struct {
|
|||||||
Remark string `json:"remark" form:"remark"` // Human-readable remark
|
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
|
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
|
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
|
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
|
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
|
||||||
|
|
||||||
// Xray configuration fields
|
// Xray configuration fields
|
||||||
Listen string `json:"listen" form:"listen"`
|
Listen string `json:"listen" form:"listen"`
|
||||||
Port int `json:"port" form:"port"`
|
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"`
|
||||||
Protocol Protocol `json:"protocol" form:"protocol"`
|
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"`
|
Settings string `json:"settings" form:"settings"`
|
||||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||||
@@ -247,13 +247,13 @@ type Setting struct {
|
|||||||
// status fields below.
|
// status fields below.
|
||||||
type Node struct {
|
type Node struct {
|
||||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
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"`
|
Remark string `json:"remark" form:"remark"`
|
||||||
Scheme string `json:"scheme" form:"scheme"`
|
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
|
||||||
Address string `json:"address" form:"address"`
|
Address string `json:"address" form:"address" validate:"required"`
|
||||||
Port int `json:"port" form:"port"`
|
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"`
|
||||||
BasePath string `json:"basePath" form:"basePath"`
|
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"`
|
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
||||||
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
||||||
|
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/gin-contrib/sessions v1.1.0
|
github.com/gin-contrib/sessions v1.1.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/go-ldap/ldap/v3 v3.4.13
|
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-json v0.10.6
|
||||||
github.com/goccy/go-yaml v1.19.2
|
github.com/goccy/go-yaml v1.19.2
|
||||||
github.com/google/uuid v1.6.0
|
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-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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/google/btree v1.1.3 // indirect
|
||||||
github.com/gorilla/context v1.1.2 // indirect
|
github.com/gorilla/context v1.1.2 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
"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/service"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/session"
|
"github.com/mhsanaei/3x-ui/v3/web/session"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/websocket"
|
"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.
|
// addInbound creates a new inbound configuration.
|
||||||
func (a *InboundController) addInbound(c *gin.Context) {
|
func (a *InboundController) addInbound(c *gin.Context) {
|
||||||
inbound := &model.Inbound{}
|
inbound, ok := middleware.BindAndValidate[model.Inbound](c)
|
||||||
err := c.ShouldBind(inbound)
|
if !ok {
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user := session.GetLoginUser(c)
|
user := session.GetLoginUser(c)
|
||||||
@@ -200,9 +199,7 @@ func (a *InboundController) updateInbound(c *gin.Context) {
|
|||||||
inbound := &model.Inbound{
|
inbound := &model.Inbound{
|
||||||
Id: id,
|
Id: id,
|
||||||
}
|
}
|
||||||
err = c.ShouldBind(inbound)
|
if !middleware.BindAndValidateInto(c, inbound) {
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Same NodeID=0 → nil normalisation as addInbound. UpdateInbound
|
// Same NodeID=0 → nil normalisation as addInbound. UpdateInbound
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
"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/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -61,9 +62,8 @@ func (a *NodeController) get(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *NodeController) add(c *gin.Context) {
|
func (a *NodeController) add(c *gin.Context) {
|
||||||
n := &model.Node{}
|
n, ok := middleware.BindAndValidate[model.Node](c)
|
||||||
if err := c.ShouldBind(n); err != nil {
|
if !ok {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.nodeService.Create(n); err != nil {
|
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)
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
n := &model.Node{}
|
n, ok := middleware.BindAndValidate[model.Node](c)
|
||||||
if err := c.ShouldBind(n); err != nil {
|
if !ok {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.nodeService.Update(id, n); err != nil {
|
if err := a.nodeService.Update(id, n); err != nil {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/entity"
|
"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/service"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/session"
|
"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.
|
// updateSetting updates all settings with the provided data.
|
||||||
func (a *SettingController) updateSetting(c *gin.Context) {
|
func (a *SettingController) updateSetting(c *gin.Context) {
|
||||||
allSetting := &entity.AllSetting{}
|
allSetting, ok := middleware.BindAndValidate[entity.AllSetting](c)
|
||||||
err := c.ShouldBind(allSetting)
|
if !ok {
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
|
oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
|
||||||
err = a.settingService.UpdateAllSetting(allSetting)
|
err := a.settingService.UpdateAllSetting(allSetting)
|
||||||
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
|
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
|
||||||
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
|
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
|
||||||
err = bumpErr
|
err = bumpErr
|
||||||
|
|||||||
@@ -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.
|
// AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.
|
||||||
type AllSetting struct {
|
type AllSetting struct {
|
||||||
// Web server settings
|
// Web server settings
|
||||||
WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
|
WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
|
||||||
WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
|
WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
|
||||||
WebPort int `json:"webPort" form:"webPort"` // Web server port number
|
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
|
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
|
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
|
WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs
|
||||||
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes
|
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
|
TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"` // Trusted reverse proxy IPs/CIDRs for forwarded headers
|
||||||
|
|
||||||
// UI settings
|
// UI settings
|
||||||
PageSize int `json:"pageSize" form:"pageSize"` // Number of items per page in lists
|
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=1,lte=1000"` // Number of items per page in lists
|
||||||
ExpireDiff int `json:"expireDiff" form:"expireDiff"` // Expiration warning threshold in days
|
ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"` // Expiration warning threshold in days
|
||||||
TrafficDiff int `json:"trafficDiff" form:"trafficDiff"` // Traffic warning threshold percentage
|
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
|
RemarkModel string `json:"remarkModel" form:"remarkModel"` // Remark model pattern for inbounds
|
||||||
Datepicker string `json:"datepicker" form:"datepicker"` // Date picker format
|
Datepicker string `json:"datepicker" form:"datepicker"` // Date picker format
|
||||||
|
|
||||||
// Telegram bot settings
|
// Telegram bot settings
|
||||||
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"` // Enable Telegram bot notifications
|
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
|
TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"` // Telegram chat ID for notifications
|
||||||
TgRunTime string `json:"tgRunTime" form:"tgRunTime"` // Cron schedule for Telegram notifications
|
TgRunTime string `json:"tgRunTime" form:"tgRunTime"` // Cron schedule for Telegram notifications
|
||||||
TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"` // Enable database backup via Telegram
|
TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"` // Enable database backup via Telegram
|
||||||
TgBotLoginNotify bool `json:"tgBotLoginNotify" form:"tgBotLoginNotify"` // Send login notifications
|
TgBotLoginNotify bool `json:"tgBotLoginNotify" form:"tgBotLoginNotify"` // Send login notifications
|
||||||
TgCpu int `json:"tgCpu" form:"tgCpu"` // CPU usage threshold for alerts
|
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
|
TgLang string `json:"tgLang" form:"tgLang"` // Telegram bot language
|
||||||
|
|
||||||
// Security settings
|
// Security settings
|
||||||
TimeLocation string `json:"timeLocation" form:"timeLocation"` // Time zone location
|
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
|
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` // Enable routing for subscription
|
||||||
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"` // Subscription global routing rules (Only for Happ)
|
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"` // Subscription global routing rules (Only for Happ)
|
||||||
SubListen string `json:"subListen" form:"subListen"` // Subscription server listen IP
|
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
|
SubPath string `json:"subPath" form:"subPath"` // Base path for subscription URLs
|
||||||
SubDomain string `json:"subDomain" form:"subDomain"` // Domain for subscription server validation
|
SubDomain string `json:"subDomain" form:"subDomain"` // Domain for subscription server validation
|
||||||
SubCertFile string `json:"subCertFile" form:"subCertFile"` // SSL certificate file for subscription server
|
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
|
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
|
ExternalTrafficInformEnable bool `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"` // Enable external traffic reporting
|
||||||
ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"` // URI for 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
|
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
|
// LDAP settings
|
||||||
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
|
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
|
||||||
LdapHost string `json:"ldapHost" form:"ldapHost"`
|
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"`
|
LdapUseTLS bool `json:"ldapUseTLS" form:"ldapUseTLS"`
|
||||||
LdapBindDN string `json:"ldapBindDN" form:"ldapBindDN"`
|
LdapBindDN string `json:"ldapBindDN" form:"ldapBindDN"`
|
||||||
LdapPassword string `json:"ldapPassword" form:"ldapPassword"`
|
LdapPassword string `json:"ldapPassword" form:"ldapPassword"`
|
||||||
@@ -106,9 +106,9 @@ type AllSetting struct {
|
|||||||
LdapInboundTags string `json:"ldapInboundTags" form:"ldapInboundTags"`
|
LdapInboundTags string `json:"ldapInboundTags" form:"ldapInboundTags"`
|
||||||
LdapAutoCreate bool `json:"ldapAutoCreate" form:"ldapAutoCreate"`
|
LdapAutoCreate bool `json:"ldapAutoCreate" form:"ldapAutoCreate"`
|
||||||
LdapAutoDelete bool `json:"ldapAutoDelete" form:"ldapAutoDelete"`
|
LdapAutoDelete bool `json:"ldapAutoDelete" form:"ldapAutoDelete"`
|
||||||
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"`
|
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB" validate:"gte=0"`
|
||||||
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"`
|
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
|
||||||
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"`
|
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
|
||||||
// JSON subscription routing rules
|
// JSON subscription routing rules
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
111
web/middleware/validate.go
Normal file
111
web/middleware/validate.go
Normal file
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
207
web/middleware/validate_test.go
Normal file
207
web/middleware/validate_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user