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:
JianFeeeee
2026-09-11 16:25:26 +08:00
parent 9eb8214fab
commit e4967b99ee
4 changed files with 134 additions and 5 deletions

View File

@ -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
View 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, "&lt;script&gt;") && !strings.Contains(got, "&#34;") {
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)
}
}