diff --git a/README.md b/README.md index ac18f22..c34cf79 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,31 @@ NAVI_SSH_PASS='<密码>' bash deploy/zd-host.sh v50 - 「从现有 nginx 配置导入」可把旧 portal-nginx 的配置整段贴进来,解析出全部域名→后端初稿 (含认证状态、免认证路径、SSE、备案注入、自签证书、auth/portal 类型);**解析只读,确认后才保存** +### 升级已有部署前必须确认的两个变量 + +`deploy/zd-host.sh` 会在切换前检查:旧容器 env 里没有 `PORTAL_COOKIE_DOMAIN` 且你没传 +`NAVI_COOKIE_DOMAIN` 时**直接拒绝升级**(退出码 2),因为新版本已移除代码里的写死默认值, +直接升级会让会话 cookie 退化为 host-only —— 表现为"在登录页登录成功,跳到别的子域却又是未登录"。 + +```bash +NAVI_COOKIE_DOMAIN=.example.com NAVI_HOME_URL=https://example.com/ \ +NAVI_HOST=user@host NAVI_SSH_PASS='<密码>' bash deploy/zd-host.sh v54 +``` + +同一脚本还会**丢弃旧容器 env 里的明文 `PORTAL_PASSWORD`**(`docker inspect` 可见,属泄露面), +并在升级后写入一个强随机门户口令、在部署日志里**只打印一次**;实际生效的本来就是数据卷里的 +加盐哈希,因此丢掉 env 不影响登录。想自己设口令:后台「统一登录账号」。 + +### 门户页服务卡片(可配置,不写死域名) + +门户页的卡片区带 `` / `` 标记,两种改法: + +1. **数据卷片段**(推荐,改完刷新即生效,无需重建镜像):把卡片 HTML 写到 + `<数据卷>/portal/cards.html`,内容会替换标记之间的部分;清空该文件即回到内置卡片。 +2. **接口**:`GET/POST /api/portal-cards`,body `{"html":"…"}`(POST 需后台令牌,空串表示重置)。 + +整页替换(含版式)仍可用 `<数据卷>/portal/index.html`。仓库内置的卡片是通用示例,不含任何真实域名。 + ### 跨子域单点登录需要的两个环境变量 登录 cookie 不再内置任何域名,**默认下发 host-only cookie**(登录站点自身可用)。 diff --git a/admin-server/main.go b/admin-server/main.go index 46e9be4..98e26d4 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -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 { diff --git a/admin-server/pages.go b/admin-server/pages.go index 2d309a2..c391247 100644 --- a/admin-server/pages.go +++ b/admin-server/pages.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "html" "net/http" "os" @@ -64,10 +65,78 @@ func renderPortalPage() []byte { "", 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 = "" + cardsEndMarker = "" +) + +// 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") diff --git a/admin-server/pages_test.go b/admin-server/pages_test.go index 2883e31..ff549ab 100644 --- a/admin-server/pages_test.go +++ b/admin-server/pages_test.go @@ -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("我的服务"), 0o644); err != nil { + t.Fatal(err) + } + page := "