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 可见 = 泄露面)
   - 升级后写入强随机口令并通过接口生效,部署日志只打印一次;失败不阻断升级并提示后台可改
This commit is contained in:
HomeDash Bot
2026-09-12 10:14:22 +08:00
parent b99319b38b
commit 7c2bed2f2d
6 changed files with 158 additions and 0 deletions

View File

@ -1240,6 +1240,8 @@ func main() {
http.HandleFunc("/api/portal-auth", cors(handlePortalAuthSettings))
// 门户首页渲染(可配置文案 + 数据卷整页覆盖),供 gateway 的 kind=portal 反代
http.HandleFunc("/portal-page", handlePortalPage)
// 门户页服务卡片片段(数据卷 portal/cards.html),改链接不必重建镜像
http.HandleFunc("/api/portal-cards", cors(handlePortalCards))
// 历史记录管理:列表 / 清空全部 / 删除单条
http.HandleFunc("/api/history", cors(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {

View File

@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"html"
"net/http"
"os"
@ -64,10 +65,78 @@ func renderPortalPage() []byte {
"<!--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")

View File

@ -65,3 +65,29 @@ func TestPortalOverrideWins(t *testing.T) {
t.Fatalf("空白覆盖文件应被忽略")
}
}
// 数据卷里的卡片片段应替换标记之间的内容(改门户链接无需重建镜像)。
func TestCardsOverrideReplacesMarkedBlock(t *testing.T) {
dir := t.TempDir()
old := confFile
defer func() { confFile = old }()
confFile = filepath.Join(dir, "conf.yml")
if err := os.MkdirAll(filepath.Join(dir, "portal"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "portal", "cards.html"), []byte("<a href=\"https://mine.example.org\">我的服务</a>"), 0o644); err != nil {
t.Fatal(err)
}
page := "<div>" + cardsStartMarker + "<a href=\"https://old.example.com\">旧</a>" + cardsEndMarker + "</div>"
got := applyCardsOverride(page)
if !strings.Contains(got, "mine.example.org") || strings.Contains(got, "old.example.com") {
t.Fatalf("覆盖未生效: %s", got)
}
// 清空后回落内置卡片
if err := os.WriteFile(filepath.Join(dir, "portal", "cards.html"), []byte(" \n"), 0o644); err != nil {
t.Fatal(err)
}
if got := applyCardsOverride(page); !strings.Contains(got, "old.example.com") {
t.Fatalf("空白片段应回落内置卡片: %s", got)
}
}