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
+10
-1
@@ -10,12 +10,21 @@ ENV=development
|
|||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
TZ=Asia/Shanghai
|
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
|
DATA_DIR=./data
|
||||||
|
|
||||||
# ---- 服务器(网关,唯一对外端口) ----
|
# ---- 服务器(网关,唯一对外端口) ----
|
||||||
|
# HOST 默认 0.0.0.0,暴露面由 .env 决定(本机自托管要收紧就改 127.0.0.1)。
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=8080
|
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。
|
# JWT_SECRET 必填,长度 >= 32;缺失或过短会在启动时 panic。
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { TopBar } from "./components/TopBar";
|
import { TopBar } from "./components/TopBar";
|
||||||
import { Sidebar, MobileNav } from "./components/Nav";
|
import { Sidebar, MobileNav } from "./components/Nav";
|
||||||
import { ModeRouter } from "./modes/ModeRouter";
|
import { ModeRouter } from "./modes/ModeRouter";
|
||||||
import { AdminApp } from "./admin/AdminApp";
|
import { AdminApp } from "./admin/AdminApp";
|
||||||
import { Login } from "./components/Login";
|
import { Login } from "./components/Login";
|
||||||
import { parseAdminHash } from "./admin/router";
|
import { parseAdminRoute, readAdminEntry, subscribeAdminRoute } from "./admin/router";
|
||||||
import { connectWs, disconnectWs } from "./lib/ws";
|
import { connectWs, disconnectWs } from "./lib/ws";
|
||||||
import { markSessionOpenedRead } from "./lib/sessionRead";
|
import { markSessionOpenedRead } from "./lib/sessionRead";
|
||||||
import { initTheme } from "./lib/theme";
|
import { initTheme } from "./lib/theme";
|
||||||
@@ -21,7 +21,15 @@ import { personaMoodSeed } from "./store/personaSelectors";
|
|||||||
export default function App(): React.ReactNode {
|
export default function App(): React.ReactNode {
|
||||||
// Apply the persisted (dark-first) theme and open the realtime channel only
|
// 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.
|
// 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 [menuOpen, setMenuOpen] = useState(false);
|
||||||
const setPersonas = useAppStore((s) => s.setPersonas);
|
const setPersonas = useAppStore((s) => s.setPersonas);
|
||||||
const seedMoods = useAppStore((s) => s.seedMoods);
|
const seedMoods = useAppStore((s) => s.seedMoods);
|
||||||
@@ -29,13 +37,13 @@ export default function App(): React.ReactNode {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initTheme();
|
initTheme();
|
||||||
if (getAuthToken()) connectWs();
|
if (getAuthToken()) connectWs();
|
||||||
const onHash = () => setRoute(parseAdminHash(window.location.hash));
|
const onRoute = () => setRoute(parseAdminRoute(window.location.pathname, entry));
|
||||||
window.addEventListener("hashchange", onHash);
|
const unsubscribe = subscribeAdminRoute(onRoute);
|
||||||
return () => {
|
return () => {
|
||||||
disconnectWs();
|
disconnectWs();
|
||||||
window.removeEventListener("hashchange", onHash);
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [entry]);
|
||||||
|
|
||||||
// Load the role catalog (design/22 §4.6). The rendered role is no longer one
|
// 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
|
// 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]);
|
}, [activeSessionId, activeSessionKnown, setMessages]);
|
||||||
|
|
||||||
// The M9 modal UI and the admin subsystem (design/10) are two separate shells.
|
// 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) {
|
if (route.admin) {
|
||||||
return <AdminApp section={route.section} />;
|
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
|
// 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
|
// "未登录" shell with no way in). Show the user-side login card instead; its
|
||||||
// onSuccess reloads the page so connectWs + session/persona effects re-run
|
// 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()) {
|
if (!getAuthToken()) {
|
||||||
return <Login onSuccess={() => window.location.reload()} />;
|
return <Login onSuccess={() => window.location.reload()} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Hash-based admin routing. The M9 SPA uses a state store (useModeStore) instead
|
* 管理面板的**安全入口**路由(路径式,裁决 1.6 / 16.4.7,design/07 §0.2)。
|
||||||
* of react-router, so the admin subsystem stays consistent: it is entered via
|
*
|
||||||
* `#/admin` and each module is a hash segment (`#/admin/personas`, ...). Keeps
|
* 面板挂在 `.env` 的 `ADMIN_PATH` 前缀下(默认 `/admin`,可自定义成
|
||||||
* the build dependency-free and works behind the single-binary gateway serving
|
* `/xxx/admin`),深链是 `/<前缀>/<section>`。这个前缀**绝不硬编进用户端
|
||||||
* `base: "./"`.
|
* bundle**:后端只在把**面板入口页**送达时注入 `window.__CYRENE_ADMIN_ENTRY__`
|
||||||
|
* (见 internal/services/gateway/routes.go 的 adminEntryHatch)。用户端页面
|
||||||
|
* 没有这个全局量,因此用户端既没有管理入口链接、也解析不出面板路由。
|
||||||
|
*
|
||||||
|
* 面板内部的模块切换走 history(pushState + popstate);hash 只留给渠道页
|
||||||
|
* 自己的子页(admin/modules/channels/useAdapterNav.ts)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type AdminSectionId =
|
export type AdminSectionId =
|
||||||
@@ -30,6 +35,17 @@ export interface AdminRoute {
|
|||||||
section: AdminSectionId;
|
section: AdminSectionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 后端注入的面板入口信息:只有面板入口那一份 index.html 带它。 */
|
||||||
|
export interface AdminEntry {
|
||||||
|
prefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__CYRENE_ADMIN_ENTRY__?: AdminEntry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const VALID_SECTIONS: ReadonlySet<string> = new Set([
|
const VALID_SECTIONS: ReadonlySet<string> = new Set([
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"personas",
|
"personas",
|
||||||
@@ -50,26 +66,67 @@ const VALID_SECTIONS: ReadonlySet<string> = new Set([
|
|||||||
"users",
|
"users",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Parse a `window.location.hash` into an admin route descriptor. */
|
/** 面板入口的路径前缀(去掉尾斜杠);无入口信息时返回空串。 */
|
||||||
export function parseAdminHash(hash: string): AdminRoute {
|
function entryBase(entry: AdminEntry | null): string {
|
||||||
const raw = hash.replace(/^#/, "").replace(/^\/+/, "").split("/");
|
return (entry?.prefix ?? "").replace(/\/+$/, "");
|
||||||
if (raw[0] !== "admin") return { admin: false, section: "dashboard" };
|
}
|
||||||
const section = raw[1];
|
|
||||||
|
/** 读取后端注入的入口信息;用户端(无注入)返回 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 {
|
return {
|
||||||
admin: true,
|
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). */
|
/** 某个 section 的绝对路径(面板根 = 前缀本身)。 */
|
||||||
export const ADMIN_ROOT_HASH = "#/admin";
|
export function adminSectionPath(section: AdminSectionId, entry: AdminEntry | null): string {
|
||||||
|
const base = entryBase(entry);
|
||||||
/** Build a hash for a given admin section. */
|
if (!base) return "/";
|
||||||
export function adminSectionHash(section: AdminSectionId): string {
|
return section === "dashboard" ? base : `${base}/${section}`;
|
||||||
return section === "dashboard" ? ADMIN_ROOT_HASH : `#/admin/${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 {
|
export function navigateAdmin(section: AdminSectionId): void {
|
||||||
window.location.hash = adminSectionHash(section);
|
navigateTo(adminSectionPath(section, readAdminEntry()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 离开面板回到用户端根路径(面板外壳的「返回用户端」用它)。 */
|
||||||
|
export function navigateUserShell(): void {
|
||||||
|
navigateTo("/");
|
||||||
}
|
}
|
||||||
@@ -12,13 +12,27 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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.
|
// Config is the root configuration tree.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Env string `env:"ENV" default:"development"`
|
Env string `env:"ENV" default:"development"`
|
||||||
LogLevel string `env:"LOG_LEVEL" default:"debug"`
|
LogLevel string `env:"LOG_LEVEL" default:"debug"`
|
||||||
Timezone string `env:"TZ" default:"Asia/Shanghai"`
|
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"`
|
DataDir string `env:"DATA_DIR" default:"./data"`
|
||||||
AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"`
|
AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"`
|
||||||
|
|
||||||
@@ -43,6 +57,35 @@ type Config struct {
|
|||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Host string `env:"HOST" default:"0.0.0.0"`
|
Host string `env:"HOST" default:"0.0.0.0"`
|
||||||
Port int `env:"PORT" default:"8080"`
|
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.
|
// 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 {
|
if err := decodeValue(reflect.ValueOf(cfg), fromEnv); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
cfg.DataDir = resolveConfiguredDataDir(cfg.DataDir)
|
||||||
|
|
||||||
if errs := validateStruct(reflect.ValueOf(cfg).Elem()); len(errs) > 0 {
|
if errs := validateStruct(reflect.ValueOf(cfg).Elem()); len(errs) > 0 {
|
||||||
parts := make([]string, 0, len(errs))
|
parts := make([]string, 0, len(errs))
|
||||||
@@ -424,3 +468,18 @@ func MustLoad(envPath string) *Config {
|
|||||||
}
|
}
|
||||||
return cfg
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adminEntryTestIndex 是最小的 index.html:注入点 `<head>` 与
|
||||||
|
// 站点根锚点都由 handler 追加,因此这里只保证两者都存在。
|
||||||
|
const adminEntryTestIndex = `<html><head><meta charset="utf-8"></head><body><div id="root"></div></body></html>`
|
||||||
|
|
||||||
|
// 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), `<base href="/">`) {
|
||||||
|
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 守住注入的两个回退点:没有 `<head>` 时注入到
|
||||||
|
// 文档最前(仍先于任何资源引用),且 `<base>` 与入口信息一并出现。
|
||||||
|
func TestAdminEntryHTML_NoHead(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
out := adminEntryHTML([]byte("<html><body>x</body></html>"), "/admin")
|
||||||
|
s := string(out)
|
||||||
|
if !strings.HasPrefix(s, `<base href="/">`) {
|
||||||
|
t.Fatalf("injection without <head> = %q, want it prepended", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, `"prefix":"/admin"`) {
|
||||||
|
t.Fatalf("injection without <head> lost the entry info: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 本文件拥有 HTTP 表面:路由表(REST /api/v1、实时
|
// 本文件拥有 HTTP 表面:路由表(REST /api/v1、实时
|
||||||
@@ -252,15 +258,115 @@ func (s *Server) apiKeyTable() auth.KeyTable {
|
|||||||
|
|
||||||
// withMiddleware 把已路由的 handler 包进外层,最外层在前:
|
// withMiddleware 把已路由的 handler 包进外层,最外层在前:
|
||||||
// 上传能力 URL 防护、SPA 回退(当配置了前端时)、
|
// 上传能力 URL 防护、SPA 回退(当配置了前端时)、
|
||||||
// panic recover、CORS 与请求日志。
|
// 安全入口(管理面板前缀)、panic recover、CORS 与请求日志。
|
||||||
func (s *Server) withMiddleware(mux http.Handler) http.Handler {
|
func (s *Server) withMiddleware(mux http.Handler) http.Handler {
|
||||||
var h http.Handler = mux
|
var h http.Handler = mux
|
||||||
h = guardUploadPaths(h)
|
h = guardUploadPaths(h)
|
||||||
if s.frontend != nil {
|
if s.frontend != nil {
|
||||||
h = s.spa(h)
|
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.recover(h)
|
||||||
h = s.cors(h)
|
h = s.cors(h)
|
||||||
h = s.logging(h)
|
h = s.logging(h)
|
||||||
return 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 前缀下:
|
||||||
|
// - 前缀自身与其下的深链(/<前缀>/<section>)送出注入了入口信息的
|
||||||
|
// 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 在 `<head>` 之后注入入口信息与 `<base href="/">`:
|
||||||
|
// - 入口信息是内联经典脚本,必先于 Vite 的 deferred module 执行,前端启动
|
||||||
|
// 时即可读到面板前缀;
|
||||||
|
// - `<base href="/">` 把相对资源锚回站点根:面板入口可能是多段路径
|
||||||
|
// (/xxx/admin)或深链(/admin/personas),否则 Vite 产出的 `./assets/...`
|
||||||
|
// 会解析到错误目录(单二进制始终挂在站点根,design/13 §0)。
|
||||||
|
//
|
||||||
|
// 找不到 `<head>` 时注入到文档最前,同样先于任何资源引用。
|
||||||
|
func adminEntryHTML(indexHTML []byte, prefix string) []byte {
|
||||||
|
payload, err := json.Marshal(map[string]string{"prefix": prefix})
|
||||||
|
if err != nil {
|
||||||
|
// 字符串映射不会让 json.Marshal 失败;真出错时只保住 <base>。
|
||||||
|
payload = []byte(`{}`)
|
||||||
|
}
|
||||||
|
snippet := []byte(`<base href="/"><script>window.` + adminEntryGlobal + "=" + string(payload) + `;</script>`)
|
||||||
|
out := make([]byte, 0, len(indexHTML)+len(snippet))
|
||||||
|
if i := bytes.Index(indexHTML, []byte("<head>")); i >= 0 {
|
||||||
|
at := i + len("<head>")
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user