feat(admin): 备案号可编辑,页脚模板由服务端生成
备案号原来只能写死在 pageInfo.footer 的 HTML 里。现在 /admin/ 站点信息 拆出「ICP 备案号」「公安备案号」两个输入框,页脚 HTML 由服务端 beianFooter() 统一按模板生成(含 html.EscapeString 转义), 自定义页脚 HTML 降级为「高级」折叠项;旧 footer 会被解析回填到字段。 conf.example.yml 里重复的 .navi-search-bar 规则已由 web/navi.css 接管,删除。
This commit is contained in:
@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@ -475,6 +476,33 @@ func handleSyncInfo(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// beianFooter 按固定模板生成页脚备案号 HTML。
|
||||
//
|
||||
// 备案号在 /admin/ 里就是两个普通输入框,模板统一由服务端生成,
|
||||
// 用户不必写 HTML;类名与 web/navi.css 里的 .navi-footer 对应。
|
||||
// footer 字段本身仍然保留:传了自定义 HTML 就以它为准(高级用法)。
|
||||
func beianFooter(icp, gongan string) string {
|
||||
icp = strings.TrimSpace(icp)
|
||||
gongan = strings.TrimSpace(gongan)
|
||||
parts := make([]string, 0, 2)
|
||||
if icp != "" {
|
||||
parts = append(parts, `<a href="https://beian.miit.gov.cn" target="_blank" rel="noopener noreferrer">`+html.EscapeString(icp)+`</a>`)
|
||||
}
|
||||
if gongan != "" {
|
||||
parts = append(parts, `<a href="https://beian.mps.gov.cn" target="_blank" rel="noopener noreferrer">`+html.EscapeString(gongan)+`</a>`)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return `<div class="navi-footer">` + strings.Join(parts, `<span class="navi-footer-sep">·</span>`) + `</div>`
|
||||
}
|
||||
|
||||
// mapString 读取 map 里的字符串字段,缺失或类型不符时返回空串。
|
||||
func mapString(m map[string]any, key string) string {
|
||||
v, _ := m[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// ── 站点信息:标题 / 副标题 / 顶部导航 / 页脚 ──
|
||||
// 这些东西原先只能在 conf.yml 里手改,现在可在 /admin/ 编辑。
|
||||
func handlePageInfo(w http.ResponseWriter, r *http.Request) {
|
||||
@ -501,6 +529,8 @@ func handlePageInfo(w http.ResponseWriter, r *http.Request) {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Footer *string `json:"footer"`
|
||||
ICP *string `json:"icp"`
|
||||
Gongan *string `json:"gongan"`
|
||||
NavLinks *[]map[string]any `json:"navLinks"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
@ -516,6 +546,17 @@ func handlePageInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if body.Footer != nil {
|
||||
cfg.PageInfo["footer"] = *body.Footer
|
||||
}
|
||||
// 备案号单独存字段,便于在 /admin/ 里直接编辑,而不是让用户写 HTML。
|
||||
if body.ICP != nil {
|
||||
cfg.PageInfo["icp"] = *body.ICP
|
||||
}
|
||||
if body.Gongan != nil {
|
||||
cfg.PageInfo["gongan"] = *body.Gongan
|
||||
}
|
||||
// 页脚为空(留空 = 交给模板)时,按备案号重新生成。
|
||||
if strings.TrimSpace(mapString(cfg.PageInfo, "footer")) == "" {
|
||||
cfg.PageInfo["footer"] = beianFooter(mapString(cfg.PageInfo, "icp"), mapString(cfg.PageInfo, "gongan"))
|
||||
}
|
||||
if body.NavLinks != nil {
|
||||
cfg.PageInfo["navLinks"] = *body.NavLinks
|
||||
}
|
||||
|
||||
63
admin-server/main_test.go
Normal file
63
admin-server/main_test.go
Normal file
@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBeianFooter(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, icp, gongan string
|
||||
wantParts []string
|
||||
wantEmpty bool
|
||||
}{
|
||||
{name: "both", icp: "京ICP备00000000号-1", gongan: "京公网安备00000000000000号",
|
||||
wantParts: []string{"class=\"navi-footer\"", "beian.miit.gov.cn", "京ICP备00000000号-1", "navi-footer-sep", "beian.mps.gov.cn", "京公网安备00000000000000号"}},
|
||||
{name: "only icp", icp: " 京ICP备12345678号 ", wantParts: []string{"class=\"navi-footer\"", "京ICP备12345678号"}},
|
||||
{name: "only gongan", gongan: "沪公网安备31010102002555号", wantParts: []string{"class=\"navi-footer\"", "沪公网安备31010102002555号"}},
|
||||
{name: "blank", icp: " ", gongan: "", wantEmpty: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := beianFooter(tc.icp, tc.gongan)
|
||||
if tc.wantEmpty {
|
||||
if got != "" {
|
||||
t.Fatalf("want empty footer, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, p := range tc.wantParts {
|
||||
if !strings.Contains(got, p) {
|
||||
t.Fatalf("footer %q missing %q", got, p)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, " ") {
|
||||
t.Fatalf("footer keeps stray whitespace: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 备案号里若混入引号/尖括号,必须被转义,不能原样拼进 HTML。
|
||||
func TestBeianFooterEscapesInput(t *testing.T) {
|
||||
got := beianFooter(`"><script>alert(1)</script>`, "")
|
||||
if strings.Contains(got, "<script>") {
|
||||
t.Fatalf("unescaped input leaked into footer: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<script>") && !strings.Contains(got, """) {
|
||||
t.Fatalf("expected escaped output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapString(t *testing.T) {
|
||||
m := map[string]any{"a": "x", "b": 1, "c": nil}
|
||||
if got := mapString(m, "a"); got != "x" {
|
||||
t.Fatalf("mapString(a) = %q; want x", got)
|
||||
}
|
||||
if got := mapString(m, "b"); got != "" {
|
||||
t.Fatalf("mapString(b) = %q; want empty for non-string", got)
|
||||
}
|
||||
if got := mapString(m, "missing"); got != "" {
|
||||
t.Fatalf("mapString(missing) = %q; want empty", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user