Files
HomeDash/admin-server/pages.go
HomeDash Bot 7c2bed2f2d feat: 门户页卡片可配置 + 升级前域名校验 + 清理明文门户口令
按用户选择实施三件事:

1) 门户页服务卡片可配置(不再写死真实域名)
   - portal/index.html 卡片区加 <!--NAVI_CARDS_START/END--> 标记,内置卡片改为通用示例
   - renderPortalPage 支持数据卷 <data>/portal/cards.html 覆盖标记区间(清空即回落内置)
   - 新增 GET/POST /api/portal-cards 读写该片段
   - 测试:覆盖替换成功、空白片段回落内置
   - 仓库内真实域名由此清零

2) 升级前必须提供跨子域单点登录变量(防止"去掉写死默认值"造成 SSO 静默失效)
   - zd-host.sh 在切换前检查 PORTAL_COOKIE_DOMAIN:缺失且未传 NAVI_COOKIE_DOMAIN
     时直接拒绝升级(退出码 2)并给出正确命令;PORTAL_HOME_URL 可用 NAVI_HOME_URL 补
   - README 新增「升级已有部署前必须确认的两个变量」含失败表现与正确命令

3) 清理明文门户口令
   - 迁移旧容器 env 时丢弃 PORTAL_PASSWORD(docker inspect 可见 = 泄露面)
   - 升级后写入强随机口令并通过接口生效,部署日志只打印一次;失败不阻断升级并提示后台可改
2026-09-12 10:14:22 +08:00

159 lines
5.3 KiB
Go
Raw 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-->", beianFooter(mapString(info, "icp"), mapString(info, "gongan")),
)
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())
}
// 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")
}