feat(config,gateway,web): 路径式安全入口 ADMIN_PATH + 数据目录 platform 哨兵(裁决 1.6/16.4.7/12.4.2 + 1.5/12.4.3 核对)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-10-04 14:00:25 +08:00
1 parent 9253fef92a
commit a398dc9b92
7 files changed
+481 -33

No files matched your search

+76 -19
View File
@@ -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("/");
}