mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-02-28 05:02:59 +00:00
feat(settings): migrate to typed app_settings with legacy fallback
This commit is contained in:
parent
6b6d92e67e
commit
f3ac4bef4c
7 changed files with 582 additions and 158 deletions
66
database/app_settings_migration.go
Normal file
66
database/app_settings_migration.go
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// initAppSettings ensures the typed app_settings row exists and is seeded.
|
||||||
|
// If missing, it migrates values from legacy key/value settings where available.
|
||||||
|
func initAppSettings() error {
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&model.AppSettings{}).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := model.NewDefaultAppSettings("")
|
||||||
|
var legacy []model.Setting
|
||||||
|
if err := db.Model(&model.Setting{}).Find(&legacy).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, setting := range legacy {
|
||||||
|
if _, err := cfg.SetByKey(setting.Key, setting.Value); err != nil {
|
||||||
|
log.Printf("Warning: failed to migrate legacy setting key=%s to app_settings: %v", setting.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Create(cfg).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAppSettings returns the authoritative typed settings row.
|
||||||
|
func GetAppSettings() (*model.AppSettings, error) {
|
||||||
|
cfg := &model.AppSettings{}
|
||||||
|
err := db.Model(&model.AppSettings{}).First(cfg).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreateAppSettings returns the typed settings row, creating one when missing.
|
||||||
|
func GetOrCreateAppSettings(defaultXrayTemplate string) (*model.AppSettings, error) {
|
||||||
|
cfg, err := GetAppSettings()
|
||||||
|
if err == nil {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg = model.NewDefaultAppSettings(defaultXrayTemplate)
|
||||||
|
if createErr := db.Create(cfg).Error; createErr != nil {
|
||||||
|
return nil, createErr
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveAppSettings persists the typed settings row.
|
||||||
|
func SaveAppSettings(cfg *model.AppSettings) error {
|
||||||
|
return db.Save(cfg).Error
|
||||||
|
}
|
||||||
62
database/app_settings_migration_test.go
Normal file
62
database/app_settings_migration_test.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedLegacySettings(t *testing.T, dbPath string, rows []model.Setting) {
|
||||||
|
t.Helper()
|
||||||
|
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open legacy db: %v", err)
|
||||||
|
}
|
||||||
|
if err := gdb.AutoMigrate(&model.Setting{}); err != nil {
|
||||||
|
t.Fatalf("migrate legacy setting table: %v", err)
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := gdb.Create(&row).Error; err != nil {
|
||||||
|
t.Fatalf("insert legacy row %s: %v", row.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitDBMigratesLegacySettingsToAppSettings(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "migrate.db")
|
||||||
|
seedLegacySettings(t, dbPath, []model.Setting{
|
||||||
|
{Key: "webPort", Value: "8899"},
|
||||||
|
{Key: "subPath", Value: "/legacy-sub/"},
|
||||||
|
{Key: "tgBotEnable", Value: "true"},
|
||||||
|
{Key: "xrayTemplateConfig", Value: `{"log":{}}`},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := InitDB(dbPath); err != nil {
|
||||||
|
t.Fatalf("InitDB failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := GetAppSettings()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAppSettings failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.WebPort != 8899 {
|
||||||
|
t.Fatalf("expected WebPort=8899, got %d", cfg.WebPort)
|
||||||
|
}
|
||||||
|
if cfg.SubPath != "/legacy-sub/" {
|
||||||
|
t.Fatalf("expected SubPath migrated, got %q", cfg.SubPath)
|
||||||
|
}
|
||||||
|
if !cfg.TgBotEnable {
|
||||||
|
t.Fatalf("expected TgBotEnable=true from legacy row")
|
||||||
|
}
|
||||||
|
if cfg.XrayTemplateConfig != `{"log":{}}` {
|
||||||
|
t.Fatalf("expected xray template config migrated, got %q", cfg.XrayTemplateConfig)
|
||||||
|
}
|
||||||
|
if cfg.SessionMaxAge == 0 {
|
||||||
|
t.Fatalf("expected default SessionMaxAge to be seeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,7 @@ func initModels() error {
|
||||||
&model.Inbound{},
|
&model.Inbound{},
|
||||||
&model.OutboundTraffics{},
|
&model.OutboundTraffics{},
|
||||||
&model.Setting{},
|
&model.Setting{},
|
||||||
|
&model.AppSettings{},
|
||||||
&model.InboundClientIps{},
|
&model.InboundClientIps{},
|
||||||
&model.MasterClient{},
|
&model.MasterClient{},
|
||||||
&model.MasterClientInbound{},
|
&model.MasterClientInbound{},
|
||||||
|
|
@ -148,6 +149,9 @@ func InitDB(dbPath string) error {
|
||||||
if err := initModels(); err != nil {
|
if err := initModels(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := initAppSettings(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
isUsersEmpty, err := isTableEmpty("users")
|
isUsersEmpty, err := isTableEmpty("users")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
266
database/model/app_settings.go
Normal file
266
database/model/app_settings.go
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/util/random"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppSettings is the authoritative typed settings row.
|
||||||
|
// It mirrors the historical key/value settings keys via `setting` tags.
|
||||||
|
type AppSettings struct {
|
||||||
|
ID int `gorm:"primaryKey;autoIncrement"`
|
||||||
|
CreatedAt int64 `gorm:"autoCreateTime:milli"`
|
||||||
|
UpdatedAt int64 `gorm:"autoUpdateTime:milli"`
|
||||||
|
|
||||||
|
XrayTemplateConfig string `gorm:"type:text" setting:"xrayTemplateConfig"`
|
||||||
|
WebListen string `setting:"webListen"`
|
||||||
|
WebDomain string `setting:"webDomain"`
|
||||||
|
WebPort int `setting:"webPort"`
|
||||||
|
WebCertFile string `setting:"webCertFile"`
|
||||||
|
WebKeyFile string `setting:"webKeyFile"`
|
||||||
|
Secret string `setting:"secret"`
|
||||||
|
WebBasePath string `setting:"webBasePath"`
|
||||||
|
SessionMaxAge int `setting:"sessionMaxAge"`
|
||||||
|
PageSize int `setting:"pageSize"`
|
||||||
|
ExpireDiff int `setting:"expireDiff"`
|
||||||
|
TrafficDiff int `setting:"trafficDiff"`
|
||||||
|
RemarkModel string `setting:"remarkModel"`
|
||||||
|
TimeLocation string `setting:"timeLocation"`
|
||||||
|
|
||||||
|
TgBotEnable bool `setting:"tgBotEnable"`
|
||||||
|
TgBotToken string `setting:"tgBotToken"`
|
||||||
|
TgBotProxy string `setting:"tgBotProxy"`
|
||||||
|
TgBotAPIServer string `setting:"tgBotAPIServer"`
|
||||||
|
TgBotChatID string `setting:"tgBotChatId"`
|
||||||
|
TgRunTime string `setting:"tgRunTime"`
|
||||||
|
TgBotBackup bool `setting:"tgBotBackup"`
|
||||||
|
TgBotLoginNotify bool `setting:"tgBotLoginNotify"`
|
||||||
|
TgCPU int `setting:"tgCpu"`
|
||||||
|
TgLang string `setting:"tgLang"`
|
||||||
|
|
||||||
|
TwoFactorEnable bool `setting:"twoFactorEnable"`
|
||||||
|
TwoFactorToken string `setting:"twoFactorToken"`
|
||||||
|
|
||||||
|
SubEnable bool `setting:"subEnable"`
|
||||||
|
SubJSONEnable bool `setting:"subJsonEnable"`
|
||||||
|
SubTitle string `setting:"subTitle"`
|
||||||
|
SubSupportURL string `setting:"subSupportUrl"`
|
||||||
|
SubProfileURL string `setting:"subProfileUrl"`
|
||||||
|
SubAnnounce string `setting:"subAnnounce"`
|
||||||
|
SubEnableRouting bool `setting:"subEnableRouting"`
|
||||||
|
SubRoutingRules string `gorm:"type:text" setting:"subRoutingRules"`
|
||||||
|
SubListen string `setting:"subListen"`
|
||||||
|
SubPort int `setting:"subPort"`
|
||||||
|
SubPath string `setting:"subPath"`
|
||||||
|
SubDomain string `setting:"subDomain"`
|
||||||
|
SubCertFile string `setting:"subCertFile"`
|
||||||
|
SubKeyFile string `setting:"subKeyFile"`
|
||||||
|
SubUpdates string `setting:"subUpdates"`
|
||||||
|
SubEncrypt bool `setting:"subEncrypt"`
|
||||||
|
SubShowInfo bool `setting:"subShowInfo"`
|
||||||
|
SubURI string `setting:"subURI"`
|
||||||
|
SubJSONPath string `setting:"subJsonPath"`
|
||||||
|
SubJSONURI string `setting:"subJsonURI"`
|
||||||
|
SubJSONFragment string `setting:"subJsonFragment"`
|
||||||
|
SubJSONNoises string `setting:"subJsonNoises"`
|
||||||
|
SubJSONMux string `setting:"subJsonMux"`
|
||||||
|
SubJSONRules string `setting:"subJsonRules"`
|
||||||
|
Datepicker string `setting:"datepicker"`
|
||||||
|
|
||||||
|
Warp string `setting:"warp"`
|
||||||
|
ExternalTrafficInformEnable bool `setting:"externalTrafficInformEnable"`
|
||||||
|
ExternalTrafficInformURI string `setting:"externalTrafficInformURI"`
|
||||||
|
XrayOutboundTestURL string `setting:"xrayOutboundTestUrl"`
|
||||||
|
|
||||||
|
LdapEnable bool `setting:"ldapEnable"`
|
||||||
|
LdapHost string `setting:"ldapHost"`
|
||||||
|
LdapPort int `setting:"ldapPort"`
|
||||||
|
LdapUseTLS bool `setting:"ldapUseTLS"`
|
||||||
|
LdapBindDN string `setting:"ldapBindDN"`
|
||||||
|
LdapPassword string `setting:"ldapPassword"`
|
||||||
|
LdapBaseDN string `setting:"ldapBaseDN"`
|
||||||
|
LdapUserFilter string `setting:"ldapUserFilter"`
|
||||||
|
LdapUserAttr string `setting:"ldapUserAttr"`
|
||||||
|
LdapVlessField string `setting:"ldapVlessField"`
|
||||||
|
LdapSyncCron string `setting:"ldapSyncCron"`
|
||||||
|
LdapFlagField string `setting:"ldapFlagField"`
|
||||||
|
LdapTruthyValues string `setting:"ldapTruthyValues"`
|
||||||
|
LdapInvertFlag bool `setting:"ldapInvertFlag"`
|
||||||
|
LdapInboundTags string `setting:"ldapInboundTags"`
|
||||||
|
LdapAutoCreate bool `setting:"ldapAutoCreate"`
|
||||||
|
LdapAutoDelete bool `setting:"ldapAutoDelete"`
|
||||||
|
LdapDefaultTotalGB int `setting:"ldapDefaultTotalGB"`
|
||||||
|
LdapDefaultExpiryDays int `setting:"ldapDefaultExpiryDays"`
|
||||||
|
LdapDefaultLimitIP int `setting:"ldapDefaultLimitIP"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AppSettings) TableName() string {
|
||||||
|
return "app_settings"
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
appSettingsFieldMapOnce sync.Once
|
||||||
|
appSettingsFieldMap map[string]int
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildAppSettingsFieldMap() {
|
||||||
|
appSettingsFieldMap = make(map[string]int)
|
||||||
|
t := reflect.TypeOf(AppSettings{})
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
f := t.Field(i)
|
||||||
|
key := f.Tag.Get("setting")
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appSettingsFieldMap[key] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAppSettingsFieldMap() map[string]int {
|
||||||
|
appSettingsFieldMapOnce.Do(buildAppSettingsFieldMap)
|
||||||
|
return appSettingsFieldMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSettingValues returns canonical defaults for settings keys.
|
||||||
|
func DefaultSettingValues(xrayTemplateConfig string) map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"xrayTemplateConfig": xrayTemplateConfig,
|
||||||
|
"webListen": "",
|
||||||
|
"webDomain": "",
|
||||||
|
"webPort": "2053",
|
||||||
|
"webCertFile": "",
|
||||||
|
"webKeyFile": "",
|
||||||
|
"secret": random.Seq(32),
|
||||||
|
"webBasePath": "/",
|
||||||
|
"sessionMaxAge": "360",
|
||||||
|
"pageSize": "25",
|
||||||
|
"expireDiff": "0",
|
||||||
|
"trafficDiff": "0",
|
||||||
|
"remarkModel": "-ieo",
|
||||||
|
"timeLocation": "Local",
|
||||||
|
"tgBotEnable": "false",
|
||||||
|
"tgBotToken": "",
|
||||||
|
"tgBotProxy": "",
|
||||||
|
"tgBotAPIServer": "",
|
||||||
|
"tgBotChatId": "",
|
||||||
|
"tgRunTime": "@daily",
|
||||||
|
"tgBotBackup": "false",
|
||||||
|
"tgBotLoginNotify": "true",
|
||||||
|
"tgCpu": "80",
|
||||||
|
"tgLang": "en-US",
|
||||||
|
"twoFactorEnable": "false",
|
||||||
|
"twoFactorToken": "",
|
||||||
|
"subEnable": "true",
|
||||||
|
"subJsonEnable": "false",
|
||||||
|
"subTitle": "",
|
||||||
|
"subSupportUrl": "",
|
||||||
|
"subProfileUrl": "",
|
||||||
|
"subAnnounce": "",
|
||||||
|
"subEnableRouting": "true",
|
||||||
|
"subRoutingRules": "",
|
||||||
|
"subListen": "",
|
||||||
|
"subPort": "2096",
|
||||||
|
"subPath": "/sub/",
|
||||||
|
"subDomain": "",
|
||||||
|
"subCertFile": "",
|
||||||
|
"subKeyFile": "",
|
||||||
|
"subUpdates": "12",
|
||||||
|
"subEncrypt": "true",
|
||||||
|
"subShowInfo": "true",
|
||||||
|
"subURI": "",
|
||||||
|
"subJsonPath": "/json/",
|
||||||
|
"subJsonURI": "",
|
||||||
|
"subJsonFragment": "",
|
||||||
|
"subJsonNoises": "",
|
||||||
|
"subJsonMux": "",
|
||||||
|
"subJsonRules": "",
|
||||||
|
"datepicker": "gregorian",
|
||||||
|
"warp": "",
|
||||||
|
"externalTrafficInformEnable": "false",
|
||||||
|
"externalTrafficInformURI": "",
|
||||||
|
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
||||||
|
"ldapEnable": "false",
|
||||||
|
"ldapHost": "",
|
||||||
|
"ldapPort": "389",
|
||||||
|
"ldapUseTLS": "false",
|
||||||
|
"ldapBindDN": "",
|
||||||
|
"ldapPassword": "",
|
||||||
|
"ldapBaseDN": "",
|
||||||
|
"ldapUserFilter": "(objectClass=person)",
|
||||||
|
"ldapUserAttr": "mail",
|
||||||
|
"ldapVlessField": "vless_enabled",
|
||||||
|
"ldapSyncCron": "@every 1m",
|
||||||
|
"ldapFlagField": "",
|
||||||
|
"ldapTruthyValues": "true,1,yes,on",
|
||||||
|
"ldapInvertFlag": "false",
|
||||||
|
"ldapInboundTags": "",
|
||||||
|
"ldapAutoCreate": "false",
|
||||||
|
"ldapAutoDelete": "false",
|
||||||
|
"ldapDefaultTotalGB": "0",
|
||||||
|
"ldapDefaultExpiryDays": "0",
|
||||||
|
"ldapDefaultLimitIP": "0",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDefaultAppSettings creates a settings row initialized with default values.
|
||||||
|
func NewDefaultAppSettings(xrayTemplateConfig string) *AppSettings {
|
||||||
|
cfg := &AppSettings{}
|
||||||
|
defaults := DefaultSettingValues(xrayTemplateConfig)
|
||||||
|
for key, value := range defaults {
|
||||||
|
_, _ = cfg.SetByKey(key, value)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByKey returns a string representation of a settings key value.
|
||||||
|
func (s *AppSettings) GetByKey(key string) (string, bool, error) {
|
||||||
|
idx, ok := getAppSettingsFieldMap()[key]
|
||||||
|
if !ok {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
v := reflect.ValueOf(s).Elem().Field(idx)
|
||||||
|
switch v.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
return v.String(), true, nil
|
||||||
|
case reflect.Bool:
|
||||||
|
return strconv.FormatBool(v.Bool()), true, nil
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return strconv.FormatInt(v.Int(), 10), true, nil
|
||||||
|
default:
|
||||||
|
return "", true, fmt.Errorf("unsupported settings field kind for key %s: %s", key, v.Kind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetByKey sets a settings value by historical key name.
|
||||||
|
func (s *AppSettings) SetByKey(key string, value string) (bool, error) {
|
||||||
|
idx, ok := getAppSettingsFieldMap()[key]
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
v := reflect.ValueOf(s).Elem().Field(idx)
|
||||||
|
switch v.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
v.SetString(value)
|
||||||
|
return true, nil
|
||||||
|
case reflect.Bool:
|
||||||
|
parsed, err := strconv.ParseBool(value)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
v.SetBool(parsed)
|
||||||
|
return true, nil
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
v.SetInt(parsed)
|
||||||
|
return true, nil
|
||||||
|
default:
|
||||||
|
return true, fmt.Errorf("unsupported settings field kind for key %s: %s", key, v.Kind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,6 @@ package service
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
@ -15,7 +14,6 @@ import (
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/common"
|
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/random"
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/reflect_util"
|
"github.com/mhsanaei/3x-ui/v2/util/reflect_util"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/entity"
|
"github.com/mhsanaei/3x-ui/v2/web/entity"
|
||||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||||
|
|
@ -24,85 +22,7 @@ import (
|
||||||
//go:embed config.json
|
//go:embed config.json
|
||||||
var xrayTemplateConfig string
|
var xrayTemplateConfig string
|
||||||
|
|
||||||
var defaultValueMap = map[string]string{
|
var defaultValueMap = model.DefaultSettingValues(xrayTemplateConfig)
|
||||||
"xrayTemplateConfig": xrayTemplateConfig,
|
|
||||||
"webListen": "",
|
|
||||||
"webDomain": "",
|
|
||||||
"webPort": "2053",
|
|
||||||
"webCertFile": "",
|
|
||||||
"webKeyFile": "",
|
|
||||||
"secret": random.Seq(32),
|
|
||||||
"webBasePath": "/",
|
|
||||||
"sessionMaxAge": "360",
|
|
||||||
"pageSize": "25",
|
|
||||||
"expireDiff": "0",
|
|
||||||
"trafficDiff": "0",
|
|
||||||
"remarkModel": "-ieo",
|
|
||||||
"timeLocation": "Local",
|
|
||||||
"tgBotEnable": "false",
|
|
||||||
"tgBotToken": "",
|
|
||||||
"tgBotProxy": "",
|
|
||||||
"tgBotAPIServer": "",
|
|
||||||
"tgBotChatId": "",
|
|
||||||
"tgRunTime": "@daily",
|
|
||||||
"tgBotBackup": "false",
|
|
||||||
"tgBotLoginNotify": "true",
|
|
||||||
"tgCpu": "80",
|
|
||||||
"tgLang": "en-US",
|
|
||||||
"twoFactorEnable": "false",
|
|
||||||
"twoFactorToken": "",
|
|
||||||
"subEnable": "true",
|
|
||||||
"subJsonEnable": "false",
|
|
||||||
"subTitle": "",
|
|
||||||
"subSupportUrl": "",
|
|
||||||
"subProfileUrl": "",
|
|
||||||
"subAnnounce": "",
|
|
||||||
"subEnableRouting": "true",
|
|
||||||
"subRoutingRules": "",
|
|
||||||
"subListen": "",
|
|
||||||
"subPort": "2096",
|
|
||||||
"subPath": "/sub/",
|
|
||||||
"subDomain": "",
|
|
||||||
"subCertFile": "",
|
|
||||||
"subKeyFile": "",
|
|
||||||
"subUpdates": "12",
|
|
||||||
"subEncrypt": "true",
|
|
||||||
"subShowInfo": "true",
|
|
||||||
"subURI": "",
|
|
||||||
"subJsonPath": "/json/",
|
|
||||||
"subJsonURI": "",
|
|
||||||
"subJsonFragment": "",
|
|
||||||
"subJsonNoises": "",
|
|
||||||
"subJsonMux": "",
|
|
||||||
"subJsonRules": "",
|
|
||||||
"datepicker": "gregorian",
|
|
||||||
"warp": "",
|
|
||||||
"externalTrafficInformEnable": "false",
|
|
||||||
"externalTrafficInformURI": "",
|
|
||||||
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
|
||||||
|
|
||||||
// LDAP defaults
|
|
||||||
"ldapEnable": "false",
|
|
||||||
"ldapHost": "",
|
|
||||||
"ldapPort": "389",
|
|
||||||
"ldapUseTLS": "false",
|
|
||||||
"ldapBindDN": "",
|
|
||||||
"ldapPassword": "",
|
|
||||||
"ldapBaseDN": "",
|
|
||||||
"ldapUserFilter": "(objectClass=person)",
|
|
||||||
"ldapUserAttr": "mail",
|
|
||||||
"ldapVlessField": "vless_enabled",
|
|
||||||
"ldapSyncCron": "@every 1m",
|
|
||||||
"ldapFlagField": "",
|
|
||||||
"ldapTruthyValues": "true,1,yes,on",
|
|
||||||
"ldapInvertFlag": "false",
|
|
||||||
"ldapInboundTags": "",
|
|
||||||
"ldapAutoCreate": "false",
|
|
||||||
"ldapAutoDelete": "false",
|
|
||||||
"ldapDefaultTotalGB": "0",
|
|
||||||
"ldapDefaultExpiryDays": "0",
|
|
||||||
"ldapDefaultLimitIP": "0",
|
|
||||||
}
|
|
||||||
|
|
||||||
// SettingService provides business logic for application settings management.
|
// SettingService provides business logic for application settings management.
|
||||||
// It handles configuration storage, retrieval, and validation for all system settings.
|
// It handles configuration storage, retrieval, and validation for all system settings.
|
||||||
|
|
@ -118,75 +38,38 @@ func (s *SettingService) GetDefaultJsonConfig() (any, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
|
func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
|
||||||
db := database.GetDB()
|
|
||||||
settings := make([]*model.Setting, 0)
|
|
||||||
err := db.Model(model.Setting{}).Not("key = ?", "xrayTemplateConfig").Find(&settings).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
allSetting := &entity.AllSetting{}
|
allSetting := &entity.AllSetting{}
|
||||||
t := reflect.TypeOf(allSetting).Elem()
|
t := reflect.TypeOf(allSetting).Elem()
|
||||||
v := reflect.ValueOf(allSetting).Elem()
|
v := reflect.ValueOf(allSetting).Elem()
|
||||||
fields := reflect_util.GetFields(t)
|
fields := reflect_util.GetFields(t)
|
||||||
|
for _, field := range fields {
|
||||||
setSetting := func(key, value string) (err error) {
|
key := field.Tag.Get("json")
|
||||||
defer func() {
|
if key == "" {
|
||||||
panicErr := recover()
|
|
||||||
if panicErr != nil {
|
|
||||||
err = errors.New(fmt.Sprint(panicErr))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var found bool
|
|
||||||
var field reflect.StructField
|
|
||||||
for _, f := range fields {
|
|
||||||
if f.Tag.Get("json") == key {
|
|
||||||
field = f
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
// Some settings are automatically generated, no need to return to the front end to modify the user
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldV := v.FieldByName(field.Name)
|
|
||||||
switch t := fieldV.Interface().(type) {
|
|
||||||
case int:
|
|
||||||
n, err := strconv.ParseInt(value, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fieldV.SetInt(n)
|
|
||||||
case string:
|
|
||||||
fieldV.SetString(value)
|
|
||||||
case bool:
|
|
||||||
fieldV.SetBool(value == "true")
|
|
||||||
default:
|
|
||||||
return common.NewErrorf("unknown field %v type %v", key, t)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
keyMap := map[string]bool{}
|
|
||||||
for _, setting := range settings {
|
|
||||||
err := setSetting(setting.Key, setting.Value)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
keyMap[setting.Key] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, value := range defaultValueMap {
|
|
||||||
if keyMap[key] {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
err := setSetting(key, value)
|
value, err := s.getString(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
fieldV := v.FieldByName(field.Name)
|
||||||
|
switch fieldV.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
fieldV.SetString(value)
|
||||||
|
case reflect.Bool:
|
||||||
|
parsed, err := strconv.ParseBool(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fieldV.SetBool(parsed)
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fieldV.SetInt(parsed)
|
||||||
|
default:
|
||||||
|
return nil, common.NewErrorf("unknown field %v type %v", key, fieldV.Kind())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return allSetting, nil
|
return allSetting, nil
|
||||||
|
|
@ -194,15 +77,16 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
|
||||||
|
|
||||||
func (s *SettingService) ResetSettings() error {
|
func (s *SettingService) ResetSettings() error {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
err := db.Where("1 = 1").Delete(model.Setting{}).Error
|
if err := db.Where("1 = 1").Delete(model.Setting{}).Error; err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return db.Model(model.User{}).
|
if err := db.Where("1 = 1").Delete(model.AppSettings{}).Error; err != nil {
|
||||||
Where("1 = 1").Error
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) getSetting(key string) (*model.Setting, error) {
|
func (s *SettingService) getLegacySetting(key string) (*model.Setting, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
setting := &model.Setting{}
|
setting := &model.Setting{}
|
||||||
err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error
|
err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error
|
||||||
|
|
@ -212,24 +96,45 @@ func (s *SettingService) getSetting(key string) (*model.Setting, error) {
|
||||||
return setting, nil
|
return setting, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) saveSetting(key string, value string) error {
|
func (s *SettingService) saveLegacySetting(key string, value string) error {
|
||||||
setting, err := s.getSetting(key)
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
if database.IsNotFound(err) {
|
setting := &model.Setting{}
|
||||||
return db.Create(&model.Setting{
|
return db.Where("key = ?", key).Assign(model.Setting{
|
||||||
Key: key,
|
Key: key,
|
||||||
Value: value,
|
Value: value,
|
||||||
}).Error
|
}).FirstOrCreate(setting).Error
|
||||||
} else if err != nil {
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) saveSetting(key string, value string) error {
|
||||||
|
repo := settingsRepository{}
|
||||||
|
recognized, err := repo.set(key, value)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
setting.Key = key
|
// Keep shadow write for one release as compatibility fallback.
|
||||||
setting.Value = value
|
if legacyErr := s.saveLegacySetting(key, value); legacyErr != nil {
|
||||||
return db.Save(setting).Error
|
return legacyErr
|
||||||
|
}
|
||||||
|
if recognized {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) getString(key string) (string, error) {
|
func (s *SettingService) getString(key string) (string, error) {
|
||||||
setting, err := s.getSetting(key)
|
repo := settingsRepository{}
|
||||||
|
if value, recognized, err := repo.get(key); err == nil && recognized {
|
||||||
|
if key == "xrayTemplateConfig" && value == "" {
|
||||||
|
defaultTemplate := defaultValueMap[key]
|
||||||
|
_ = s.saveSetting(key, defaultTemplate)
|
||||||
|
return defaultTemplate, nil
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
setting, err := s.getLegacySetting(key)
|
||||||
if database.IsNotFound(err) {
|
if database.IsNotFound(err) {
|
||||||
value, ok := defaultValueMap[key]
|
value, ok := defaultValueMap[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
84
web/service/setting_repository_test.go
Normal file
84
web/service/setting_repository_test.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedLegacySettingsForServiceTest(t *testing.T, dbPath string, rows []model.Setting) {
|
||||||
|
t.Helper()
|
||||||
|
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := gdb.AutoMigrate(&model.Setting{}); err != nil {
|
||||||
|
t.Fatalf("migrate setting table: %v", err)
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := gdb.Create(&row).Error; err != nil {
|
||||||
|
t.Fatalf("insert legacy row %s: %v", row.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingServiceReadsMigratedTypedSettingsAndShadowWritesLegacy(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "setting-service.db")
|
||||||
|
seedLegacySettingsForServiceTest(t, dbPath, []model.Setting{{Key: "webPort", Value: "8111"}})
|
||||||
|
|
||||||
|
if err := database.InitDB(dbPath); err != nil {
|
||||||
|
t.Fatalf("InitDB failed: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = database.CloseDB() }()
|
||||||
|
|
||||||
|
svc := &SettingService{}
|
||||||
|
port, err := svc.GetPort()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPort failed: %v", err)
|
||||||
|
}
|
||||||
|
if port != 8111 {
|
||||||
|
t.Fatalf("expected migrated port 8111, got %d", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.SetPort(9001); err != nil {
|
||||||
|
t.Fatalf("SetPort failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := database.GetAppSettings()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAppSettings failed: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.WebPort != 9001 {
|
||||||
|
t.Fatalf("expected typed settings WebPort=9001, got %d", cfg.WebPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
var legacy model.Setting
|
||||||
|
if err := database.GetDB().Model(&model.Setting{}).Where("key = ?", "webPort").First(&legacy).Error; err != nil {
|
||||||
|
t.Fatalf("read legacy webPort row: %v", err)
|
||||||
|
}
|
||||||
|
if legacy.Value != "9001" {
|
||||||
|
t.Fatalf("expected legacy shadow write webPort=9001, got %s", legacy.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingServiceXrayTemplateFallback(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "setting-template.db")
|
||||||
|
if err := database.InitDB(dbPath); err != nil {
|
||||||
|
t.Fatalf("InitDB failed: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = database.CloseDB() }()
|
||||||
|
|
||||||
|
svc := &SettingService{}
|
||||||
|
template, err := svc.GetXrayConfigTemplate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetXrayConfigTemplate failed: %v", err)
|
||||||
|
}
|
||||||
|
if template == "" {
|
||||||
|
t.Fatalf("expected embedded xray template fallback, got empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
37
web/service/settings_repository.go
Normal file
37
web/service/settings_repository.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// settingsRepository encapsulates typed settings row access.
|
||||||
|
type settingsRepository struct{}
|
||||||
|
|
||||||
|
func (r *settingsRepository) getOrCreate() (*model.AppSettings, error) {
|
||||||
|
return database.GetOrCreateAppSettings(xrayTemplateConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *settingsRepository) get(key string) (value string, recognized bool, err error) {
|
||||||
|
cfg, err := r.getOrCreate()
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
value, recognized, err = cfg.GetByKey(key)
|
||||||
|
return value, recognized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *settingsRepository) set(key string, value string) (recognized bool, err error) {
|
||||||
|
cfg, err := r.getOrCreate()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
recognized, err = cfg.SetByKey(key, value)
|
||||||
|
if err != nil {
|
||||||
|
return recognized, err
|
||||||
|
}
|
||||||
|
if !recognized {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, database.SaveAppSettings(cfg)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue