From f3ac4bef4c75405631e767491db790b26b848871 Mon Sep 17 00:00:00 2001 From: Mohamadhosein Moazennia Date: Fri, 20 Feb 2026 11:19:48 +0330 Subject: [PATCH] feat(settings): migrate to typed app_settings with legacy fallback --- database/app_settings_migration.go | 66 ++++++ database/app_settings_migration_test.go | 62 ++++++ database/db.go | 4 + database/model/app_settings.go | 266 ++++++++++++++++++++++++ web/service/setting.go | 221 ++++++-------------- web/service/setting_repository_test.go | 84 ++++++++ web/service/settings_repository.go | 37 ++++ 7 files changed, 582 insertions(+), 158 deletions(-) create mode 100644 database/app_settings_migration.go create mode 100644 database/app_settings_migration_test.go create mode 100644 database/model/app_settings.go create mode 100644 web/service/setting_repository_test.go create mode 100644 web/service/settings_repository.go diff --git a/database/app_settings_migration.go b/database/app_settings_migration.go new file mode 100644 index 00000000..48b6de94 --- /dev/null +++ b/database/app_settings_migration.go @@ -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 +} diff --git a/database/app_settings_migration_test.go b/database/app_settings_migration_test.go new file mode 100644 index 00000000..f5b0cb70 --- /dev/null +++ b/database/app_settings_migration_test.go @@ -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") + } +} diff --git a/database/db.go b/database/db.go index 39d21db1..1badf9f2 100644 --- a/database/db.go +++ b/database/db.go @@ -35,6 +35,7 @@ func initModels() error { &model.Inbound{}, &model.OutboundTraffics{}, &model.Setting{}, + &model.AppSettings{}, &model.InboundClientIps{}, &model.MasterClient{}, &model.MasterClientInbound{}, @@ -148,6 +149,9 @@ func InitDB(dbPath string) error { if err := initModels(); err != nil { return err } + if err := initAppSettings(); err != nil { + return err + } isUsersEmpty, err := isTableEmpty("users") if err != nil { diff --git a/database/model/app_settings.go b/database/model/app_settings.go new file mode 100644 index 00000000..0e403409 --- /dev/null +++ b/database/model/app_settings.go @@ -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()) + } +} diff --git a/web/service/setting.go b/web/service/setting.go index 33012c39..5ee4b6ab 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -3,7 +3,6 @@ package service import ( _ "embed" "encoding/json" - "errors" "fmt" "net" "reflect" @@ -15,7 +14,6 @@ import ( "github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/logger" "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/web/entity" "github.com/mhsanaei/3x-ui/v2/xray" @@ -24,85 +22,7 @@ import ( //go:embed config.json var xrayTemplateConfig string -var defaultValueMap = 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", - - // 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", -} +var defaultValueMap = model.DefaultSettingValues(xrayTemplateConfig) // SettingService provides business logic for application settings management. // 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) { - 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{} t := reflect.TypeOf(allSetting).Elem() v := reflect.ValueOf(allSetting).Elem() fields := reflect_util.GetFields(t) - - setSetting := func(key, value string) (err error) { - defer func() { - 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] { + for _, field := range fields { + key := field.Tag.Get("json") + if key == "" { continue } - err := setSetting(key, value) + value, err := s.getString(key) if err != nil { 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 @@ -194,15 +77,16 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) { func (s *SettingService) ResetSettings() error { db := database.GetDB() - err := db.Where("1 = 1").Delete(model.Setting{}).Error - if err != nil { + if err := db.Where("1 = 1").Delete(model.Setting{}).Error; err != nil { return err } - return db.Model(model.User{}). - Where("1 = 1").Error + if err := db.Where("1 = 1").Delete(model.AppSettings{}).Error; err != nil { + 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() setting := &model.Setting{} 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 } -func (s *SettingService) saveSetting(key string, value string) error { - setting, err := s.getSetting(key) +func (s *SettingService) saveLegacySetting(key string, value string) error { db := database.GetDB() - if database.IsNotFound(err) { - return db.Create(&model.Setting{ - Key: key, - Value: value, - }).Error - } else if err != nil { + setting := &model.Setting{} + return db.Where("key = ?", key).Assign(model.Setting{ + Key: key, + Value: value, + }).FirstOrCreate(setting).Error +} + +func (s *SettingService) saveSetting(key string, value string) error { + repo := settingsRepository{} + recognized, err := repo.set(key, value) + if err != nil { return err } - setting.Key = key - setting.Value = value - return db.Save(setting).Error + // Keep shadow write for one release as compatibility fallback. + if legacyErr := s.saveLegacySetting(key, value); legacyErr != nil { + return legacyErr + } + if recognized { + return nil + } + return nil } 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) { value, ok := defaultValueMap[key] if !ok { diff --git a/web/service/setting_repository_test.go b/web/service/setting_repository_test.go new file mode 100644 index 00000000..de941427 --- /dev/null +++ b/web/service/setting_repository_test.go @@ -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") + } +} diff --git a/web/service/settings_repository.go b/web/service/settings_repository.go new file mode 100644 index 00000000..d69abc5a --- /dev/null +++ b/web/service/settings_repository.go @@ -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) +}