mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 23:27:14 +00:00
fix(xray): place the freedom domain strategy where the core reads it (#6515)
* fix(xray): place the freedom domain strategy where the core reads it freedom resolves through the socket layer, so xray-core reads sockopt.domainStrategy and treats both other placements as legacy: it warns on every config load for the outbound-root targetStrategy it migrates itself, and again for the settings-level domainStrategy it deprecates. The panel wrote exactly those two keys from its Freedom Protocol Strategy select, the outbound form card, and the IPv4 routing helper, so any install that had configured a strategy logged a deprecation warning on every start. The strategy now travels in streamSettings.sockopt everywhere the panel emits it: the Basics select, the outbound form (including the JSON tab, which shares the same adapter), the shipped default template, and the IPv4 outbound the routing helper injects. Reading mirrors the loader's own order — root targetStrategy, then the settings keys, then sockopt — so the card keeps showing the value the core would actually run with, and saving drops the legacy keys instead of leaving them behind. A seeder moves the keys for configs already stored in the database, following OutboundRemovedKeysFix. The shared outbound-root Target Strategy field is hidden for freedom, since the core migrates that key into the very sockopt value the card writes and two knobs for one value would race. Tests: placement round-trips and the migration table run through the real vendored core (a captured log handler proves the warning is gone after the rewrite and present before it), and the modal asserts freedom offers a single strategy field. * test(database): seed the template row the seeder test needs A fresh InitDB creates no xrayTemplateConfig row — the panel's setting defaults live in the service layer — so the test has to insert the legacy template itself and then assert the seeder's history gate stops a second pass from rewriting it. * fix(xray): keep one strategy control per outbound, seed the row in tests Review findings: the Transport tab's Sockopts block renders for freedom too, so its Domain Strategy select and the freedom card wrote one sockopt value between them and the card won on save — the field is hidden for freedom now, leaving the card as the single control. The seeder is also pre-marked on a fresh install so it does not run on the second start, and the seeder test seeds the template row itself (a fresh InitDB has none) and asserts the rewrite structurally instead of grepping for a key name that sockopt also uses.
This commit is contained in:
+120
-1
@@ -1254,7 +1254,7 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
}
|
||||
|
||||
if empty && isUsersEmpty {
|
||||
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
|
||||
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
|
||||
for _, name := range seeders {
|
||||
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
||||
return err
|
||||
@@ -1365,6 +1365,12 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "FreedomDomainStrategyFix") {
|
||||
if err := migrateFreedomDomainStrategy(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
|
||||
if err := seedNodeInboundsAdopted(); err != nil {
|
||||
return err
|
||||
@@ -1652,6 +1658,119 @@ func outboundSockopt(obj map[string]any, create bool) map[string]any {
|
||||
return sockopt
|
||||
}
|
||||
|
||||
func migrateFreedomDomainStrategy() error {
|
||||
var setting model.Setting
|
||||
err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomDomainStrategyFix"}).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated, changed, rErr := rewriteFreedomDomainStrategy(setting.Value)
|
||||
if rErr != nil {
|
||||
log.Printf("FreedomDomainStrategyFix: skip (invalid xrayTemplateConfig json): %v", rErr)
|
||||
return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomDomainStrategyFix"}).Error
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if changed {
|
||||
if err := tx.Model(&model.Setting{}).Where("key = ?", "xrayTemplateConfig").
|
||||
Update("value", updated).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.HistoryOfSeeders{SeederName: "FreedomDomainStrategyFix"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// rewriteFreedomDomainStrategy moves a freedom outbound's legacy strategy keys
|
||||
// into sockopt.domainStrategy, the placement the core's deprecation warning names.
|
||||
func rewriteFreedomDomainStrategy(raw string) (string, bool, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw, false, nil
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return raw, false, err
|
||||
}
|
||||
outbounds, ok := cfg["outbounds"].([]any)
|
||||
if !ok {
|
||||
return raw, false, nil
|
||||
}
|
||||
changed := false
|
||||
for _, ob := range outbounds {
|
||||
obj, ok := ob.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if proto, _ := obj["protocol"].(string); proto != "freedom" {
|
||||
continue
|
||||
}
|
||||
settings, hasSettings := obj["settings"].(map[string]any)
|
||||
_, hasRoot := obj["targetStrategy"]
|
||||
_, hasSettingsTarget := settings["targetStrategy"]
|
||||
_, hasSettingsDomain := settings["domainStrategy"]
|
||||
if !hasRoot && !hasSettingsTarget && !hasSettingsDomain {
|
||||
continue
|
||||
}
|
||||
strategy := freedomMigratedStrategy(obj, settings)
|
||||
delete(obj, "targetStrategy")
|
||||
if hasSettings {
|
||||
delete(settings, "targetStrategy")
|
||||
delete(settings, "domainStrategy")
|
||||
}
|
||||
if strategy != "" {
|
||||
outboundSockopt(obj, true)["domainStrategy"] = strategy
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return raw, false, nil
|
||||
}
|
||||
out, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return raw, false, err
|
||||
}
|
||||
return string(out), true, nil
|
||||
}
|
||||
|
||||
// freedomMigratedStrategy clones the core's own resolution order for a freedom
|
||||
// outbound (infra/conf/freedom.go), returning "" when none of them holds one.
|
||||
func freedomMigratedStrategy(obj, settings map[string]any) string {
|
||||
if s, ok := freedomStrategyValue(obj["targetStrategy"]); ok && !strings.EqualFold(s, "asis") {
|
||||
return s
|
||||
}
|
||||
legacy := settings["targetStrategy"]
|
||||
if s, ok := legacy.(string); !ok || s == "" {
|
||||
legacy = settings["domainStrategy"]
|
||||
}
|
||||
if s, ok := freedomStrategyValue(legacy); ok && !strings.EqualFold(s, "asis") {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// freedomStrategyValue reports a strategy the core accepts -- anything else is a
|
||||
// hard load error in freedom and sockopt alike, so it cannot be migrated.
|
||||
func freedomStrategyValue(value any) (string, bool) {
|
||||
s, ok := value.(string)
|
||||
if !ok || s == "" {
|
||||
return "", false
|
||||
}
|
||||
if !freedomDomainStrategies[strings.ToLower(s)] {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
var freedomDomainStrategies = map[string]bool{
|
||||
"asis": true, "useip": true, "useipv4": true, "useipv6": true,
|
||||
"useipv4v6": true, "useipv6v4": true, "forceip": true, "forceipv4": true,
|
||||
"forceipv6": true, "forceipv4v6": true, "forceipv6v4": true,
|
||||
}
|
||||
|
||||
func normalizeSettingPaths() error {
|
||||
pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
|
||||
var rows []model.Setting
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
corelog "github.com/xtls/xray-core/common/log"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func TestRewriteFreedomDomainStrategy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantChanged bool
|
||||
wantOutbound map[string]any
|
||||
}{
|
||||
{
|
||||
name: "the deprecated settings key moves to sockopt",
|
||||
raw: `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"UseIPv4","finalRules":[{"action":"allow"}]}}]}`,
|
||||
wantChanged: true,
|
||||
wantOutbound: map[string]any{
|
||||
"protocol": "freedom", "tag": "direct",
|
||||
"settings": map[string]any{"finalRules": []any{map[string]any{"action": "allow"}}},
|
||||
"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "UseIPv4"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "the outbound-root targetStrategy moves to sockopt and is dropped",
|
||||
raw: `{"outbounds":[{"protocol":"freedom","tag":"direct","targetStrategy":"ForceIPv6","settings":{}}]}`,
|
||||
wantChanged: true,
|
||||
wantOutbound: map[string]any{
|
||||
"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
|
||||
"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "ForceIPv6"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "the root key wins over the settings key, as in the core",
|
||||
raw: `{"outbounds":[{"protocol":"freedom","tag":"direct","targetStrategy":"UseIPv4","settings":{"domainStrategy":"UseIPv6"}}]}`,
|
||||
wantChanged: true,
|
||||
wantOutbound: map[string]any{
|
||||
"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
|
||||
"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "UseIPv4"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "the settings targetStrategy wins over domainStrategy",
|
||||
raw: `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"targetStrategy":"UseIPv6","domainStrategy":"UseIPv4"}}]}`,
|
||||
wantChanged: true,
|
||||
wantOutbound: map[string]any{
|
||||
"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
|
||||
"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "UseIPv6"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "an AsIs alias is dropped and leaves the sockopt value alone",
|
||||
raw: `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"AsIs"},"streamSettings":{"sockopt":{"domainStrategy":"UseIPv6","tcpFastOpen":true}}}]}`,
|
||||
wantChanged: true,
|
||||
wantOutbound: map[string]any{
|
||||
"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
|
||||
"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "UseIPv6", "tcpFastOpen": true}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "the existing sockopt spelling is preserved",
|
||||
raw: `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"useipv4v6"}}]}`,
|
||||
wantChanged: true,
|
||||
wantOutbound: map[string]any{
|
||||
"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
|
||||
"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "useipv4v6"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a strategy the core refuses is dropped rather than moved",
|
||||
raw: `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"UseIPv5"}}]}`,
|
||||
wantChanged: true,
|
||||
wantOutbound: map[string]any{
|
||||
"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "other protocols keep their outbound-root targetStrategy",
|
||||
raw: `{"outbounds":[{"protocol":"vless","tag":"proxy","targetStrategy":"UseIPv4","settings":{}}]}`,
|
||||
wantChanged: false,
|
||||
wantOutbound: map[string]any{
|
||||
"protocol": "vless", "tag": "proxy", "targetStrategy": "UseIPv4",
|
||||
"settings": map[string]any{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a freedom outbound without a strategy is left untouched",
|
||||
raw: `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"finalRules":[{"action":"allow"}]}}]}`,
|
||||
wantChanged: false,
|
||||
wantOutbound: map[string]any{
|
||||
"protocol": "freedom", "tag": "direct",
|
||||
"settings": map[string]any{"finalRules": []any{map[string]any{"action": "allow"}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
updated, changed, err := rewriteFreedomDomainStrategy(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if changed != tc.wantChanged {
|
||||
t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
|
||||
}
|
||||
var cfg struct {
|
||||
Outbounds []map[string]any `json:"outbounds"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(updated), &cfg); err != nil {
|
||||
t.Fatalf("rewritten template is not JSON: %v", err)
|
||||
}
|
||||
if len(cfg.Outbounds) != 1 {
|
||||
t.Fatalf("got %d outbounds, want 1", len(cfg.Outbounds))
|
||||
}
|
||||
got, _ := json.Marshal(cfg.Outbounds[0])
|
||||
want, _ := json.Marshal(tc.wantOutbound)
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("outbound = %s, want %s", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type coreLogCapture struct{ msgs []string }
|
||||
|
||||
func (c *coreLogCapture) Handle(msg corelog.Message) { c.msgs = append(c.msgs, msg.String()) }
|
||||
|
||||
func (c *coreLogCapture) has(sub string) bool {
|
||||
return strings.Contains(strings.Join(c.msgs, "\n"), sub)
|
||||
}
|
||||
|
||||
type discardLogHandler struct{}
|
||||
|
||||
func (discardLogHandler) Handle(corelog.Message) {}
|
||||
|
||||
// captureCoreLogs takes over the vendored core's log sink for the duration of
|
||||
// one test, which is the only way to observe a config-load warning.
|
||||
func captureCoreLogs(t *testing.T) *coreLogCapture {
|
||||
t.Helper()
|
||||
capture := new(coreLogCapture)
|
||||
corelog.RegisterHandler(capture)
|
||||
t.Cleanup(func() { corelog.RegisterHandler(discardLogHandler{}) })
|
||||
return capture
|
||||
}
|
||||
|
||||
// Drives the real core: a rewrite that dropped the value instead of moving it
|
||||
// would leave the config warning on every load and fail here.
|
||||
func TestRewriteFreedomDomainStrategySatisfiesCore(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
raw string
|
||||
wantValue string
|
||||
}{
|
||||
{
|
||||
name: "deprecated settings key",
|
||||
raw: `{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"UseIPv4","finalRules":[{"action":"allow"}]}}`,
|
||||
wantValue: `"domainStrategy": "UseIPv4"`,
|
||||
},
|
||||
{
|
||||
name: "outbound-root targetStrategy",
|
||||
raw: `{"protocol":"freedom","tag":"direct","targetStrategy":"ForceIPv6","settings":{}}`,
|
||||
wantValue: `"domainStrategy": "ForceIPv6"`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
capture := captureCoreLogs(t)
|
||||
|
||||
if err := xray.ValidateOutboundConfig([]byte(tc.raw)); err != nil {
|
||||
t.Fatalf("xray-core must accept the legacy outbound: %v", err)
|
||||
}
|
||||
if !capture.has("sockopt.domainStrategy") {
|
||||
t.Fatal("expected the core to warn about the legacy strategy placement")
|
||||
}
|
||||
|
||||
updated, changed, err := rewriteFreedomDomainStrategy(
|
||||
`{"outbounds":[` + tc.raw + `]}`,
|
||||
)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("rewrite: changed=%v err=%v", changed, err)
|
||||
}
|
||||
var after struct {
|
||||
Outbounds []json.RawMessage `json:"outbounds"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(updated), &after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(after.Outbounds[0]), tc.wantValue) {
|
||||
t.Fatalf("rewritten outbound = %s, want it to carry %s", after.Outbounds[0], tc.wantValue)
|
||||
}
|
||||
|
||||
capture.msgs = nil
|
||||
if err := xray.ValidateOutboundConfig(after.Outbounds[0]); err != nil {
|
||||
t.Fatalf("xray-core refused the rewritten outbound: %v", err)
|
||||
}
|
||||
if capture.has("sockopt.domainStrategy") {
|
||||
t.Fatalf("rewritten outbound still warns on load: %v", capture.msgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteFreedomDomainStrategyInvalidJSON(t *testing.T) {
|
||||
_, changed, err := rewriteFreedomDomainStrategy("{not json")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for invalid JSON")
|
||||
}
|
||||
if changed {
|
||||
t.Fatal("invalid JSON must not report a change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateFreedomDomainStrategyRewritesStoredTemplate(t *testing.T) {
|
||||
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
||||
// A CGO_ENABLED=0 build links a stubbed driver, so this test needs the same
|
||||
// C compiler the rest of the package's DB tests do.
|
||||
if err := InitDB(config.GetDBPath()); err != nil {
|
||||
if strings.Contains(err.Error(), "CGO_ENABLED=0") {
|
||||
t.Skipf("sqlite needs cgo: %v", err)
|
||||
}
|
||||
t.Fatalf("init db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
|
||||
legacy := `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"UseIPv4"}}]}`
|
||||
seedTemplate(t, legacy)
|
||||
if err := db.Where("seeder_name = ?", "FreedomDomainStrategyFix").
|
||||
Delete(&model.HistoryOfSeeders{}).Error; err != nil {
|
||||
t.Fatalf("clear seeder history: %v", err)
|
||||
}
|
||||
|
||||
if err := migrateFreedomDomainStrategy(); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
got := storedTemplate(t)
|
||||
var cfg struct {
|
||||
Outbounds []map[string]any `json:"outbounds"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got), &cfg); err != nil {
|
||||
t.Fatalf("stored template is not JSON: %v", err)
|
||||
}
|
||||
if len(cfg.Outbounds) != 1 {
|
||||
t.Fatalf("stored outbounds = %d, want 1", len(cfg.Outbounds))
|
||||
}
|
||||
outbound := cfg.Outbounds[0]
|
||||
if _, present := outbound["targetStrategy"]; present {
|
||||
t.Errorf("stored outbound kept the root targetStrategy: %s", got)
|
||||
}
|
||||
settings, _ := outbound["settings"].(map[string]any)
|
||||
if _, present := settings["domainStrategy"]; present {
|
||||
t.Errorf("stored outbound kept the deprecated settings key: %s", got)
|
||||
}
|
||||
stream, _ := outbound["streamSettings"].(map[string]any)
|
||||
sockopt, _ := stream["sockopt"].(map[string]any)
|
||||
if sockopt["domainStrategy"] != "UseIPv4" {
|
||||
t.Errorf("stored sockopt strategy = %v, want UseIPv4", sockopt["domainStrategy"])
|
||||
}
|
||||
|
||||
// The history gate is what keeps a hand-edited template from being rewritten
|
||||
// again on every restart, so run the real seeder list over a fresh legacy one.
|
||||
seedTemplate(t, legacy)
|
||||
if err := runSeeders(false); err != nil {
|
||||
t.Fatalf("runSeeders: %v", err)
|
||||
}
|
||||
if got := storedTemplate(t); got != legacy {
|
||||
t.Errorf("a completed seeder rewrote the template again: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func seedTemplate(t *testing.T, value string) {
|
||||
t.Helper()
|
||||
if err := db.Where("key = ?", "xrayTemplateConfig").Delete(&model.Setting{}).Error; err != nil {
|
||||
t.Fatalf("clear template: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Setting{Key: "xrayTemplateConfig", Value: value}).Error; err != nil {
|
||||
t.Fatalf("seed template: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func storedTemplate(t *testing.T) string {
|
||||
t.Helper()
|
||||
var setting model.Setting
|
||||
if err := db.Where("key = ?", "xrayTemplateConfig").First(&setting).Error; err != nil {
|
||||
t.Fatalf("reload template: %v", err)
|
||||
}
|
||||
return setting.Value
|
||||
}
|
||||
Reference in New Issue
Block a user