diff --git a/api/waf_admin_titles_api.go b/api/waf_admin_titles_api.go new file mode 100644 index 0000000..b335742 --- /dev/null +++ b/api/waf_admin_titles_api.go @@ -0,0 +1,68 @@ +package api + +import ( + "SamWaf/model/common/response" + "SamWaf/model/request" + response2 "SamWaf/model/response" + "SamWaf/service/waf_service" + "SamWaf/wafconfig" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "unicode" + "unicode/utf8" + + "github.com/gin-gonic/gin" +) + +func normalizeAdminTitle(title string) (string, error) { + if !utf8.ValidString(title) { + return "", fmt.Errorf("标题必须是有效 UTF-8 文本") + } + for _, r := range title { + if unicode.IsControl(r) || r == ' ' || r == ' ' { + return "", fmt.Errorf("标题必须为单行文本且不能包含控制字符") + } + } + title = strings.TrimSpace(title) + if utf8.RuneCountInString(title) > 100 { + return "", fmt.Errorf("标题不能超过100个字符") + } + return title, nil +} + +func (w *WafVpConfigApi) GetAdminTitlesApi(c *gin.Context) { + titles := (waf_service.WafAdminTitlesService{}).Get() + response.OkWithDetailed(response2.WafVpConfigAdminTitlesGetResp{ + AdminTitle: titles.AdminTitle, + }, "获取成功", c) +} + +func (w *WafVpConfigApi) UpdateAdminTitlesApi(c *gin.Context) { + var req request.WafVpConfigAdminTitlesUpdateReq + body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 16*1024)) + var fields map[string]json.RawMessage + if err != nil || !utf8.Valid(body) || json.Unmarshal(body, &fields) != nil || len(fields) != 1 { + response.FailWithMessage("解析请求失败", c) + return + } + value, ok := fields["admin_title"] + if !ok || string(value) == "null" || json.Unmarshal(body, &req) != nil { + response.FailWithMessage("解析请求失败", c) + return + } + adminTitle, err := normalizeAdminTitle(req.AdminTitle) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if err := (waf_service.WafAdminTitlesService{}).Update(wafconfig.AdminTitles{ + AdminTitle: adminTitle, + }); err != nil { + response.FailWithMessage("更新管理端标题失败", c) + return + } + w.GetAdminTitlesApi(c) +} diff --git a/api/waf_admin_titles_api_test.go b/api/waf_admin_titles_api_test.go new file mode 100644 index 0000000..9a68518 --- /dev/null +++ b/api/waf_admin_titles_api_test.go @@ -0,0 +1,93 @@ +package api + +import ( + "SamWaf/global" + "SamWaf/wafconfig" + "SamWaf/wafsec" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestNormalizeAdminTitle(t *testing.T) { + for _, tc := range []struct { + name, input, want string + invalid bool + }{ + {name: "empty"}, + {name: "trim", input: " 管理后台 ", want: "管理后台"}, + {name: "100 unicode", input: strings.Repeat("界", 100), want: strings.Repeat("界", 100)}, + {name: "101 unicode", input: strings.Repeat("界", 101), invalid: true}, + {name: "html remains text", input: "Ops & Admin", want: "Ops & Admin"}, + {name: "invalid utf8", input: string([]byte{0xff}), invalid: true}, + {name: "newline", input: "a\nb", invalid: true}, + {name: "trailing newline", input: "title\n", invalid: true}, + {name: "tab", input: "a\tb", invalid: true}, + {name: "nul", input: "a\x00b", invalid: true}, + {name: "delete", input: "a\x7fb", invalid: true}, + {name: "line separator", input: "a b", invalid: true}, + {name: "paragraph separator", input: "a b", invalid: true}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := normalizeAdminTitle(tc.input) + if (err != nil) != tc.invalid || (!tc.invalid && got != tc.want) { + t.Fatalf("got %q, %v; want %q, invalid=%v", got, err, tc.want, tc.invalid) + } + }) + } +} + +func TestAdminTitlesGetEncryptedSingleField(t *testing.T) { + priorLegacy := global.GCONFIG_COMM_LEGACY_KEY + global.GCONFIG_COMM_LEGACY_KEY = true + t.Cleanup(func() { global.GCONFIG_COMM_LEGACY_KEY = priorLegacy }) + r := gin.New() + r.GET("/api/v1/public/uiconfig", (&WafVpConfigApi{}).GetAdminTitlesApi) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/api/v1/public/uiconfig", nil)) + var envelope struct { + Code int `json:"code"` + Data string `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &envelope); err != nil || envelope.Code != 0 || envelope.Data == "" { + t.Fatalf("invalid encrypted response: %s, %v", w.Body.String(), err) + } + plain, err := wafsec.AesDecrypt(envelope.Data, global.GWAF_COMMUNICATION_KEY) + if err != nil { + t.Fatal(err) + } + var fields map[string]string + if err := json.Unmarshal(plain, &fields); err != nil { + t.Fatal(err) + } + want := wafconfig.GetAdminTitles() + if len(fields) != 1 || fields["admin_title"] != want.AdminTitle { + t.Fatalf("unexpected fields: %s", plain) + } +} + +func TestAdminTitlesUpdateRejectsInvalidRequest(t *testing.T) { + r := gin.New() + r.POST("/titles", (&WafVpConfigApi{}).UpdateAdminTitlesApi) + for _, body := range []string{ + `{"admin_title":123}`, `{"admin_title":true}`, `{"admin_title":null}`, + `{"admin_title":"a\nb"}`, + `{"admin_title":"` + strings.Repeat("界", 101) + `"}`, + "{\"admin_title\":\"" + string([]byte{0xff}) + "\"}", + `{}`, `null`, `[]`, `{"other":"title"}`, + `{"admin_title":"title","other":"extra"}`, + `{"admin_title":"title"} {}`, + } { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("POST", "/titles", strings.NewReader(body))) + var envelope struct { + Code int `json:"code"` + } + if err := json.Unmarshal(w.Body.Bytes(), &envelope); err != nil || envelope.Code != -1 { + t.Fatalf("invalid request accepted: %q, response %s", body, w.Body.String()) + } + } +} diff --git a/model/request/waf_vpconfig_req.go b/model/request/waf_vpconfig_req.go index ae2bb18..2972003 100644 --- a/model/request/waf_vpconfig_req.go +++ b/model/request/waf_vpconfig_req.go @@ -52,6 +52,10 @@ type WafVpConfigNoticeTitleUpdateReq struct { NoticeTitle string `json:"notice_title"` // 通知消息标题前缀,用于区分多实例 } +type WafVpConfigAdminTitlesUpdateReq struct { + AdminTitle string `json:"admin_title"` +} + // WafVpConfigDomainWhitelistUpdateReq 域名白名单更新请求 type WafVpConfigDomainWhitelistUpdateReq struct { DomainWhitelist string `json:"domain_whitelist"` // 多个域名用逗号分隔,为空表示不限制 diff --git a/model/response/waf_vpconfig_resp.go b/model/response/waf_vpconfig_resp.go index c267654..3731fbf 100644 --- a/model/response/waf_vpconfig_resp.go +++ b/model/response/waf_vpconfig_resp.go @@ -48,6 +48,10 @@ type WafVpConfigNoticeTitleGetResp struct { NoticeTitle string `json:"notice_title"` // 通知消息标题前缀 } +type WafVpConfigAdminTitlesGetResp struct { + AdminTitle string `json:"admin_title"` +} + // WafVpConfigDomainWhitelistGetResp 域名白名单获取响应 type WafVpConfigDomainWhitelistGetResp struct { DomainWhitelist string `json:"domain_whitelist"` diff --git a/router/waf_vpconfig_router.go b/router/waf_vpconfig_router.go index 5a01f92..b36924e 100644 --- a/router/waf_vpconfig_router.go +++ b/router/waf_vpconfig_router.go @@ -11,6 +11,10 @@ import ( type WafVpConfigRouter struct { } +func (receiver *WafVpConfigRouter) InitAdminTitlesPublicRouter(group *gin.RouterGroup) { + group.GET("/api/v1/public/uiconfig", api.APIGroupAPP.WafVpConfigApi.GetAdminTitlesApi) +} + func (receiver *WafVpConfigRouter) InitWafVpConfigRouter(group *gin.RouterGroup) { wafVpConfigApi := api.APIGroupAPP.WafVpConfigApi // 读接口:任意已登录管理员可查看当前配置 @@ -22,6 +26,7 @@ func (receiver *WafVpConfigRouter) InitWafVpConfigRouter(group *gin.RouterGroup) router.GET("/api/v1/vipconfig/getSslStatus", wafVpConfigApi.GetSslStatusApi) router.GET("/api/v1/vipconfig/getSecurityEntry", wafVpConfigApi.GetSecurityEntryApi) router.GET("/api/v1/vipconfig/getNoticeTitle", wafVpConfigApi.GetNoticeTitleApi) + router.GET("/api/v1/vipconfig/getAdminTitles", wafVpConfigApi.GetAdminTitlesApi) router.GET("/api/v1/vipconfig/getDomainWhitelist", wafVpConfigApi.GetDomainWhitelistApi) router.GET("/api/v1/vipconfig/getSslForceHttps", wafVpConfigApi.GetSslForceHttpsApi) router.GET("/api/v1/vipconfig/getSslBindCert", wafVpConfigApi.GetSslBindCertApi) @@ -51,6 +56,7 @@ func (receiver *WafVpConfigRouter) InitWafVpConfigRouter(group *gin.RouterGroup) writeRouter.POST("/api/v1/vipconfig/restartManager", wafVpConfigApi.RestartManagerApi) writeRouter.POST("/api/v1/vipconfig/updateSecurityEntry", wafVpConfigApi.UpdateSecurityEntryApi) writeRouter.POST("/api/v1/vipconfig/updateNoticeTitle", wafVpConfigApi.UpdateNoticeTitleApi) + writeRouter.POST("/api/v1/vipconfig/updateAdminTitles", wafVpConfigApi.UpdateAdminTitlesApi) writeRouter.POST("/api/v1/vipconfig/updateDomainWhitelist", wafVpConfigApi.UpdateDomainWhitelistApi) writeRouter.POST("/api/v1/vipconfig/updateSslForceHttps", wafVpConfigApi.UpdateSslForceHttpsApi) writeRouter.POST("/api/v1/vipconfig/updateSslBindCert", wafVpConfigApi.UpdateSslBindCertApi) diff --git a/service/waf_service/waf_admin_titles.go b/service/waf_service/waf_admin_titles.go new file mode 100644 index 0000000..7a094d1 --- /dev/null +++ b/service/waf_service/waf_admin_titles.go @@ -0,0 +1,13 @@ +package waf_service + +import "SamWaf/wafconfig" + +type WafAdminTitlesService struct{} + +func (WafAdminTitlesService) Get() wafconfig.AdminTitles { + return wafconfig.GetAdminTitles() +} + +func (WafAdminTitlesService) Update(titles wafconfig.AdminTitles) error { + return wafconfig.UpdateAdminTitles(titles) +} diff --git a/wafconfig/titles.go b/wafconfig/titles.go new file mode 100644 index 0000000..981ffd3 --- /dev/null +++ b/wafconfig/titles.go @@ -0,0 +1,73 @@ +package wafconfig + +import ( + "SamWaf/utils" + "os" + "path/filepath" + "sync" + + "github.com/spf13/viper" +) + +type AdminTitles struct { + AdminTitle string `json:"admin_title"` +} + +var adminTitlesState struct { + sync.RWMutex + titles AdminTitles +} + +func GetAdminTitles() AdminTitles { + adminTitlesState.RLock() + defer adminTitlesState.RUnlock() + return adminTitlesState.titles +} + +func loadAdminTitles(config *viper.Viper) { + adminTitlesState.Lock() + defer adminTitlesState.Unlock() + adminTitlesState.titles = AdminTitles{ + AdminTitle: config.GetString("ui.admin_title"), + } +} + +func UpdateAdminTitles(titles AdminTitles) error { + return updateAdminTitles(filepath.Join(utils.GetCurrentDir(), "conf"), titles) +} + +func updateAdminTitles(configDir string, titles AdminTitles) error { + adminTitlesState.Lock() + defer adminTitlesState.Unlock() + + if err := os.MkdirAll(configDir, os.ModePerm); err != nil { + return err + } + config := viper.New() + config.AddConfigPath(configDir) + config.SetConfigName("config") + config.SetConfigType("yml") + if err := config.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return err + } + return persistAdminTitles(config, titles, true) + } + return persistAdminTitles(config, titles, false) +} + +// 调用方持有 adminTitlesState 写锁。 +func persistAdminTitles(config *viper.Viper, titles AdminTitles, create bool) error { + config.Set("ui.admin_title", titles.AdminTitle) + var err error + if create { + err = config.SafeWriteConfig() + } else { + err = config.WriteConfig() + } + if err != nil { + return err + } + adminTitlesState.titles = titles + return nil +} diff --git a/wafconfig/titles_test.go b/wafconfig/titles_test.go new file mode 100644 index 0000000..7fd478e --- /dev/null +++ b/wafconfig/titles_test.go @@ -0,0 +1,134 @@ +package wafconfig + +import ( + "os" + "path/filepath" + "sync" + "testing" + + "github.com/spf13/viper" +) + +func preserveAdminTitles(t *testing.T) { + t.Helper() + prior := GetAdminTitles() + t.Cleanup(func() { + adminTitlesState.Lock() + adminTitlesState.titles = prior + adminTitlesState.Unlock() + }) +} + +func TestAdminTitlesPersistAndLoad(t *testing.T) { + preserveAdminTitles(t) + dir := t.TempDir() + path := filepath.Join(dir, "config.yml") + if err := os.WriteFile(path, []byte("local_port: 26666\nnotice:\n title: existing\n"), 0600); err != nil { + t.Fatal(err) + } + want := AdminTitles{AdminTitle: "管理后台 & Ops"} + if err := updateAdminTitles(dir, want); err != nil { + t.Fatal(err) + } + if got := GetAdminTitles(); got != want { + t.Fatalf("runtime titles = %#v, want %#v", got, want) + } + config := viper.New() + config.SetConfigFile(path) + if err := config.ReadInConfig(); err != nil { + t.Fatal(err) + } + if config.GetString("notice.title") != "existing" || config.GetInt("local_port") != 26666 { + t.Fatal("existing config was not preserved") + } + ui := config.GetStringMapString("ui") + if len(ui) != 1 || ui["admin_title"] != want.AdminTitle { + t.Fatalf("unexpected ui config: %#v", ui) + } + loadAdminTitles(viper.New()) + if GetAdminTitles() != (AdminTitles{}) { + t.Fatal("missing config must load empty defaults") + } + loadAdminTitles(config) + if got := GetAdminTitles(); got != want { + t.Fatalf("loaded titles = %#v, want %#v", got, want) + } + if err := updateAdminTitles(dir, AdminTitles{}); err != nil { + t.Fatal(err) + } + if err := config.ReadInConfig(); err != nil { + t.Fatal(err) + } + if !config.IsSet("ui.admin_title") || config.GetString("ui.admin_title") != "" { + t.Fatal("empty admin title must persist under ui.admin_title") + } + loadAdminTitles(config) + if GetAdminTitles() != (AdminTitles{}) { + t.Fatal("empty titles must persist") + } +} + +func TestAdminTitlesPersistenceFailureKeepsSnapshot(t *testing.T) { + preserveAdminTitles(t) + dir := t.TempDir() + prior := AdminTitles{AdminTitle: "prior"} + if err := updateAdminTitles(dir, prior); err != nil { + t.Fatal(err) + } + config := viper.New() + config.SetConfigFile(dir) + config.SetConfigType("yml") + adminTitlesState.Lock() + err := persistAdminTitles(config, AdminTitles{AdminTitle: "next"}, false) + adminTitlesState.Unlock() + if err == nil { + t.Fatal("expected config write failure") + } + if GetAdminTitles() != prior { + t.Fatal("write failure changed runtime titles") + } + path := filepath.Join(dir, "config.yml") + if err := os.WriteFile(path, []byte("ui: [invalid"), 0600); err != nil { + t.Fatal(err) + } + if err := updateAdminTitles(dir, AdminTitles{AdminTitle: "next"}); err == nil { + t.Fatal("expected config read failure") + } + if GetAdminTitles() != prior { + t.Fatal("failed persistence changed runtime titles") + } + if err := updateAdminTitles(path, AdminTitles{}); err == nil { + t.Fatal("expected config directory failure") + } + if GetAdminTitles() != prior { + t.Fatal("directory failure changed runtime titles") + } +} + +func TestAdminTitlesCreateAndConcurrentSnapshot(t *testing.T) { + preserveAdminTitles(t) + dir := t.TempDir() + if err := updateAdminTitles(dir, AdminTitles{}); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 1000; i++ { + got := GetAdminTitles() + switch got.AdminTitle { + case "", "one", "two": + default: + t.Errorf("unexpected snapshot: %#v", got) + return + } + } + }() + for _, title := range []string{"one", "two", ""} { + if err := updateAdminTitles(dir, AdminTitles{AdminTitle: title}); err != nil { + t.Error(err) + } + } + wg.Wait() +} diff --git a/wafconfig/wafconfig.go b/wafconfig/wafconfig.go index 0154d66..c2692ef 100644 --- a/wafconfig/wafconfig.go +++ b/wafconfig/wafconfig.go @@ -103,6 +103,8 @@ func LoadAndInitConfig() { } configChanged = true } + loadAdminTitles(config) + if config.IsSet("notice.isenable") { global.GWAF_NOTICE_ENABLE = config.GetBool("notice.isenable") } else { diff --git a/wafmangeweb/localserver.go b/wafmangeweb/localserver.go index b11be66..5091ea6 100644 --- a/wafmangeweb/localserver.go +++ b/wafmangeweb/localserver.go @@ -126,6 +126,7 @@ func (web *WafWebManager) initRouter(r *gin.Engine) { PublicRouterGroup.Use(middleware.SecApi(), middleware.IPWhitelist(), middleware.DomainWhitelist(), middleware.ReplayProtect()) router.PublicApiGroupApp.InitLoginRouter(PublicRouterGroup) router.PublicApiGroupApp.InitSecKeyRouter(PublicRouterGroup) + router.ApiGroupApp.InitAdminTitlesPublicRouter(PublicRouterGroup) RouterGroup := r.Group("") RouterGroup.Use(middleware.Auth(), middleware.ReplayProtect(), middleware.OpenApiLogMiddleware(), middleware.IPWhitelist(), middleware.DomainWhitelist(), middleware.SecApi(), middleware.GinGlobalExceptionMiddleWare()) diff --git a/wafmangeweb/static/static.go b/wafmangeweb/static/static.go index 4a0885e..db9e406 100644 --- a/wafmangeweb/static/static.go +++ b/wafmangeweb/static/static.go @@ -4,6 +4,8 @@ import ( "SamWaf/common/zlog" "SamWaf/global" "SamWaf/public" + "SamWaf/wafconfig" + "encoding/json" "errors" "fmt" "io" @@ -87,6 +89,15 @@ func initIndex() { // 存储原始 HTML,安全路径注入由 NoRoute handler 在每次请求时动态完成 global.GWAF_LOCAL_INDEX_HTML = string(index) } +func injectAdminTitles(html string, titles wafconfig.AdminTitles) string { + data, _ := json.Marshal(titles) + script := `` + if strings.Contains(html, "") { + return strings.Replace(html, "", script+"", 1) + } + return script + html +} + func Static(r *gin.Engine, noRoute func(handlers ...gin.HandlerFunc)) { initStatic() initIndex() @@ -153,6 +164,7 @@ func Static(r *gin.Engine, noRoute func(handlers ...gin.HandlerFunc)) { injectScript := `` html = strings.ReplaceAll(html, ``, injectScript+``) } + html = injectAdminTitles(html, wafconfig.GetAdminTitles()) _, _ = c.Writer.WriteString(html) c.Writer.Flush() c.Writer.WriteHeaderNow() diff --git a/wafmangeweb/static/static_test.go b/wafmangeweb/static/static_test.go new file mode 100644 index 0000000..3392408 --- /dev/null +++ b/wafmangeweb/static/static_test.go @@ -0,0 +1,72 @@ +package static + +import ( + "SamWaf/global" + "SamWaf/wafconfig" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestAdminTitlesNoRouteBootstrap(t *testing.T) { + priorHTML := global.GWAF_LOCAL_INDEX_HTML + priorEnable, priorPath := global.GWAF_SECURITY_ENTRY_ENABLE, global.GWAF_SECURITY_ENTRY_PATH + t.Cleanup(func() { + global.GWAF_LOCAL_INDEX_HTML = priorHTML + global.GWAF_SECURITY_ENTRY_ENABLE, global.GWAF_SECURITY_ENTRY_PATH = priorEnable, priorPath + }) + r := gin.New() + Static(r, r.NoRoute) + global.GWAF_LOCAL_INDEX_HTML = `
` + for _, enabled := range []bool{false, true} { + global.GWAF_SECURITY_ENTRY_ENABLE, global.GWAF_SECURITY_ENTRY_PATH = enabled, "entry" + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/login", nil)) + body := w.Body.String() + if w.Code != 200 || strings.Count(body, `id="samwaf-ui-config"`) != 1 { + t.Fatalf("bootstrap missing: %d %s", w.Code, body) + } + if enabled { + if !strings.Contains(body, `src="/entry/assets/app.js"`) || !strings.Contains(body, `href="/entry/assets/app.css"`) || !strings.Contains(body, `window.__SAMWAF_SECURITY_PATH__='/entry'`) { + t.Fatalf("security path rewriting changed: %s", body) + } + } else if !strings.Contains(body, `src="/assets/app.js"`) || strings.Contains(body, "__SAMWAF_SECURITY_PATH__") { + t.Fatalf("unexpected security path rewriting: %s", body) + } + } +} + +func TestInjectAdminTitles(t *testing.T) { + const marker = `` + "<>& 中文"}, + } { + got := injectAdminTitles(html, titles) + if strings.Count(got, marker) != 1 || strings.Count(got, "") != 1 { + t.Fatalf("unexpected script boundaries: %s", got) + } + data := strings.SplitN(strings.SplitN(got, marker, 2)[1], "", 2)[0] + if strings.ContainsAny(data, "<>&") { + t.Fatalf("JSON HTML characters were not escaped: %s", data) + } + var decoded map[string]string + if err := json.Unmarshal([]byte(data), &decoded); err != nil { + t.Fatal(err) + } + if len(decoded) != 1 || decoded["admin_title"] != titles.AdminTitle { + t.Fatalf("unexpected bootstrap: %s", data) + } + if strings.Contains(html, "") && !strings.Contains(got, "") { + t.Fatal("bootstrap must be inserted in head") + } + } + } +} diff --git a/wafupgradenotice/upgrade_notes.yaml b/wafupgradenotice/upgrade_notes.yaml index 6438726..90fa136 100644 --- a/wafupgradenotice/upgrade_notes.yaml +++ b/wafupgradenotice/upgrade_notes.yaml @@ -12,6 +12,22 @@ # 5. fresh_install: true 的条目只在"全新安装"时生成,不参与版本区间计算 notes: + - id: v1_3_25_admin_titles + version: v1.3.25 + kind: check + level: low + page: /sys/VpConfig?section=title + apply: + type: navigate + zh: + title: 支持统一自定义后台标题 + detail: 可在「系统设置 → 管理配置 → 后台标题」设置一个后台标题,同时用于登录页和浏览器标签页,保存后无需重启。留空时两处各自沿用当前语言的默认文案;其他已打开的页面刷新后显示新标题。 + revert: 清空后台标题并保存,即可恢复两处的默认文案。 + en: + title: Customize a shared admin title + detail: Set one Admin Title under System Settings > Management Configuration > Admin Titles to use the same text on the login page and browser tab. Saving applies immediately without restart. Leave it blank to use each location's default text for the current language; refresh other open pages to load the new title. + revert: Clear the Admin Title and save to restore the default text in both locations. + - id: v1_3_25_unmatched_website_response version: v1.3.25 kind: check