Merge pull request #1026 from samwafgo/feat_manage_title

feat: support a unified custom admin title
This commit is contained in:
samwafgo
2026-10-20 11:28:36 +08:00
committed by GitHub
13 changed files with 498 additions and 0 deletions
+68
View File
@@ -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)
}
+93
View File
@@ -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: "<b>Ops & Admin</b>", want: "<b>Ops & Admin</b>"},
{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())
}
}
}
+4
View File
@@ -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"` // 多个域名用逗号分隔,为空表示不限制
+4
View File
@@ -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"`
+6
View File
@@ -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)
+13
View File
@@ -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)
}
+73
View File
@@ -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
}
+134
View File
@@ -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()
}
+2
View File
@@ -103,6 +103,8 @@ func LoadAndInitConfig() {
}
configChanged = true
}
loadAdminTitles(config)
if config.IsSet("notice.isenable") {
global.GWAF_NOTICE_ENABLE = config.GetBool("notice.isenable")
} else {
+1
View File
@@ -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())
+12
View File
@@ -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 := `<script id="samwaf-ui-config" type="application/json">` + string(data) + `</script>`
if strings.Contains(html, "</head>") {
return strings.Replace(html, "</head>", script+"</head>", 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 := `<script>window.__SAMWAF_SECURITY_PATH__='` + secPath + `';</script>`
html = strings.ReplaceAll(html, `</head>`, injectScript+`</head>`)
}
html = injectAdminTitles(html, wafconfig.GetAdminTitles())
_, _ = c.Writer.WriteString(html)
c.Writer.Flush()
c.Writer.WriteHeaderNow()
+72
View File
@@ -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 = `<html><head><script src="/assets/app.js"></script><link href="/assets/app.css"></head></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 = `<script id="samwaf-ui-config" type="application/json">`
for _, html := range []string{
`<html><head><title>SamWaf</title></head><body></body></html>`,
`<html><body>fallback</body></html>`,
} {
for _, titles := range []wafconfig.AdminTitles{
{},
{AdminTitle: `</script><script>alert("x")</script>` + "<>&

中文"},
} {
got := injectAdminTitles(html, titles)
if strings.Count(got, marker) != 1 || strings.Count(got, "</script>") != 1 {
t.Fatalf("unexpected script boundaries: %s", got)
}
data := strings.SplitN(strings.SplitN(got, marker, 2)[1], "</script>", 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, "</head>") && !strings.Contains(got, "</script></head>") {
t.Fatal("bootstrap must be inserted in head")
}
}
}
}
+16
View File
@@ -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