Files
HomeDash/admin-server/pages.go
HomeDash Bot 3d18fb59ce fix(ui): 登录页页脚被当纯文本显示(html/template 转义)+ 三处页脚统一
用户发现登录页出现 `<div class="navi-footer">…` 这串标记文字。

① 根因:auth.go 把 beianFooter() 的字符串塞进 Go html/template(map[string]string),
   模板自动转义整段 HTML → 页面上直接显示标记源码。
   修法:改用 map[string]any + template.HTML(beianFooter 内部已对备案号做 html.EscapeString,
   注入合法)。
② 登录页与门户页此前**都无视**后台的「页脚」自定义字段(后台提示语写的是"填写后以这里为准")。
   新增统一入口 siteFooter(info):自定义 footer 优先,否则按 icp/gongan/座右铭生成;
   pages.go(门户页)与 auth.go(登录页)都改走它。
③ 页脚座右铭此前只由 navi.js 在 Dashy 页客户端注入 → 登录页/门户页永远没有。
   新增 pageInfo 字段 motto(换行或 | 分行,留空用内置默认),
   footerMarkup() 生成时一并输出 .navi-quote 两行;navi.js 已有幂等判断不会重复注入。
   后台「站点信息」新增座右铭输入框,并登记进 /api/schema。
④ 登录页 / 门户页补页脚样式:左列两行备案号、右列两行座右铭;窄屏退化为居中单列。
2026-09-13 07:56:38 +08:00

168 lines
5.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"html"
"net/http"
"os"
"path/filepath"
"strings"
)
// ── 认证页 / 门户页的可配置渲染 ──
//
// 原先这两页的文案写死在代码与镜像里(登录页标题、门户页标题/副标题/页脚…),
// 改一次就得改代码重新部署。现在统一从 conf.yml 的 pageInfo 读取,
// 缺省时回落到当前默认文案(即"不配置也照旧")。
//
// 覆盖优先级(门户页):
// 1. <data>/portal/index.html —— 完全自定义整页(放进去即生效)
// 2. 内置模板 + pageInfo 字段替换
//
// 门户页的静态资源(/css/portal.css、favicon)仍由 nginx 提供,
// 因此自定义整页时把它们放在 <data>/portal/ 下即可覆盖。
const (
defaultPortalTitle = "JianF Git - 个人开发环境"
defaultPortalHero = "代码、博客与灵感之站"
defaultPortalHeroSub = "JianF Git · 代码托管 · 技术博客 · 私有服务入口"
defaultAuthTitle = "统一门户"
defaultAuthSubtitle = "登录后访问受保护的内部服务"
defaultAuthLogo = "JF"
)
// pageStr 读取 pageInfo 中的字符串,缺失时用兜底值。
func pageStr(info map[string]any, key, fallback string) string {
if v := strings.TrimSpace(mapString(info, key)); v != "" {
return v
}
return fallback
}
// portalOverridePath 数据卷里的整页覆盖文件。
func portalOverridePath() string {
return filepath.Join(filepath.Dir(confFile), "portal", "index.html")
}
// renderPortalPage 渲染门户首页:优先数据卷覆盖,其次内置模板 + pageInfo 替换。
func renderPortalPage() []byte {
if b, err := os.ReadFile(portalOverridePath()); err == nil && len(strings.TrimSpace(string(b))) > 0 {
return b
}
builtin, err := os.ReadFile("/app/portal/index.html")
if err != nil {
return []byte("<!doctype html><meta charset=utf-8><title>Portal</title><h1>Portal</h1>")
}
cfg, _ := loadConfig()
info := map[string]any{}
if cfg != nil {
info = cfg.PageInfo
}
rep := strings.NewReplacer(
"<!--PAGE_TITLE-->", html.EscapeString(pageStr(info, "portalTitle", defaultPortalTitle)),
"<!--HERO_TITLE-->", html.EscapeString(pageStr(info, "heroTitle", defaultPortalHero)),
"<!--HERO_SUBTITLE-->", html.EscapeString(pageStr(info, "heroSubtitle", defaultPortalHeroSub)),
"<!--FOOTER-->", siteFooter(info),
)
out := rep.Replace(string(builtin))
// 服务卡片区可覆盖:数据卷 portal/cards.html 存在时替换标记之间的内容,
// 这样改门户页的服务链接不必重新构建镜像(仓库也不再预置任何真实域名)。
out = applyCardsOverride(out)
// 未做占位替换的旧模板(例如自备的 index.html)原样返回
return []byte(out)
}
// cardsOverridePath 数据卷里的服务卡片片段。
func cardsOverridePath() string {
return filepath.Join(filepath.Dir(confFile), "portal", "cards.html")
}
const (
cardsStartMarker = "<!--NAVI_CARDS_START-->"
cardsEndMarker = "<!--NAVI_CARDS_END-->"
)
// applyCardsOverride 用数据卷片段替换标记之间的卡片内容;无片段或标记缺失则原样返回。
func applyCardsOverride(page string) string {
raw, err := os.ReadFile(cardsOverridePath())
if err != nil || len(strings.TrimSpace(string(raw))) == 0 {
return page
}
i := strings.Index(page, cardsStartMarker)
j := strings.Index(page, cardsEndMarker)
if i < 0 || j < i {
return page
}
return page[:i+len(cardsStartMarker)] + "\n" + string(raw) + "\n" + page[j:]
}
// handlePortalCards 读写服务卡片片段(后台「站点与外观」用)。
func handlePortalCards(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
raw, err := os.ReadFile(cardsOverridePath())
if err != nil {
jsonResp(w, map[string]any{"html": ""})
return
}
jsonResp(w, map[string]any{"html": string(raw)})
case http.MethodPost:
if !requireAdminToken(w, r) {
return
}
var body struct {
HTML string `json:"html"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonErr(w, 400, err.Error())
return
}
path := cardsOverridePath()
if strings.TrimSpace(body.HTML) == "" {
_ = os.Remove(path) // 清空 = 回到内置卡片
jsonResp(w, map[string]any{"status": "reset"})
return
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
jsonErr(w, 500, err.Error())
return
}
if err := os.WriteFile(path, []byte(body.HTML), 0o644); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, map[string]any{"status": "saved"})
default:
jsonErr(w, 405, "方法不允许")
}
}
// handlePortalPage 供 nginx 的 kind=portal 路由反代使用。
func handlePortalPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(renderPortalPage())
}
// pageInfoMap 读取数据卷里的站点信息(供 siteFooter 等统一入口使用)。
func pageInfoMap() map[string]any {
cfg, err := loadConfig()
if err != nil || cfg == nil || cfg.PageInfo == nil {
return map[string]any{}
}
return cfg.PageInfo
}
// authPageInfo 返回登录页所需字段(供 auth.go 渲染)。
func authPageInfo() (title, subtitle, logo, icp, gongan string) {
cfg, err := loadConfig()
info := map[string]any{}
if err == nil && cfg != nil {
info = cfg.PageInfo
}
return pageStr(info, "authTitle", defaultAuthTitle),
pageStr(info, "authSubtitle", defaultAuthSubtitle),
pageStr(info, "authLogo", defaultAuthLogo),
mapString(info, "icp"), mapString(info, "gongan")
}