feat(config,gateway,web): 路径式安全入口 ADMIN_PATH + 数据目录 platform 哨兵(裁决 1.6/16.4.7/12.4.2 + 1.5/12.4.3 核对)
- 1.6 + 16.4.7:安全入口=路径式 `/<ADMIN_PATH>`(env `ADMIN_PATH`,默认 `admin`;`xxx/admin` 亦可,
非法值回退 `/admin`)。命中前缀 → 送注入了 `<base href="/">` + `window.__CYRENE_ADMIN_ENTRY__` 的面板入口页
(深链直达);**形如面板入口但前缀不符 → 404**(不回落用户端聊天壳);`/api/*`、`/ws`、webhook 先放行
⇒ **API 不藏**。前端路由改**路径式**(`router.ts` 只读注入,无硬编兜底;移除 hash 入口)
- 1.5 / 12.4.3 核对:暴露面默认 `0.0.0.0` 与裁决一致,无需改代码(仅补注释)
- 12.4.2:`DATA_DIR` 支持 `platform` 哨兵(大小写不敏感)→ 平台标准目录;默认 `./data` 与显式空值行为不变
(显式空值保持原兜底,避免正在运行实例突然换库)
- 复核:build/vet 绿 + config/gateway 包测试绿 + linux/darwin 交叉编译绿;新增 7 条用例,
并用真实构建产物(`webui.Assets()`)跑通「浏览器直开 /admin 能启动面板」(`<base>` 必须在资源引用之前)
- 变异 2 条(关掉错前缀 404 / 前端默认前缀兜底)已还原
- ⚠️ 运行时序:前端已生效而后端 hatch 未生效期间**面板打不开** ⇒ 需紧接一次「build 到位 + restart」
This commit is contained in:
1 parent
9253fef92a
commit
a398dc9b92
7 files changed
+481
-33
No files matched your search
@@ -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 <AdminApp section={route.section} />;
|
||||
}
|
||||
@@ -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 <Login onSuccess={() => window.location.reload()} />;
|
||||
}
|
||||
|
||||
@@ -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`),深链是 `/<前缀>/<section>`。这个前缀**绝不硬编进用户端
|
||||
* 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<string> = new Set([
|
||||
"dashboard",
|
||||
"personas",
|
||||
@@ -50,26 +66,67 @@ const VALID_SECTIONS: ReadonlySet<string> = 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("/");
|
||||
}
|
||||
Reference in new issue
Block a user