diff --git a/.env.example b/.env.example index efdb58f..c62b8cc 100644 --- a/.env.example +++ b/.env.example @@ -10,12 +10,21 @@ ENV=development LOG_LEVEL=debug TZ=Asia/Shanghai -# ---- 数据目录(未设置时按 OS 惯例解析) ---- +# ---- 数据目录(裁决 12.4.2/15.4.1,design/13 §2) ---- +# 两种取法都实现:默认 ./data;显式写 platform(保留字哨兵)则走平台标准数据目录 +# (Windows %APPDATA%\Cyrene、macOS ~/Library/Application Support/Cyrene、Linux ~/.local/share/cyrene)。 DATA_DIR=./data # ---- 服务器(网关,唯一对外端口) ---- +# HOST 默认 0.0.0.0,暴露面由 .env 决定(本机自托管要收紧就改 127.0.0.1)。 HOST=0.0.0.0 PORT=8080 +# 管理面板的安全入口路径(裁决 1.6/16.4.7,design/07 §0.2): +# 管理面板前端挂在这个路径下,默认 admin(即 /admin);想藏起来就自定义,例如 +# ADMIN_PATH=my-secret/admin → 面板在 /my-secret/admin,猜 /admin 直接 404。 +# 它只影响面板前端路由;REST 仍是 /api/v1/admin/*(API 不藏,安全靠 JWT + 角色门), +# 且该路径串不会出现在用户端页面里。 +ADMIN_PATH=admin # ---- 认证 / 管理员 ---- # JWT_SECRET 必填,长度 >= 32;缺失或过短会在启动时 panic。 diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index 71d3bc3..1a25647 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -1,10 +1,10 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { TopBar } from "./components/TopBar"; import { Sidebar, MobileNav } from "./components/Nav"; import { ModeRouter } from "./modes/ModeRouter"; import { AdminApp } from "./admin/AdminApp"; import { Login } from "./components/Login"; -import { parseAdminHash } from "./admin/router"; +import { parseAdminRoute, readAdminEntry, subscribeAdminRoute } from "./admin/router"; import { connectWs, disconnectWs } from "./lib/ws"; import { markSessionOpenedRead } from "./lib/sessionRead"; import { initTheme } from "./lib/theme"; @@ -21,7 +21,15 @@ import { personaMoodSeed } from "./store/personaSelectors"; export default function App(): React.ReactNode { // Apply the persisted (dark-first) theme and open the realtime channel only // when we hold a token — otherwise the gateway rejects the handshake with 401. - const [route, setRoute] = useState(() => parseAdminHash(window.location.hash)); + // + // 管理面板是**安全入口**(裁决 1.6 / 16.4.7,design/07 §0.2):入口前缀由后端 + // 按 .env 的 ADMIN_PATH 注入到面板那一份 index.html + // (`window.__CYRENE_ADMIN_ENTRY__`,见 internal/services/gateway/routes.go)。 + // 用户端页面没有这个全局量,因此这里的 route.admin 恒为 false —— 用户端 + // 既不含入口链接、也解析不出面板路由。面板模块切换走 history(不再读 hash, + // hash 只留给渠道页的子页)。 + const entry = useMemo(readAdminEntry, []); + const [route, setRoute] = useState(() => parseAdminRoute(window.location.pathname, entry)); const [menuOpen, setMenuOpen] = useState(false); const setPersonas = useAppStore((s) => s.setPersonas); const seedMoods = useAppStore((s) => s.seedMoods); @@ -29,13 +37,13 @@ export default function App(): React.ReactNode { useEffect(() => { initTheme(); if (getAuthToken()) connectWs(); - const onHash = () => setRoute(parseAdminHash(window.location.hash)); - window.addEventListener("hashchange", onHash); + const onRoute = () => setRoute(parseAdminRoute(window.location.pathname, entry)); + const unsubscribe = subscribeAdminRoute(onRoute); return () => { disconnectWs(); - window.removeEventListener("hashchange", onHash); + unsubscribe(); }; - }, []); + }, [entry]); // Load the role catalog (design/22 §4.6). The rendered role is no longer one // global value: it is derived per active session from this catalog plus that @@ -136,7 +144,7 @@ export default function App(): React.ReactNode { }, [activeSessionId, activeSessionKnown, setMessages]); // The M9 modal UI and the admin subsystem (design/10) are two separate shells. - // `/admin` is reached via `#/admin`; every admin module is a hash segment. + // 面板由安全入口路径进入(见上方 route 注释),每个模块是前缀下的一段路径。 if (route.admin) { return ; } @@ -144,7 +152,7 @@ export default function App(): React.ReactNode { // No credential: never render the chat shell (it would silently show an // "未登录" shell with no way in). Show the user-side login card instead; its // onSuccess reloads the page so connectWs + session/persona effects re-run - // with the fresh token. Admin shell keeps its own LoginGate (#/admin). + // with the fresh token. Admin shell keeps its own LoginGate. if (!getAuthToken()) { return window.location.reload()} />; } diff --git a/frontend/web/src/admin/router.ts b/frontend/web/src/admin/router.ts index 78f11c5..36c684c 100644 --- a/frontend/web/src/admin/router.ts +++ b/frontend/web/src/admin/router.ts @@ -1,9 +1,14 @@ /** - * Hash-based admin routing. The M9 SPA uses a state store (useModeStore) instead - * of react-router, so the admin subsystem stays consistent: it is entered via - * `#/admin` and each module is a hash segment (`#/admin/personas`, ...). Keeps - * the build dependency-free and works behind the single-binary gateway serving - * `base: "./"`. + * 管理面板的**安全入口**路由(路径式,裁决 1.6 / 16.4.7,design/07 §0.2)。 + * + * 面板挂在 `.env` 的 `ADMIN_PATH` 前缀下(默认 `/admin`,可自定义成 + * `/xxx/admin`),深链是 `/<前缀>/
`。这个前缀**绝不硬编进用户端 + * bundle**:后端只在把**面板入口页**送达时注入 `window.__CYRENE_ADMIN_ENTRY__` + * (见 internal/services/gateway/routes.go 的 adminEntryHatch)。用户端页面 + * 没有这个全局量,因此用户端既没有管理入口链接、也解析不出面板路由。 + * + * 面板内部的模块切换走 history(pushState + popstate);hash 只留给渠道页 + * 自己的子页(admin/modules/channels/useAdapterNav.ts)。 */ export type AdminSectionId = @@ -30,6 +35,17 @@ export interface AdminRoute { section: AdminSectionId; } +/** 后端注入的面板入口信息:只有面板入口那一份 index.html 带它。 */ +export interface AdminEntry { + prefix: string; +} + +declare global { + interface Window { + __CYRENE_ADMIN_ENTRY__?: AdminEntry; + } +} + const VALID_SECTIONS: ReadonlySet = new Set([ "dashboard", "personas", @@ -50,26 +66,67 @@ const VALID_SECTIONS: ReadonlySet = new Set([ "users", ]); -/** Parse a `window.location.hash` into an admin route descriptor. */ -export function parseAdminHash(hash: string): AdminRoute { - const raw = hash.replace(/^#/, "").replace(/^\/+/, "").split("/"); - if (raw[0] !== "admin") return { admin: false, section: "dashboard" }; - const section = raw[1]; +/** 面板入口的路径前缀(去掉尾斜杠);无入口信息时返回空串。 */ +function entryBase(entry: AdminEntry | null): string { + return (entry?.prefix ?? "").replace(/\/+$/, ""); +} + +/** 读取后端注入的入口信息;用户端(无注入)返回 null。 */ +export function readAdminEntry(): AdminEntry | null { + if (typeof window === "undefined") return null; + const entry = window.__CYRENE_ADMIN_ENTRY__; + return entry && typeof entry.prefix === "string" ? entry : null; +} + +/** + * 把 pathname 解析成面板路由:前缀不匹配(或压根没有入口信息,即用户端) + * 一律 `admin: false`,因此用户端永远不会被路径名带进面板;未知的 section + * 段回落到 dashboard。 + */ +export function parseAdminRoute(pathname: string, entry: AdminEntry | null): AdminRoute { + const base = entryBase(entry); + if (!base) return { admin: false, section: "dashboard" }; + if (pathname !== base && !pathname.startsWith(`${base}/`)) { + return { admin: false, section: "dashboard" }; + } + const rest = pathname.slice(base.length).replace(/^\/+/, "").split("/")[0] ?? ""; return { admin: true, - section: VALID_SECTIONS.has(section) ? (section as AdminSectionId) : "dashboard", + section: VALID_SECTIONS.has(rest) ? (rest as AdminSectionId) : "dashboard", }; } -/** The hash that selects the admin root (dashboard). */ -export const ADMIN_ROOT_HASH = "#/admin"; - -/** Build a hash for a given admin section. */ -export function adminSectionHash(section: AdminSectionId): string { - return section === "dashboard" ? ADMIN_ROOT_HASH : `#/admin/${section}`; +/** 某个 section 的绝对路径(面板根 = 前缀本身)。 */ +export function adminSectionPath(section: AdminSectionId, entry: AdminEntry | null): string { + const base = entryBase(entry); + if (!base) return "/"; + return section === "dashboard" ? base : `${base}/${section}`; } -/** Navigate to an admin section by setting the location hash. */ +const listeners = new Set<() => void>(); + +/** 订阅面板路由变化(浏览器前进/后退 + 本模块的 pushState)。 */ +export function subscribeAdminRoute(onChange: () => void): () => void { + listeners.add(onChange); + window.addEventListener("popstate", onChange); + return () => { + listeners.delete(onChange); + window.removeEventListener("popstate", onChange); + }; +} + +/** 用 history 跳转并通知订阅者(不刷新页面、不靠 hash)。 */ +function navigateTo(path: string): void { + if (window.location.pathname !== path) window.history.pushState({}, "", path); + for (const fn of listeners) fn(); +} + +/** 跳转到面板某模块(面板侧栏/仪表盘下钻用)。 */ export function navigateAdmin(section: AdminSectionId): void { - window.location.hash = adminSectionHash(section); + navigateTo(adminSectionPath(section, readAdminEntry())); +} + +/** 离开面板回到用户端根路径(面板外壳的「返回用户端」用它)。 */ +export function navigateUserShell(): void { + navigateTo("/"); } diff --git a/internal/config/config.go b/internal/config/config.go index d7ce550..2962d03 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,13 +12,27 @@ import ( "reflect" "strings" "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/platform" ) +// DataDirPlatform 是 DATA_DIR 的保留字哨兵:显式配置它就走平台标准数据目录 +// (design/13 §2,裁决 12.4.2 + 15.4.1);未配置时仍是标签里的默认 `./data`。 +const DataDirPlatform = "platform" + +// DefaultAdminPath 是 ADMIN_PATH 的默认值:面板挂在 /admin +// (design/07 §0.2,裁决 1.6 / 16.4.7)。 +const DefaultAdminPath = "admin" + // Config is the root configuration tree. type Config struct { - Env string `env:"ENV" default:"development"` - LogLevel string `env:"LOG_LEVEL" default:"debug"` - Timezone string `env:"TZ" default:"Asia/Shanghai"` + Env string `env:"ENV" default:"development"` + LogLevel string `env:"LOG_LEVEL" default:"debug"` + Timezone string `env:"TZ" default:"Asia/Shanghai"` + // DataDir 是数据目录。两种取法都实现(design/13 §2,裁决 12.4.2/15.4.1): + // 未配置 → `./data`(标签默认,也是历史默认);显式配置保留字哨兵 + // DataDirPlatform(`DATA_DIR=platform`)→ 平台标准数据目录,见 + // resolveConfiguredDataDir。 DataDir string `env:"DATA_DIR" default:"./data"` AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"` @@ -43,6 +57,35 @@ type Config struct { type ServerConfig struct { Host string `env:"HOST" default:"0.0.0.0"` Port int `env:"PORT" default:"8080"` + + // AdminPath 是管理面板前端路由的**安全入口**路径前缀(裁决 1.6 / 16.4.7, + // design/07 §0.2):形如 `admin`(默认,面板挂在 `/admin`)或用户自定义的 + // `xxx/admin`(面板挂在 `/xxx/admin`)。网关按它挂载面板入口,猜错的前缀 + // 直接 404;该值只注入到面板入口页,用户端 bundle 不含它。 + // REST 路径**不随它变化**——`/api/v1/admin/*` 照旧,安全靠 JWT + 角色门 + // (design/07 §0.1「API 不藏」)。 + AdminPath string `env:"ADMIN_PATH" default:"admin"` +} + +// AdminPathPrefix 返回管理面板前端路由的绝对路径前缀(默认 `/admin`)。 +// 容忍配置里的首尾斜杠与空白;值非法(空、含 `.`/`..` 段,或含 `?`/`#` +// 这类会截断路径的字符)时回退到默认前缀,宁可挂回默认也不挂到不可预测的 +// 路径上。 +func (s ServerConfig) AdminPathPrefix() string { + raw := strings.Trim(strings.TrimSpace(s.AdminPath), "/") + if raw == "" { + return "/" + DefaultAdminPath + } + segs := strings.Split(raw, "/") + clean := make([]string, 0, len(segs)) + for _, seg := range segs { + seg = strings.TrimSpace(seg) + if seg == "" || seg == "." || seg == ".." || strings.ContainsAny(seg, "?#") { + return "/" + DefaultAdminPath + } + clean = append(clean, seg) + } + return "/" + strings.Join(clean, "/") } // AuthConfig holds authentication and admin credential settings. @@ -403,6 +446,7 @@ func Load(envPath string) (*Config, error) { if err := decodeValue(reflect.ValueOf(cfg), fromEnv); err != nil { return nil, err } + cfg.DataDir = resolveConfiguredDataDir(cfg.DataDir) if errs := validateStruct(reflect.ValueOf(cfg).Elem()); len(errs) > 0 { parts := make([]string, 0, len(errs)) @@ -424,3 +468,18 @@ func MustLoad(envPath string) *Config { } return cfg } + +// resolveConfiguredDataDir 在读取阶段把 DATA_DIR 的哨兵值换成平台标准数据 +// 目录(design/13 §2,裁决 12.4.2 + 15.4.1),使「显式 `./data`」与「平台 +// 标准路径」两条路都真实可达: +// - 未配置 → 标签默认 `./data`(调用方拿到的就是它,行为与历史一致); +// - `DATA_DIR=platform`(大小写不敏感)→ platform.DefaultDataDir(); +// - 其余值 → 原样保留。 +// +// 空串保持原样:它由组合根的兜底分支处理,语义不变。 +func resolveConfiguredDataDir(raw string) string { + if strings.EqualFold(strings.TrimSpace(raw), DataDirPlatform) { + return platform.DefaultDataDir() + } + return raw +} diff --git a/internal/config/data_dir_admin_path_test.go b/internal/config/data_dir_admin_path_test.go new file mode 100644 index 0000000..c879cb0 --- /dev/null +++ b/internal/config/data_dir_admin_path_test.go @@ -0,0 +1,72 @@ +package config + +import ( + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/platform" +) + +// TestLoad_DataDirPlatformSentinel 覆盖数据目录的两种取法(design/13 §2, +// 裁决 12.4.2 + 15.4.1):未配置走默认 `./data`;显式哨兵 `platform` 才走 +// 平台标准数据目录;显式路径原样保留。 +func TestLoad_DataDirPlatformSentinel(t *testing.T) { + t.Setenv("JWT_SECRET", strings.Repeat("s", 40)) + unsetEnvForTest(t, "DATA_DIR") + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.DataDir != "./data" { + t.Errorf("DataDir = %q, want ./data (unset keeps the historic default)", cfg.DataDir) + } + + t.Setenv("DATA_DIR", "platform") + cfg, err = Load("") + if err != nil { + t.Fatalf("Load() with sentinel error: %v", err) + } + if want := platform.DefaultDataDir(); cfg.DataDir != want { + t.Errorf("DataDir = %q, want the platform standard dir %q", cfg.DataDir, want) + } + + // 显式路径(含显式 ./data)不被改写。 + for _, p := range []string{"./data", "/var/lib/cyrene"} { + t.Setenv("DATA_DIR", p) + cfg, err = Load("") + if err != nil { + t.Fatalf("Load() with DATA_DIR=%q error: %v", p, err) + } + if cfg.DataDir != p { + t.Errorf("DataDir = %q, want %q verbatim", cfg.DataDir, p) + } + } +} + +// TestAdminPathPrefix 覆盖安全入口前缀的归一化(裁决 1.6 / 16.4.7, +// design/07 §0.2):默认 `admin` → `/admin`,自定义 `xxx/admin` → +// `/xxx/admin`(即 `host/xxx/admin` 形态),非法值一律挂回默认。 +func TestAdminPathPrefix(t *testing.T) { + want := map[string]string{ + "": "/admin", + "admin": "/admin", + "/admin": "/admin", + "/admin/": "/admin", + "xxx/admin": "/xxx/admin", + "/xxx/admin/": "/xxx/admin", + " a / b ": "/a/b", + "../etc": "/admin", + "a/../b": "/admin", + "a//b": "/admin", + "a?b": "/admin", + "a#b": "/admin", + " ": "/admin", + } + for in, exp := range want { + cfg := ServerConfig{AdminPath: in} + if got := cfg.AdminPathPrefix(); got != exp { + t.Errorf("AdminPath %q → %q, want %q", in, got, exp) + } + } +} diff --git a/internal/services/gateway/admin_entry_test.go b/internal/services/gateway/admin_entry_test.go new file mode 100644 index 0000000..e072fa3 --- /dev/null +++ b/internal/services/gateway/admin_entry_test.go @@ -0,0 +1,137 @@ +package gateway + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" +) + +// adminEntryTestIndex 是最小的 index.html:注入点 `` 与 +// 站点根锚点都由 handler 追加,因此这里只保证两者都存在。 +const adminEntryTestIndex = `
` + +// adminEntryEnv 构造一个挂了前端、并按 adminPath 配置安全入口的网关。 +func adminEntryEnv(t *testing.T, adminPath string) *httptest.Server { + t.Helper() + cfg := cfgDefault() + cfg.Server.AdminPath = adminPath + front := fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte(adminEntryTestIndex)}, + "assets/app.js": &fstest.MapFile{Data: []byte("var x = 1;")}, + } + _, ts, _ := buildEnv(t, cfg, Deps{Frontend: front}) + return ts +} + +// TestAdminEntry_DefaultPrefix 守住默认前缀下的安全入口(裁决 1.6/16.4.7): +// `/admin` 与深链直达面板入口页(带注入),不再回落用户端聊天壳;用户端 +// 路径与真实静态资源不受影响。 +func TestAdminEntry_DefaultPrefix(t *testing.T) { + t.Parallel() + ts := adminEntryEnv(t, "") + + resp, body := doReq(t, http.MethodGet, ts.URL+"/admin", nil, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /admin status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + if !strings.Contains(string(body), `window.__CYRENE_ADMIN_ENTRY__={"prefix":"/admin"}`) { + t.Fatalf("GET /admin missing injected entry info: %s", body) + } + if !strings.Contains(string(body), ``) { + t.Fatalf("GET /admin missing root base href: %s", body) + } + if got := resp.Header.Get("Cache-Control"); got != "no-store" { + t.Fatalf("GET /admin Cache-Control = %q, want no-store", got) + } + + resp, body = doReq(t, http.MethodGet, ts.URL+"/admin/personas", nil, "") + if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"prefix":"/admin"`) { + t.Fatalf("GET /admin/personas = %d body=%s, want the injected admin entry", resp.StatusCode, body) + } + + // 真实静态资源仍按文件送出(不注入)。 + resp, body = doReq(t, http.MethodGet, ts.URL+"/assets/app.js", nil, "") + if resp.StatusCode != http.StatusOK || string(body) != "var x = 1;" { + t.Fatalf("GET /assets/app.js = %d body=%q, want the raw asset", resp.StatusCode, body) + } + + // 用户端回落路径不带入口信息(用户端 bundle 与页面都不该出现面板前缀)。 + resp, body = doReq(t, http.MethodGet, ts.URL+"/chat/somewhere", nil, "") + if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "root") { + t.Fatalf("GET /chat/somewhere = %d body=%s, want the user shell", resp.StatusCode, body) + } + if strings.Contains(string(body), "__CYRENE_ADMIN_ENTRY__") { + t.Fatalf("user shell leaked the admin entry info: %s", body) + } +} + +// TestAdminEntry_CustomPrefix 守住自定义前缀(`host/xxx/admin` 形态):正确 +// 前缀直达,猜错前缀(含默认 /admin)一律 404,绝不回落用户端聊天壳。 +func TestAdminEntry_CustomPrefix(t *testing.T) { + t.Parallel() + ts := adminEntryEnv(t, "xxx/admin") + + resp, body := doReq(t, http.MethodGet, ts.URL+"/xxx/admin", nil, "") + if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"prefix":"/xxx/admin"`) { + t.Fatalf("GET /xxx/admin = %d body=%s, want the injected custom prefix", resp.StatusCode, body) + } + + for _, p := range []string{"/admin", "/admin/personas", "/other/admin", "/admin/"} { + resp, body = doReq(t, http.MethodGet, ts.URL+p, nil, "") + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("GET %s = %d body=%s, want 404 (wrong prefix must not fall back)", p, resp.StatusCode, body) + } + if strings.Contains(string(body), "__CYRENE_ADMIN_ENTRY__") { + t.Fatalf("GET %s leaked the admin entry info", p) + } + } + + // 用户端路径依旧正常。 + resp, body = doReq(t, http.MethodGet, ts.URL+"/chat", nil, "") + if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "root") { + t.Fatalf("GET /chat = %d body=%s, want the user shell", resp.StatusCode, body) + } +} + +// TestAdminEntry_APIRouteUntouched 守住「API 不藏」:前缀即便被配成含 api 段 +// 的值,也不得截住 REST 路由(design/07 §0.1)。 +func TestAdminEntry_APIRouteUntouched(t *testing.T) { + t.Parallel() + ts := adminEntryEnv(t, "api/v1/admin") + + resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/health", nil, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /api/v1/health = %d body=%s, want the health payload", resp.StatusCode, body) + } + if strings.Contains(string(body), "__CYRENE_ADMIN_ENTRY__") { + t.Fatalf("API route was served the admin entry page: %s", body) + } +} + +// TestAdminEntry_PrefixOverAssetDir 守住前缀撞上真实资源目录的退化:把前缀 +// 配成 assets 时,真实资源必须仍由文件服务器送出,不能被面板入口页顶掉。 +func TestAdminEntry_PrefixOverAssetDir(t *testing.T) { + t.Parallel() + ts := adminEntryEnv(t, "assets") + + resp, body := doReq(t, http.MethodGet, ts.URL+"/assets/app.js", nil, "") + if resp.StatusCode != http.StatusOK || string(body) != "var x = 1;" { + t.Fatalf("GET /assets/app.js = %d body=%q, want the real asset (not the shell)", resp.StatusCode, body) + } +} + +// TestAdminEntryHTML_NoHead 守住注入的两个回退点:没有 `` 时注入到 +// 文档最前(仍先于任何资源引用),且 `` 与入口信息一并出现。 +func TestAdminEntryHTML_NoHead(t *testing.T) { + t.Parallel() + out := adminEntryHTML([]byte("x"), "/admin") + s := string(out) + if !strings.HasPrefix(s, ``) { + t.Fatalf("injection without = %q, want it prepended", s) + } + if !strings.Contains(s, `"prefix":"/admin"`) { + t.Fatalf("injection without lost the entry info: %s", s) + } +} diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go index 52615fe..49c5c41 100644 --- a/internal/services/gateway/routes.go +++ b/internal/services/gateway/routes.go @@ -1,9 +1,15 @@ package gateway import ( + "bytes" + "encoding/json" + "io/fs" "net/http" + "path" + "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // 本文件拥有 HTTP 表面:路由表(REST /api/v1、实时 @@ -252,15 +258,115 @@ func (s *Server) apiKeyTable() auth.KeyTable { // withMiddleware 把已路由的 handler 包进外层,最外层在前: // 上传能力 URL 防护、SPA 回退(当配置了前端时)、 -// panic recover、CORS 与请求日志。 +// 安全入口(管理面板前缀)、panic recover、CORS 与请求日志。 func (s *Server) withMiddleware(mux http.Handler) http.Handler { var h http.Handler = mux h = guardUploadPaths(h) if s.frontend != nil { h = s.spa(h) + // 安全入口(裁决 1.6 / 16.4.7,design/07 §0.2):管理面板前端按 .env 的 + // ADMIN_PATH 挂载(默认 /admin),猜错的前缀 404 而不回落用户端聊天壳。 + h = s.adminEntryHatch(h, s.cfg.Server.AdminPathPrefix()) } h = s.recover(h) h = s.cors(h) h = s.logging(h) return h } + +// --- 安全入口(裁决 1.6 / 16.4.7,design/07 §0.2) --------------------------- + +// adminEntryGlobal 是注入面板入口页的全局变量名:SPA 据此判定「本页由安全 +// 入口送达」并取得面板前缀。它是前缀抵达前端的唯一通道,因此该串不硬编进 +// 用户端 bundle(见 frontend/web/src/admin/router.ts)。 +const adminEntryGlobal = "__CYRENE_ADMIN_ENTRY__" + +// adminEntryHatch 把管理面板挂到配置的 ADMIN_PATH 前缀下: +// - 前缀自身与其下的深链(/<前缀>/
)送出注入了入口信息的 +// index.html,使真实路径直达面板而不回落用户端聊天壳; +// - 形如面板入口但前缀不对的探测(例如前缀被改后仍被访问的 /admin) +// 直接 404,既不回落聊天壳也不泄露正确前缀; +// - 其余路径原样交给常规 SPA / API 处理。 +func (s *Server) adminEntryHatch(next http.Handler, prefix string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + if isAPIRoute(p) || s.isWebhookRoute(p) { + next.ServeHTTP(w, r) + return + } + switch { + case p == prefix || strings.HasPrefix(p, prefix+"/"): + if s.adminEntryRealFile(p) || !s.serveAdminEntry(w, prefix) { + next.ServeHTTP(w, r) + } + case isAdminShapedPath(p): + http.NotFound(w, r) + default: + next.ServeHTTP(w, r) + } + }) +} + +// adminEntryRealFile 报告 path 是否命中 dist 里的真实静态文件:前缀若恰好 +// 撞上资源目录/文件名(例如把 ADMIN_PATH 配成 assets),文件服务器照常送 +// 文件,绝不让面板入口页顶掉 SPA 的资源。 +func (s *Server) adminEntryRealFile(p string) bool { + rel := strings.TrimPrefix(path.Clean(p), "/") + if rel == "" { + return false + } + info, err := fs.Stat(s.frontend, rel) + return err == nil && !info.IsDir() +} + +// serveAdminEntry 送出管理面板入口页。返回 false 表示 index.html 读不出来 +// (例如外置 dist 尚未构建),交由常规 SPA 处理。 +func (s *Server) serveAdminEntry(w http.ResponseWriter, prefix string) bool { + raw, err := fs.ReadFile(s.frontend, "index.html") + if err != nil { + return false + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // 入口页按请求注入(前缀 / 站点根锚点),不能缓存。 + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(adminEntryHTML(raw, prefix)) + return true +} + +// adminEntryHTML 在 `` 之后注入入口信息与 ``: +// - 入口信息是内联经典脚本,必先于 Vite 的 deferred module 执行,前端启动 +// 时即可读到面板前缀; +// - `` 把相对资源锚回站点根:面板入口可能是多段路径 +// (/xxx/admin)或深链(/admin/personas),否则 Vite 产出的 `./assets/...` +// 会解析到错误目录(单二进制始终挂在站点根,design/13 §0)。 +// +// 找不到 `` 时注入到文档最前,同样先于任何资源引用。 +func adminEntryHTML(indexHTML []byte, prefix string) []byte { + payload, err := json.Marshal(map[string]string{"prefix": prefix}) + if err != nil { + // 字符串映射不会让 json.Marshal 失败;真出错时只保住 。 + payload = []byte(`{}`) + } + snippet := []byte(``) + out := make([]byte, 0, len(indexHTML)+len(snippet)) + if i := bytes.Index(indexHTML, []byte("")); i >= 0 { + at := i + len("") + out = append(out, indexHTML[:at]...) + out = append(out, snippet...) + return append(out, indexHTML[at:]...) + } + out = append(out, snippet...) + return append(out, indexHTML...) +} + +// isAdminShapedPath 报告 path 是否形如一次管理面板入口尝试(路径里出现 +// `admin` 段,如 /admin、/admin/x、/foo/admin):前缀被自定义后,这类猜测 +// 一律 404,避免回落用户端聊天壳把探测结果变成「面板不存在」以外的信息。 +func isAdminShapedPath(p string) bool { + for _, seg := range strings.Split(p, "/") { + if seg == config.DefaultAdminPath { + return true + } + } + return false +}