From 38df493d891cc36baeab92daa3b4251fa38bd3de Mon Sep 17 00:00:00 2001 From: tyanxie <494966054@qq.com> Date: Thu, 20 Aug 2026 00:14:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(hub):=20=E6=94=AF=E6=8C=81=E5=AD=90?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E9=83=A8=E7=BD=B2=EF=BC=88--base-path?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vite 构建使用 base: './' 相对路径,产物路径无关 - Hub 运行时注入 window.__BASE_PATH__ 到 index.html - 前端通过 basePath 工具模块统一处理 Router basename、API/WS 前缀 - CLI 新增 --base-path 参数,写入 hub.json,restart 自动继承 - 优先级:PAIMON_BASE_PATH 环境变量 > --base-path 参数 > hub.json 继承 > 默认 / - 抽取 withBasePath() 工具函数统一路径拼接 - favicon 改为相对路径以兼容子路径部署 - 新增 env.d.ts 声明 Window 全局类型 - daemon.ts 抽取 normalizeBasePath 统一规范化逻辑 --- AGENTS.md | 1 + src/cli/commands/hub/index.ts | 16 +- src/cli/commands/hub/restart.ts | 4 +- src/cli/commands/hub/start.ts | 3 +- src/cli/commands/hub/status.ts | 4 +- src/cli/daemon.ts | 13 +- src/hub/index.ts | 348 ++++++++++++++++----------- src/protocol/types.ts | 2 + src/utils/basePath.ts | 28 +++ src/web/index.html | 16 +- src/web/src/components/LoginPage.tsx | 3 +- src/web/src/env.d.ts | 5 + src/web/src/hooks/useLogoSrc.ts | 5 +- src/web/src/main.tsx | 3 +- src/web/src/stores/useWebSocket.ts | 5 +- src/web/src/utils/authFetch.ts | 6 + src/web/src/utils/basePath.ts | 14 ++ vite.config.ts | 1 + 18 files changed, 317 insertions(+), 160 deletions(-) create mode 100644 src/utils/basePath.ts create mode 100644 src/web/src/env.d.ts create mode 100644 src/web/src/utils/basePath.ts diff --git a/AGENTS.md b/AGENTS.md index 210cb0e..7e3c7f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ bin/ - **Hub→Edge request-response 通用模式** — `src/hub/pending.ts` 提供 `PendingRequests` 泛型工具,基于 token 匹配 WS 异步请求与响应。spawn 和目录浏览(browse)均使用此模式 - **目录浏览 API** — `GET /api/edges/:edgeId/browse?path=xxx`,Hub 转发给 Edge 执行 readdir。Edge 解析 parent/prefix(路径以 `/` 结尾列全部,否则以末段为前缀过滤),仅返回子目录,默认隐藏 dotfiles(前缀以 `.` 开头时显示),最多 200 条(截断时标记 `truncated`)。前端据此实现类 VS Code 的路径补全选择器 - **bind 地址与安全** — Hub 和 Edge 默认 bind `127.0.0.1`(仅本机)。`--host` 可指定;非 loopback 时 CLI 和日志都会警告 +- **子路径部署(Base Path)** — Hub 支持通过 `--base-path /paimon` 部署到反向代理子路径下。优先级:`PAIMON_BASE_PATH` 环境变量 > `--base-path` 参数 > hub.json 继承 > 默认 `/`。Vite 构建使用 `base: './'`(相对路径),产物路径无关;Hub 运行时在返回 index.html 时动态注入 `` 和 `window.__BASE_PATH__`,前端通过 `src/web/src/utils/basePath.ts` 读取并用于 React Router basename、API/WS 路径前缀。Hub 自身会 strip basePath 前缀(路径以 basePath 开头则移除,否则保持原样),因此无需依赖反向代理 strip,直接访问和经 nginx 转发均可工作 - **Access Token 认证** — Hub 启动时生成或接收 access token(优先级:`PAIMON_ACCESS_TOKEN` 环境变量 > `--token` 参数 > 自动生成),写入 `hub.json`。Edge/Browser/HTTP API 连接 Hub 时必须携带 token(WS 通过 `?token=xxx`,HTTP 通过 `Authorization: Bearer xxx`)。`/api/health` 不需认证。`PAIMON_AUTH_DISABLED=1` 可关闭认证(仅开发调试) - **Token 生命周期** — token 存储于 `hub.json`,随 `paimon hub stop` 删除而失效。`paimon hub restart` 默认继承旧 token(显示来源为 `inherited`)。Pi Extension → Edge 不需认证(Edge 仅 bind loopback,天然同机信任) - **Edge token 来源** — 优先级:`PAIMON_ACCESS_TOKEN` 环境变量 > `--token` 参数 > 同机 hub.json fallback diff --git a/src/cli/commands/hub/index.ts b/src/cli/commands/hub/index.ts index 6fdd07b..acb42b3 100644 --- a/src/cli/commands/hub/index.ts +++ b/src/cli/commands/hub/index.ts @@ -12,9 +12,18 @@ export function registerHubCommand(program: Command): void { .option("--port ", "port number", String(DEFAULTS.PORT)) .option("--host ", "bind address", DEFAULTS.HOST) .option("--token ", "access token (default: auto-generate)") + .option( + "--base-path ", + "base path for sub-path deployment (e.g. /paimon)", + ) .action(async (opts) => { const { handleStart } = await import("./start"); - await handleStart(parseInt(opts.port), opts.host, opts.token); + await handleStart( + parseInt(opts.port), + opts.host, + opts.token, + opts.basePath, + ); }); hub @@ -31,12 +40,17 @@ export function registerHubCommand(program: Command): void { .option("--port ", "port number") .option("--host ", "bind address") .option("--token ", "access token (default: auto-generate)") + .option( + "--base-path ", + "base path for sub-path deployment (e.g. /paimon)", + ) .action(async (opts) => { const { handleRestart } = await import("./restart"); await handleRestart( opts.port ? parseInt(opts.port) : undefined, opts.host, opts.token, + opts.basePath, ); }); diff --git a/src/cli/commands/hub/restart.ts b/src/cli/commands/hub/restart.ts index b1c556a..4264864 100644 --- a/src/cli/commands/hub/restart.ts +++ b/src/cli/commands/hub/restart.ts @@ -12,6 +12,7 @@ export async function handleRestart( port: number | undefined, host: string | undefined, token?: string, + basePath?: string, ): Promise { // 先读取当前状态(stop 会清理状态文件,必须先读) const prevState = await readHubState(); @@ -19,6 +20,7 @@ export async function handleRestart( // 未指定则继承之前的值,兜底用默认值 const finalPort = port ?? prevState?.port ?? DEFAULTS.PORT; const finalHost = host ?? prevState?.host ?? DEFAULTS.HOST; + const finalBasePath = basePath ?? prevState?.basePath; if (isNaN(finalPort) || finalPort < 1 || finalPort > 65535) { console.error("Invalid port number"); @@ -35,5 +37,5 @@ export async function handleRestart( } else if (prevState?.accessToken) { tokenOption = { token: prevState.accessToken, source: "inherited" }; } - await startDaemon(finalPort, finalHost, tokenOption); + await startDaemon(finalPort, finalHost, tokenOption, finalBasePath); } diff --git a/src/cli/commands/hub/start.ts b/src/cli/commands/hub/start.ts index 14f5eb6..b83c75a 100644 --- a/src/cli/commands/hub/start.ts +++ b/src/cli/commands/hub/start.ts @@ -6,6 +6,7 @@ export async function handleStart( port: number, host: string, token?: string, + basePath?: string, ): Promise { if (isNaN(port) || port < 1 || port > 65535) { console.error("Invalid port number"); @@ -14,5 +15,5 @@ export async function handleStart( const tokenOption: TokenOption | undefined = token ? { token, source: "--token" } : undefined; - await startDaemon(port, host, tokenOption); + await startDaemon(port, host, tokenOption, basePath); } diff --git a/src/cli/commands/hub/status.ts b/src/cli/commands/hub/status.ts index 6124230..efea86f 100644 --- a/src/cli/commands/hub/status.ts +++ b/src/cli/commands/hub/status.ts @@ -13,7 +13,9 @@ export async function handleStatus(): Promise { console.log(`Hub is running`); console.log(` PID: ${state.pid}`); console.log(` Bind: ${state.host}:${state.port}`); - console.log(` URL: http://${displayHost}:${state.port}`); + console.log( + ` URL: http://${displayHost}:${state.port}${state.basePath ?? ""}`, + ); console.log(` Token: ${maskToken(state.accessToken)}`); } else { console.log("Hub is not running"); diff --git a/src/cli/daemon.ts b/src/cli/daemon.ts index 0a12947..9726a87 100644 --- a/src/cli/daemon.ts +++ b/src/cli/daemon.ts @@ -3,6 +3,7 @@ import { join, resolve } from "node:path"; import { mkdir, unlink, rename } from "node:fs/promises"; import { openSync, closeSync } from "node:fs"; +import { normalizeBasePath } from "../utils/basePath"; import { DEFAULTS } from "../protocol/types"; import type { HubState } from "../protocol/types"; import { @@ -90,7 +91,15 @@ export async function startDaemon( port: number, host: string, tokenOption?: TokenOption, + rawBasePath?: string, ): Promise { + let basePath: string | undefined; + try { + basePath = normalizeBasePath(rawBasePath); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } // 检查是否已在运行 const existing = await readHubState(); if (existing && isProcessAlive(existing.pid)) { @@ -128,6 +137,7 @@ export async function startDaemon( PAIMON_PORT: String(port), PAIMON_HOST: host, PAIMON_ACCESS_TOKEN: accessToken, + ...(basePath ? { PAIMON_BASE_PATH: basePath } : {}), }, stdin: "ignore", // stdout/stderr 仅作为 crash 兜底,正常结构化日志走 rotating-file-stream @@ -180,10 +190,11 @@ export async function startDaemon( host, startedAt: new Date().toISOString(), accessToken, + ...(basePath ? { basePath } : {}), }); console.log(`Hub started (PID: ${child.pid}, port: ${port}, host: ${host})`); - console.log(` Web UI: http://${healthHost}:${port}`); + console.log(` Web UI: http://${healthHost}:${port}${basePath ?? ""}`); // 有意打印完整 token:用户首次启动时需要复制 token 用于 Web 登录和 Edge 配置 console.log(` Token: ${accessToken} (${tokenSource})`); console.log(` Logs: ${getMainLogPath(DEFAULTS.HUB_LOG_NAME)}`); diff --git a/src/hub/index.ts b/src/hub/index.ts index 423f566..c27c707 100644 --- a/src/hub/index.ts +++ b/src/hub/index.ts @@ -2,13 +2,15 @@ // // Hub 只与 Edge 和 Browser 通信,不再直接连接 pi extension。 // Edge 通过 /ws/edge 连接 Hub,Browser 通过 /ws/browser 连接。 +// 所有路由统一在 fetch 中处理,支持 basePath prefix strip(直接访问和反向代理均可工作)。 import { existsSync } from "node:fs"; import { resolve, dirname, sep } from "node:path"; -import type { ServerWebSocket, Server, BunRequest } from "bun"; +import type { ServerWebSocket, Server } from "bun"; import { randomUUID } from "node:crypto"; import { DEFAULTS } from "../protocol/types"; import { isLoopbackHost, nonLoopbackWarning, isCompiled } from "../utils/env"; +import { normalizeBasePath } from "../utils/basePath"; import { extractToken, verifyAccessToken, isAuthDisabled } from "./auth"; import { hubRegistry, @@ -23,11 +25,25 @@ import { } from "./router"; import * as log from "./logger"; +// 路由匹配正则 +const RE_BROWSE = /^\/api\/edges\/([^/]+)\/browse$/; +const RE_SHUTDOWN = /^\/api\/instance\/([^/]+)\/shutdown$/; + const port = parseInt(process.env.PAIMON_PORT || String(DEFAULTS.PORT), 10); const host = process.env.PAIMON_HOST || DEFAULTS.HOST; const accessToken = process.env.PAIMON_ACCESS_TOKEN || ""; const authEnabled = !isAuthDisabled() && accessToken.length > 0; +// Base path:用于子路径部署(如 /paimon),前端运行时通过注入的全局变量获取 +// 运行时统一为 ""(根路径)或 "/paimon"(子路径),与前端 BASE_PATH 语义一致 +let basePath: string; +try { + basePath = normalizeBasePath(process.env.PAIMON_BASE_PATH) ?? ""; +} catch (err) { + log.error((err as Error).message); + process.exit(1); +} + // 静态文件目录:编译模式从二进制上级的 web/ 读取(bin/paimon → ../web),源码模式从项目根 dist/web 读取 const webDir = isCompiled ? resolve(dirname(process.execPath), "../web") @@ -43,6 +59,17 @@ if (!existsSync(resolve(webDir, "index.html"))) { process.exit(1); } +// 预读并注入 basePath 的 index.html(运行时注入,无需重新构建前端) +const rawIndexHtml = await Bun.file(resolve(webDir, "index.html")).text(); +// 注入 让相对路径资源基于 basePath 解析, +// 注入 __BASE_PATH__ 让前端 JS 知道当前部署前缀 +const injectedHead = [ + "", + ``, + ``, +].join(""); +const indexHtml = rawIndexHtml.replace("", injectedHead); + /** * 认证请求:提取 token 并校验,失败返回 401 Response。 * 认证关闭时始终返回 null(放行)。 @@ -70,7 +97,24 @@ function upgradeWs( return undefined; } +/** + * Strip basePath 前缀:如果路径以 basePath 开头则移除,否则保持原样。 + * 兼容两种场景:反向代理已 strip(路径不带前缀)/ 直接访问(路径带前缀)。 + */ +function stripBasePath(pathname: string): string { + if (basePath) { + if (pathname === basePath) return "/"; + if (pathname.startsWith(basePath + "/")) { + return pathname.slice(basePath.length); + } + } + return pathname; +} + log.info(`Starting Hub server on ${host}:${port}...`); +if (basePath) { + log.info(`Base path: ${basePath}`); +} if (authEnabled) { log.info("Authentication enabled"); } else { @@ -86,177 +130,94 @@ const server = Bun.serve({ hostname: host, port, - routes: { + async fetch(req, server) { + const url = new URL(req.url); + const method = req.method; + const pathname = stripBasePath(url.pathname); + // ── WebSocket 升级端点 ── - // /ws/edge:Edge 节点连接(注册 + 转发实例信息 + 接收指令) - "/ws/edge": (req: Request, server: Server) => { + if (pathname === "/ws/edge") { const denied = authenticate(req); if (denied) return denied; return upgradeWs(req, server, { role: "edge" } as EdgeWsData); - }, - // /ws/browser:Web 控制面板连接 - "/ws/browser": (req: Request, server: Server) => { + } + + if (pathname === "/ws/browser") { const denied = authenticate(req); if (denied) return denied; return upgradeWs(req, server, { role: "browser", subscriptions: new Set(), } as BrowserWsData); - }, + } // ── JSON API ── - "/api/instances": { - GET: (req: Request) => { - const denied = authenticate(req); - if (denied) return denied; - return Response.json({ instances: hubRegistry.getAllInstances() }); - }, - // 在指定 Edge 上 spawn 一个 headless pi 实例 - POST: async (req: Request) => { - const denied = authenticate(req); - if (denied) return denied; - let body: { cwd?: string; edgeId?: string }; - try { - body = (await req.json()) as { cwd?: string; edgeId?: string }; - } catch { - return Response.json({ error: "Invalid JSON body" }, { status: 400 }); - } - const cwd = body.cwd?.trim() ?? ""; - if (!cwd) { - return Response.json( - { error: "Working directory is required" }, - { status: 400 }, - ); - } - - // 确定目标 Edge - let edgeId = body.edgeId?.trim(); - if (!edgeId) { - // 未指定 edgeId 时,选择第一个可用的 edge - const edges = hubRegistry.getAllEdges(); - if (edges.length === 0) { - return Response.json( - { error: "No edge nodes connected" }, - { status: 503 }, - ); - } - edgeId = edges[0].edgeId; - } + if (pathname === "/api/health" && method === "GET") { + return Response.json({ status: "ok", uptime: process.uptime() }); + } - const edgeWs = hubRegistry.getEdgeWs(edgeId); - if (!edgeWs) { - return Response.json( - { error: `Edge ${edgeId} is not connected` }, - { status: 502 }, - ); - } + if (pathname === "/api/instances" && method === "GET") { + const denied = authenticate(req); + if (denied) return denied; + return Response.json({ instances: hubRegistry.getAllInstances() }); + } - // 生成 token,发 spawn 指令给 Edge,等待结果 - const token = randomUUID(); - const spawnPromise = hubRegistry.registerPendingSpawn(token); - - edgeWs.send( - JSON.stringify({ - type: "spawn", - payload: { cwd, token }, - }), - ); - - try { - const instanceId = await spawnPromise; - return Response.json({ instanceId }); - } catch (err) { - const message = (err as Error).message; - log.error(`Failed to spawn instance on edge ${edgeId}: ${message}`); - return Response.json({ error: message }, { status: 500 }); - } - }, - }, - "/api/edges": { - GET: (req: Request) => { - const denied = authenticate(req); - if (denied) return denied; - return Response.json({ edges: hubRegistry.getAllEdges() }); - }, - }, - "/api/edges/:edgeId/browse": { - GET: async (req: BunRequest<"/api/edges/:edgeId/browse">) => { - const denied = authenticate(req); - if (denied) return denied; - const { edgeId } = req.params; - const url = new URL(req.url); - const path = url.searchParams.get("path"); - - if (!path) { - return Response.json( - { error: "Query parameter 'path' is required" }, - { status: 400 }, - ); - } + if (pathname === "/api/instances" && method === "POST") { + const denied = authenticate(req); + if (denied) return denied; + return handleSpawnInstance(req); + } - const edgeWs = hubRegistry.getEdgeWs(edgeId); - if (!edgeWs) { - return Response.json( - { error: `Edge ${edgeId} is not connected` }, - { status: 502 }, - ); - } + if (pathname === "/api/edges" && method === "GET") { + const denied = authenticate(req); + if (denied) return denied; + return Response.json({ edges: hubRegistry.getAllEdges() }); + } - const token = randomUUID(); - const browsePromise = hubRegistry.registerPendingBrowse(token); - - edgeWs.send( - JSON.stringify({ - type: "browse", - payload: { path, token }, - }), - ); - - try { - const result = await browsePromise; - return Response.json(result); - } catch (err) { - const message = (err as Error).message; - return Response.json({ error: message }, { status: 500 }); - } - }, - }, - "/api/health": { - GET: () => Response.json({ status: "ok", uptime: process.uptime() }), - }, + // /api/edges/:edgeId/browse + const browseMatch = pathname.match(RE_BROWSE); + if (browseMatch && method === "GET") { + const denied = authenticate(req); + if (denied) return denied; + return handleBrowse(url.searchParams, decodeURIComponent(browseMatch[1])); + } - // 让指定实例优雅退出 - "/api/instance/:id/shutdown": { - POST: (req: BunRequest<"/api/instance/:id/shutdown">) => { - const denied = authenticate(req); - if (denied) return denied; - const id = req.params.id; - return ( - forwardToEdgeForHttp(id, { - type: "shutdown", - payload: { instanceId: id }, - }) ?? Response.json({ ok: true }) - ); - }, - }, - }, + // /api/instance/:id/shutdown + const shutdownMatch = pathname.match(RE_SHUTDOWN); + if (shutdownMatch && method === "POST") { + const denied = authenticate(req); + if (denied) return denied; + const id = decodeURIComponent(shutdownMatch[1]); + return ( + forwardToEdgeForHttp(id, { + type: "shutdown", + payload: { instanceId: id }, + }) ?? Response.json({ ok: true }) + ); + } - // 兜底:静态文件服务 + SPA fallback - async fetch(req) { - const url = new URL(req.url); - const filePath = url.pathname === "/" ? "/index.html" : url.pathname; + // ── 静态文件服务 + SPA fallback ── + const filePath = pathname === "/" ? "/index.html" : pathname; const resolvedPath = resolve(webDir, `.${filePath}`); // 防御路径遍历 if (resolvedPath === webDir || resolvedPath.startsWith(webDir + sep)) { + // index.html 返回注入了 basePath 的版本 + if (filePath === "/index.html") { + return new Response(indexHtml, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } const file = Bun.file(resolvedPath); if (await file.exists()) { return new Response(file); } } - // SPA fallback - return new Response(Bun.file(resolve(webDir, "index.html"))); + // SPA fallback:返回注入了 basePath 的 index.html + return new Response(indexHtml, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); }, // WebSocket 处理 @@ -293,6 +254,105 @@ const server = Bun.serve({ }, }); +// ── API handler 函数 ── + +async function handleSpawnInstance(req: Request): Promise { + let body: { cwd?: string; edgeId?: string }; + try { + body = (await req.json()) as { cwd?: string; edgeId?: string }; + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const cwd = body.cwd?.trim() ?? ""; + if (!cwd) { + return Response.json( + { error: "Working directory is required" }, + { status: 400 }, + ); + } + + // 确定目标 Edge + let edgeId = body.edgeId?.trim(); + if (!edgeId) { + const edges = hubRegistry.getAllEdges(); + if (edges.length === 0) { + return Response.json( + { error: "No edge nodes connected" }, + { status: 503 }, + ); + } + edgeId = edges[0].edgeId; + } + + const edgeWs = hubRegistry.getEdgeWs(edgeId); + if (!edgeWs) { + return Response.json( + { error: `Edge ${edgeId} is not connected` }, + { status: 502 }, + ); + } + + // 生成 token,发 spawn 指令给 Edge,等待结果 + const token = randomUUID(); + const spawnPromise = hubRegistry.registerPendingSpawn(token); + + edgeWs.send( + JSON.stringify({ + type: "spawn", + payload: { cwd, token }, + }), + ); + + try { + const instanceId = await spawnPromise; + return Response.json({ instanceId }); + } catch (err) { + const message = (err as Error).message; + log.error(`Failed to spawn instance on edge ${edgeId}: ${message}`); + return Response.json({ error: message }, { status: 500 }); + } +} + +async function handleBrowse( + searchParams: URLSearchParams, + edgeId: string, +): Promise { + const path = searchParams.get("path"); + + if (!path) { + return Response.json( + { error: "Query parameter 'path' is required" }, + { status: 400 }, + ); + } + + const edgeWs = hubRegistry.getEdgeWs(edgeId); + if (!edgeWs) { + return Response.json( + { error: `Edge ${edgeId} is not connected` }, + { status: 502 }, + ); + } + + const token = randomUUID(); + const browsePromise = hubRegistry.registerPendingBrowse(token); + + edgeWs.send( + JSON.stringify({ + type: "browse", + payload: { path, token }, + }), + ); + + try { + const result = await browsePromise; + return Response.json(result); + } catch (err) { + const message = (err as Error).message; + return Response.json({ error: message }, { status: 500 }); + } +} + log.info(`Hub server listening on http://${host}:${server.port}`); // 优雅退出 diff --git a/src/protocol/types.ts b/src/protocol/types.ts index 800c120..61e33c6 100644 --- a/src/protocol/types.ts +++ b/src/protocol/types.ts @@ -587,6 +587,8 @@ export interface HubState { startedAt: string; // ISO 8601 /** Hub 访问令牌(Edge / Browser / API 连接时校验) */ accessToken: string; + /** 子路径部署前缀(如 /paimon),无子路径时不存储 */ + basePath?: string; } // ============================================================ diff --git a/src/utils/basePath.ts b/src/utils/basePath.ts new file mode 100644 index 0000000..1312619 --- /dev/null +++ b/src/utils/basePath.ts @@ -0,0 +1,28 @@ +// basePath 规范化工具,Hub 和 CLI 共用 + +const VALID_BASE_PATH_RE = /^[a-zA-Z0-9/_-]+$/; + +/** + * 规范化 basePath: + * - 空字符串或 "/" 视为无子路径(返回 undefined) + * - 以 / 开头、不以 / 结尾(如 "/paimon") + * - 仅允许 [a-zA-Z0-9/_-] 字符,不合法则抛出错误 + */ +export function normalizeBasePath(raw?: string): string | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!trimmed || trimmed === "/") return undefined; + + // 规范化:去除多余斜杠,确保以 / 开头 + const normalized = "/" + trimmed.split("/").filter(Boolean).join("/"); + if (normalized === "/") return undefined; + + // 白名单校验 + if (!VALID_BASE_PATH_RE.test(normalized)) { + throw new Error( + `Invalid base path "${raw}": only [a-zA-Z0-9/_-] characters are allowed`, + ); + } + + return normalized; +} diff --git a/src/web/index.html b/src/web/index.html index eb5c18d..8bdc421 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -6,10 +6,10 @@ name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content" /> - - - - + + + + Paimon