From bf7f2df67ee5e8f2e24edb338ee99a1cde6079a8 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Tue, 25 Aug 2026 11:35:05 +0800 Subject: [PATCH] init: init proj --- .env.example | 6 + .gitignore | 11 + CLAUDE.md | 99 + Dockerfile | 25 + README.md | 85 + docker-compose.yml | 13 + docs/ADDING-REGIONS.md | 135 + docs/SEEDING.md | 33 + docs/design-demos.html | 720 +++++ package.json | 15 + pnpm-lock.yaml | 2733 +++++++++++++++++ pnpm-workspace.yaml | 8 + server/assets/surge-template.conf | 303 ++ server/package.json | 32 + server/src/db/index.ts | 125 + server/src/db/schema.ts | 101 + server/src/db/seed.ts | 131 + server/src/env.ts | 11 + server/src/index.ts | 54 + server/src/middleware/admin-auth.ts | 31 + server/src/routes/groups.ts | 107 + server/src/routes/nodes.ts | 132 + server/src/routes/rules.ts | 116 + server/src/routes/settings.ts | 22 + server/src/routes/sub.ts | 104 + server/src/routes/tokens.ts | 78 + server/src/services/generators/clash.ts | 192 ++ .../services/generators/shadowrocket-conf.ts | 110 + .../src/services/generators/shadowrocket.ts | 27 + server/src/services/generators/surge.ts | 124 + server/src/services/ir.ts | 131 + server/src/services/rulesets/convert.test.ts | 29 + server/src/services/rulesets/convert.ts | 65 + server/src/services/rulesets/fetch.ts | 48 + server/src/services/rulesets/mapping.ts | 53 + server/src/services/sharelink/hysteria2.ts | 44 + server/src/services/sharelink/parse.test.ts | 167 + server/src/services/sharelink/parse.ts | 44 + server/src/services/sharelink/ss.ts | 102 + server/src/services/sharelink/trojan.ts | 41 + server/src/services/sharelink/types.ts | 23 + server/src/services/sharelink/util.ts | 27 + server/src/services/sharelink/vmess.ts | 50 + .../services/template/surge-parser.test.ts | 55 + server/src/services/template/surge-parser.ts | 159 + server/tsconfig.json | 14 + shared/package.json | 21 + shared/src/dto.ts | 119 + shared/src/index.ts | 3 + shared/src/protocols.ts | 50 + shared/src/schemas.ts | 99 + shared/tsconfig.json | 11 + web/index.html | 12 + web/package.json | 25 + web/src/App.vue | 189 ++ web/src/api/client.ts | 44 + web/src/components/EgressBadge.vue | 62 + web/src/components/ImportModal.vue | 121 + web/src/components/NodeFormModal.vue | 184 ++ web/src/components/ProtoDot.vue | 51 + web/src/components/RulesetPreviewDrawer.vue | 91 + web/src/components/TokenFormModal.vue | 236 ++ web/src/components/stations.ts | 23 + web/src/main.ts | 10 + web/src/pages/GroupsPage.vue | 353 +++ web/src/pages/NodesPage.vue | 300 ++ web/src/pages/RulesPage.vue | 266 ++ web/src/pages/SettingsPage.vue | 92 + web/src/pages/SubscriptionsPage.vue | 404 +++ web/src/router.ts | 13 + web/src/stores/groups.ts | 41 + web/src/stores/nodes.ts | 53 + web/src/stores/rules.ts | 49 + web/src/stores/subscriptions.ts | 49 + web/src/styles/tokens.css | 98 + web/src/theme.ts | 46 + web/src/utils/clipboard.ts | 45 + web/src/utils/nodeName.ts | 12 + web/tsconfig.json | 13 + web/vite.config.ts | 16 + 80 files changed, 9936 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docs/ADDING-REGIONS.md create mode 100644 docs/SEEDING.md create mode 100644 docs/design-demos.html create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 server/assets/surge-template.conf create mode 100644 server/package.json create mode 100644 server/src/db/index.ts create mode 100644 server/src/db/schema.ts create mode 100644 server/src/db/seed.ts create mode 100644 server/src/env.ts create mode 100644 server/src/index.ts create mode 100644 server/src/middleware/admin-auth.ts create mode 100644 server/src/routes/groups.ts create mode 100644 server/src/routes/nodes.ts create mode 100644 server/src/routes/rules.ts create mode 100644 server/src/routes/settings.ts create mode 100644 server/src/routes/sub.ts create mode 100644 server/src/routes/tokens.ts create mode 100644 server/src/services/generators/clash.ts create mode 100644 server/src/services/generators/shadowrocket-conf.ts create mode 100644 server/src/services/generators/shadowrocket.ts create mode 100644 server/src/services/generators/surge.ts create mode 100644 server/src/services/ir.ts create mode 100644 server/src/services/rulesets/convert.test.ts create mode 100644 server/src/services/rulesets/convert.ts create mode 100644 server/src/services/rulesets/fetch.ts create mode 100644 server/src/services/rulesets/mapping.ts create mode 100644 server/src/services/sharelink/hysteria2.ts create mode 100644 server/src/services/sharelink/parse.test.ts create mode 100644 server/src/services/sharelink/parse.ts create mode 100644 server/src/services/sharelink/ss.ts create mode 100644 server/src/services/sharelink/trojan.ts create mode 100644 server/src/services/sharelink/types.ts create mode 100644 server/src/services/sharelink/util.ts create mode 100644 server/src/services/sharelink/vmess.ts create mode 100644 server/src/services/template/surge-parser.test.ts create mode 100644 server/src/services/template/surge-parser.ts create mode 100644 server/tsconfig.json create mode 100644 shared/package.json create mode 100644 shared/src/dto.ts create mode 100644 shared/src/index.ts create mode 100644 shared/src/protocols.ts create mode 100644 shared/src/schemas.ts create mode 100644 shared/tsconfig.json create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/App.vue create mode 100644 web/src/api/client.ts create mode 100644 web/src/components/EgressBadge.vue create mode 100644 web/src/components/ImportModal.vue create mode 100644 web/src/components/NodeFormModal.vue create mode 100644 web/src/components/ProtoDot.vue create mode 100644 web/src/components/RulesetPreviewDrawer.vue create mode 100644 web/src/components/TokenFormModal.vue create mode 100644 web/src/components/stations.ts create mode 100644 web/src/main.ts create mode 100644 web/src/pages/GroupsPage.vue create mode 100644 web/src/pages/NodesPage.vue create mode 100644 web/src/pages/RulesPage.vue create mode 100644 web/src/pages/SettingsPage.vue create mode 100644 web/src/pages/SubscriptionsPage.vue create mode 100644 web/src/router.ts create mode 100644 web/src/stores/groups.ts create mode 100644 web/src/stores/nodes.ts create mode 100644 web/src/stores/rules.ts create mode 100644 web/src/stores/subscriptions.ts create mode 100644 web/src/styles/tokens.css create mode 100644 web/src/theme.ts create mode 100644 web/src/utils/clipboard.ts create mode 100644 web/src/utils/nodeName.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..80751c3 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# 服务端口(监听 0.0.0.0,局域网内的手机等设备可直接访问) +PORT=3000 +# SQLite 数据目录(相对 server/ 或绝对路径) +DATA_DIR=./data +# 管理界面鉴权 token。未设置时 /api/* 仅放行 localhost 与局域网私有网段 +ADMIN_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1b1f3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +data/ +*.db +*.db-journal +*.db-wal +*.db-shm +.env +.env.local +.DS_Store +*.log diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8f79239 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 这是什么 + +自托管的代理节点与分流规则管理平台:Web 界面管理多协议节点(VMess/Trojan/Shadowsocks/Hysteria2)与策略组、规则,向 macOS Surge、iOS ShadowRocket、Android ClashMeta 下发托管订阅。规则模板来自 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge),节点来自 `~/Development/Fusion/Projects/edge-nodes/` 交付的边缘节点舰队。 + +## 命令 + +```bash +pnpm dev # server :3000 + web :5173(都监听 0.0.0.0,局域网可访问) +pnpm -r typecheck # 三个包全量类型检查 +pnpm test # 服务端单测(vitest) +pnpm --filter @proxy-station/web build + +# 单测过滤(在 server/ 目录下) +npx vitest run parse # 按文件名 +npx vitest run -t "round-trip" # 按用例名 +npx vitest # watch 模式 + +# 校验生成的 Clash 配置(最有价值的一条端到端验证) +curl -s "http://localhost:3000/sub//clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml +``` + +**网络与安装的两个坑**:本机会话注入的 `HTTPS_PROXY` 会让 git/构建挂死,跑任何命令前先剥掉代理(见用户全局 CLAUDE.md)。pnpm 在无 TTY 时执行 `pnpm install` 需要 `CI=true`,原生模块(better-sqlite3、esbuild、vue-demi)的构建许可写在 `pnpm-workspace.yaml` 的 `allowBuilds`。 + +## 架构 + +pnpm monorepo:`shared/`(zod schema 与 DTO,前后端共用源码,非构建产物)+ `server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Naive UI + Pinia)。生产模式由 server 静态托管 web 产物,单端口。 + +### 配置生成流水线(核心) + +``` +SQLite → buildProfileIR({nodeIds}) → ProfileIR → 四个序列化器 + ├─ generateSurge(ir, {selfUrl}) + ├─ generateShadowrocket(ir) 节点订阅 + ├─ generateShadowrocketConf(ir, …) 策略组+规则 + └─ generateClash(ir, {convertBaseUrl}) +``` + +`services/ir.ts` 是唯一的读库出口,把节点、策略组、规则、Surge 原文段落收成与客户端无关的 `ProfileIR`;四个生成器只读 IR,互不依赖。**新增客户端就是加一个序列化器**,不要在生成器里再查数据库。 + +IR 层承担三件防护,改动时不要绕过: +- 停用或已删除的策略组会被剔除,引用它的成员与规则一并跳过 +- `FINAL` 规则永远保留,其策略组失效时降级为 `DIRECT` +- `nodeIds` 为空数组表示「不限制」,非空则按订阅分配的节点范围过滤 + +### 客户端能力矩阵 + +`shared/src/protocols.ts` 的 `clientSupports()` 决定某节点能否下发给某客户端,生成器据此静默排除并回报 `excluded`。当前唯一规则:**Surge 不支持 Shadowsocks over WebSocket**(Surge 的 ss 类型只有 simple-obfs,没有 WS 传输)。这不是 bug,同机的 VMess/Trojan 线路凭据相同、覆盖等价。 + +### 协议字段差异 + +同一协议在三端写法不同,映射集中在 `shared/src/protocols.ts` 与各生成器: +- SS cipher:分享链接用 Xray 命名 `chacha20-poly1305`,Surge/Clash 要 `chacha20-ietf-poly1305`(`normalizeSsCipher()`) +- VMess `alterId=0` 必须输出 Surge 的 `vmess-aead=true`,否则连不上且症状隐晦 +- Hysteria2 的 salamander 混淆:Surge 用 `salamander-password=`,Clash 用 `obfs`/`obfs-password` +- SS over WS 在 Clash 侧唯一可行方案是 `plugin: v2ray-plugin` + +这些写法参考了 `../sub-router` 项目(用户已在生产使用的同类系统),遇到新的客户端适配问题优先查它。 + +### 策略组的两种取节点方式 + +`includeAllNodes` 与 `filterRegex` 可以共存,生成器里 **filterRegex 分支优先**: +- 有 `filterRegex` → 动态筛选:Surge `include-all-proxies=true, policy-regex-filter=…`/Clash `filter:`/ShadowRocket `use=true, policy-regex-filter=…`。新增同地区节点会自动进组 +- 仅 `includeAllNodes` → Surge 静态展开全部节点名,Clash/ShadowRocket 仍用动态引用 + +**地区组靠节点名称正则匹配,不看 `region` 字段**。节点命名规范与新增地区的完整步骤见 `docs/ADDING-REGIONS.md`——命名不对就进不了地区组。 + +### 规则集:映射与兜底转换 + +模板里的 `RULE-SET`/`DOMAIN-SET` 指向外部 URL。Surge/ShadowRocket 直接透传;Clash 需要 rule-providers,`services/rulesets/mapping.ts` 按序尝试映射到 skk.moe 的 Clash 版与 blackmatrix7 的 Clash 目录,映射不到的标记 `needsConvert`,provider URL 指向自托管端点 `/sub/:token/ruleset/:id.yaml`——该端点实时拉取 Surge 版并用 `convert.ts` 转成 classical provider。规则集拉取带 24h 缓存 + etag,失败回落陈旧缓存。 + +### ShadowRocket 分两步导入 + +ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf)。**conf 里的策略组用 `use=true` 引用订阅名**,所以用户在 App 内必须把节点订阅命名为 `SHADOWROCKET_SUB_NAME`(= `Proxy Station`)。ShadowRocket 不支持 `DOMAIN-SET`/`AND`/`URL-REGEX`,生成时跳过并回报 `skippedRules`。**该 conf 尚未经过真机验证。** + +### 鉴权与订阅 + +`/api/*` 走 `adminAuth`:设置了 `ADMIN_TOKEN` 则要求 Bearer,未设置时仅放行 localhost 与 RFC1918 私有网段(公网 IP 一律拒绝)。`/sub/:token/*` 公开,token 用常量时间比对。**订阅 URL 完全由请求来源决定**——后端只返回路径,前端用 `window.location.origin` 拼接,`#!MANAGED-CONFIG` 与 Clash provider URL 取本次请求的 origin,所以没有也不需要「订阅基址」配置项。 + +未匹配的 `/api/*` 显式返回 404,不落到 SPA 回退(否则会拿到一页 HTML,调接口时极难排查)。 + +## 前端约定 + +UI 走「站牌导视」设计方向(轨道交通导视系统语言):白底 `#f7f7f4`、深藏青重点色 `#2b3f5c`、**琥珀 `#b26a00` = 直连出口、青 `#007a72` = WARP 净出口**,协议各有线路色圆标。颜色只在承载信息时出现——避免发光、亮边条、装饰性渐变。四个方向的设计对比留在 `docs/design-demos.html`。 + +两个共用工具: +- `utils/nodeName.ts` 的 `nodeDisplayName()`:与出口标签并排显示时剥掉名字末尾的出口词。**下发给客户端的名字必须保留出口词**,客户端里没有标签,用户只能靠名字区分同协议的两条线路 +- `utils/clipboard.ts` 的 `copyText()`:`navigator.clipboard` 只在 HTTPS/localhost 可用,局域网 HTTP 访问时是 `undefined`,直接调用会静默失败。必须走这个带 `execCommand` 降级的封装 + +## 数据与安全 + +SQLite 落在 `server/data/`,存有节点真实凭据。`data/`、`*.db`、`.env` 已在 `.gitignore`——**绝不入 git**。仓库内所有示例、测试、模板一律用占位凭据。节点通过 Web 界面粘贴分享链接导入,不直接读 edge-nodes 目录(见 `docs/SEEDING.md`)。 + +schema 变更走 `db/index.ts` 里的 `BOOTSTRAP_SQL` 加 `CREATE TABLE IF NOT EXISTS`,旧库补列用同文件下方的轻量 `ALTER TABLE` 循环(吞掉「列已存在」错误)——没有引入 migration 工具。 + +公网部署必须设 `ADMIN_TOKEN` 并用反代提供 HTTPS:订阅 token 就在 URL 里,明文 HTTP 会泄露。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e51be1d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# ---- 构建阶段:完整镜像自带编译工具链(better-sqlite3 原生模块)---- +FROM node:22 AS build +WORKDIR /app +RUN corepack enable +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ +COPY shared/package.json shared/ +COPY server/package.json server/ +COPY web/package.json web/ +RUN CI=true pnpm install --frozen-lockfile +COPY shared/ shared/ +COPY server/ server/ +COPY web/ web/ +RUN pnpm --filter @proxy-station/web build + +# ---- 运行阶段 ---- +FROM node:22-slim +WORKDIR /app +RUN corepack enable +COPY --from=build /app ./ +ENV NODE_ENV=production +ENV PORT=3000 +ENV DATA_DIR=/data +VOLUME /data +EXPOSE 3000 +CMD ["pnpm", "--filter", "@proxy-station/server", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..07bdc6d --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Proxy Station + +自托管的代理节点与分流规则管理平台。在 Web 界面里管理多协议节点与策略组、规则,向 macOS、iOS、Android 三端下发托管订阅——客户端填一次 URL,之后配置自动更新。 + +``` + 节点 ──┐ + 策略组 ──┼──→ ProfileIR ──┬──→ Surge .conf + 规则 ──┘ ├──→ ShadowRocket 节点订阅 + 规则配置 + └──→ ClashMeta .yaml +``` + +## 能做什么 + +**多协议节点管理**——VMess、Trojan、Shadowsocks、Hysteria2,支持粘贴分享链接批量导入(`vmess://`、`trojan://`、`ss://` 的 SIP002 与 Xray 两种写法、`hysteria2://`),也支持手动填写与导出。节点按站点分段展示。 + +**分流规则与策略组**——首次启动自动从 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge) 模板导入(26 个策略组 + 88 条规则,含广告拦截、AI 服务、流媒体、国内直连等)。规则可排序、改策略、启停、增删;策略组可启停并调整顺序,顺序即客户端里的排列顺序。外部规则集可在界面上实时预览内容。 + +**按客户端自动适配**——同一份数据生成三端配置,客户端不支持的协议在下发时自动屏蔽(例如 Surge 的 ss 类型没有 WebSocket 传输,SS over WS 节点会被排除),字段差异自动映射(cipher 命名、VMess AEAD、Hysteria2 混淆参数等)。 + +**按订阅分配节点**——每个订阅可以只开放部分节点,用于把不同线路分给不同设备或他人。 + +## 客户端支持 + +| 客户端 | 节点 | 策略组 | 规则 | 导入方式 | +|---|:--:|:--:|:--:|---| +| **Surge** (macOS) | ✅ | ✅ | ✅ | 一个 URL,托管配置自动更新(`#!MANAGED-CONFIG`) | +| **ClashMeta** (Android) | ✅ | ✅ | ✅ | 一个 URL,`RULE-SET` 自动映射为 rule-providers | +| **ShadowRocket** (iOS) | ✅ | ✅ | ✅ | **两个 URL**:节点订阅 + 规则配置,见下 | + +ShadowRocket 的原生订阅格式只承载节点,因此拆成两步:先在「订阅」里添加节点链接并**命名为 `Proxy Station`**,再在「配置」里添加规则链接。配置文件按这个名字引用节点,名字必须一致。订阅页的 ⓘ 图标里有同样的说明。 + +> ClashMeta 侧无 Clash 版本的规则集,由本服务实时拉取 Surge 版并转换后提供,无需外部转换服务。 + +## 快速开始 + +```bash +pnpm install +pnpm dev # server :3000 + web :5173 +``` + +打开 http://localhost:3000 (或开发时的 :5173),然后: + +1. **节点** → 批量导入,粘贴分享链接。命名建议带地区词(如 `🇺🇸 US LA VMess Direct`),地区策略组靠节点名正则匹配 +2. **订阅** → 新建订阅,勾选要开放的节点 +3. 复制对应客户端的 URL 填进客户端 + +两端都监听 `0.0.0.0`,手机在同一局域网下用 `http://<你的内网IP>:5173` 即可访问管理界面,页面上显示的订阅 URL 会自动带上该地址。 + +## 部署 + +```bash +ADMIN_TOKEN=<随机长字符串> docker compose up -d +``` + +SQLite 落在 `./data` 卷。订阅 URL 由请求来源自动推断,无需配置基址。 + +**公网部署的两条硬要求**:设置 `ADMIN_TOKEN`(未设置时管理接口只放行 localhost 与局域网);用反向代理提供 **HTTPS**——订阅 token 就在 URL 里,明文传输会泄露。反代的 access log 建议对 `/sub/` 路径脱敏。 + +## 校验 + +```bash +# Clash 产物(最有价值的一条端到端验证) +curl -s "http://localhost:3000/sub//clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml + +pnpm -r typecheck +pnpm test +``` + +Surge 与 ShadowRocket 需真机导入验收。 + +## 文档 + +| 文件 | 内容 | +|---|---| +| `docs/SEEDING.md` | 从 edge-nodes 交付文档导入真实节点、命名建议 | +| `docs/ADDING-REGIONS.md` | 新增地区(香港、日本等)时的节点命名与策略组配套步骤 | +| `CLAUDE.md` | 架构要点与开发约定 | + +## 安全 + +数据库存有节点真实凭据。`data/`、`*.db`、`.env` 已在 `.gitignore` 中,**绝不入 git**;仓库内所有示例、测试、模板一律使用占位凭据。订阅 token 可随时在「订阅」页禁用或删除。 + +## 技术栈 + +pnpm monorepo:`server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Vite + Naive UI + Pinia)+ `shared/`(zod schema 与 DTO,前后端共用)。生产模式由 server 静态托管前端产物,单端口运行。 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..39a8505 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + proxy-station: + build: . + container_name: proxy-station + restart: unless-stopped + ports: + - '3000:3000' + volumes: + - ./data:/data + environment: + # 公网部署必须设置:管理接口的 Bearer token + # 未设置时管理接口仅放行 localhost 与局域网,订阅端点始终公开(token 即凭证) + - ADMIN_TOKEN=${ADMIN_TOKEN:-} diff --git a/docs/ADDING-REGIONS.md b/docs/ADDING-REGIONS.md new file mode 100644 index 0000000..54a0ee5 --- /dev/null +++ b/docs/ADDING-REGIONS.md @@ -0,0 +1,135 @@ +# 新增地区与新增站点 + +当舰队里加入新地区(香港、日本、韩国、台湾、英国……)的节点时,按本文操作。 + +> 背景:策略组的地区筛选靠**节点名称正则**,不靠节点的 `region` 字段。所以节点命名是一切的前提。 + +## 当前状态(2026-08-24) + +只有美国与新加坡两个地区的节点,因此策略组里只有 `🇺🇸 United States` 与 `🇸🇬 Singapore` 两个地区组。 +香港、日本、台湾、英国、韩国的地区组**已删除**——留着会是空组,在客户端里显示为无可用节点。 +兜底的 `🌍 Other` 组也已删除:所有节点都能被现有两个地区组收纳,它恒为空。 + +## 一、给新节点命名(决定它能被哪个组收纳) + +命名格式:`<国旗> <国家码> · <站点码> · <协议> · <出口>` + +``` +🇭🇰 HK · VMess · 直连 +🇯🇵 JP · Trojan · WARP +🇺🇸 US · LA · VMess · 直连 ← 同国多站点时插入站点码 +``` + +- **国家码必须出现在名字里**(HK/JP/US/SG…),地区组的正则靠它匹配 +- 出口词(`直连` / `WARP`)必须保留:客户端里没有出口标签,用户只能靠名字区分同协议的两条线路。界面上显示时会自动隐去,不必担心重复 +- 站点码(LA/RN/AWS)用于节点页的分段归类,来自节点的 `region` 字段 + +节点的 `region` 字段填站点码(`SG`、`LA`、`RN`、`HK`…),它决定节点页里归到哪一段。 + +## 二、让站点标题显示国旗 + +`web/src/components/stations.ts` 的 `STATION_LABELS` 加一行: + +```ts +const STATION_LABELS: Record = { + SG: '🇸🇬 SG AWS', + LA: '🇺🇸 US LA', + RN: '🇺🇸 US RN', + HK: '🇭🇰 HK 香港站点名', // ← 新增 +}; +``` + +未配置的站点码会原样显示,不影响功能。 + +## 三、新建地区策略组 + +在「策略组」页点「添加策略组」,按下表填写: + +| 字段 | 值 | +|---|---| +| 名称 | `🇭🇰 Hong Kong`(emoji 前缀 + 英文原名) | +| 类型 | `url-test` | +| 成员 | 留空 | +| 收纳全部节点 | **开启** | +| 名称筛选正则 | 见下方对照表 | +| 测速 URL | `http://cp.cloudflare.com/generate_204` | +| 间隔 | `300` | +| 容差 | `50` | + +「收纳全部节点」+「筛选正则」的组合会生成 `include-all-proxies=true, policy-regex-filter=…`(Surge)、`include-all-proxies: true, filter: …`(Clash)、`use=true, policy-regex-filter=…`(ShadowRocket),三端都是**动态筛选**——之后再加同地区节点会自动进组,不用改配置。 + +各地区的正则(取自原模板,可直接复制): + +``` +香港 港|🇭🇰|香港|HK|Hong +日本 日|🇯🇵|日本|JP|Japan +台湾 台|🇹🇼|台湾|TW|Tai +韩国 韩|韩国|Korea|KR|🇰🇷 +英国 🇬🇧|英国|英|英格兰|UK|United Kingdom +新加坡 坡|🇸🇬|新加坡|狮城|SG|Singapore +美国 美|🇺🇸|美国|US|States|American +``` + +## 四、兜底组 `🌍 Other`(当前不存在) + +原模板有一个 `🌍 Other` 组,用否定前瞻正则收纳「不属于任何已建地区组」的节点。**当前已删除**,因为现有节点都能被美/新两个组收纳,它恒为空。 + +只有当你加入的节点**不属于任何已建地区组**(比如加了德国节点但没建德国组)时,才需要重建它: + +| 字段 | 值 | +|---|---| +| 名称 | `🌍 Other` | +| 类型 | `url-test`,收纳全部节点开启 | +| 筛选正则 | 见下(把所有**已建**地区组的关键字排除掉) | + +``` +^((?!港|🇭🇰|香港|HK|Hong|日|🇯🇵|日本|JP|Japan|坡|🇸🇬|新加坡|狮城|SG|Singapore|美|🇺🇸|美国|US|States|American|Remain|Expired|官网|如需|套餐|去除|剩余|距离|Reset|重置|流量).)+$ +``` + +**如果重建了 Other,每次新增地区组都必须往这个否定前瞻里追加对应关键字**,否则新地区节点会同时出现在自己的组和 Other 里。 + +## 五、把新地区加进服务组的可选项 + +新建地区组后,它不会自动出现在服务组的成员里。按需编辑这些组,把新地区加进成员列表: + +| 组 | 当前成员 | 建议 | +|---|---|---| +| `⚡️ Automatic` | SG、US | 加入所有地区组 | +| `🚀 Proxy` | Automatic、SG、US | 加入所有地区组 | +| `🎬 Streaming` | SG、US | 加入解锁能力强的地区(HK/TW/JP) | +| `🤖 AI` | Automatic、US、SG | 加入 JP、US 等 AI 服务可用地区 | +| `🌐 Global Services` | Automatic、US、SG | 同 AI | +| `🍎 Apple` | Mainland、US | 按需加入 HK | + +成员的顺序就是客户端里的排列顺序,**第一个成员是该组的默认选中项**。 + +## 六、地区流媒体解锁规则(可选) + +模板原有 `stream_hk`、`stream_jp`、`stream_tw`、`stream_kr`、`stream_eu` 等按地区解锁的规则集。删除地区组时,这些规则被改指向了 `🎬 Streaming`。 + +如果新建了对应地区组,可以在「规则」页把这些规则的策略改回该地区组,解锁效果更精准: + +| 规则集 | 建议策略 | +|---|---| +| `stream_hk`(non_ip + ip 两条) | `🇭🇰 Hong Kong` | +| `stream_jp`(两条) | `🇯🇵 Japan` | +| `stream_tw`、`stream_kr`、`stream_eu` | 对应地区组 | + +## 七、验证 + +1. 「策略组」页:新地区组的**命中**一栏应列出该地区的所有节点 +2. 「节点」页:新站点应有独立分段,标题显示国旗 +3. 命令行校验 Clash 产物: + ```bash + curl -s "http:///sub//clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml + ``` +4. 若重建过 `🌍 Other`,确认它的命中里**不再包含**新地区节点 + +## 删除某个地区(反向操作) + +1. 把指向该组的规则改指向别的组(否则规则会被自动跳过——IR 层有悬空引用防护,但那是兜底不是意图) +2. 从其他组的成员里摘掉它 +3. 删除该组 +4. Other 的排除列表可以保留该地区关键字(留着无害,将来重建时省事) + +> 上述 1–3 步如果漏做,生成器不会产出无效配置(悬空引用会被自动跳过、FINAL 会降级为 DIRECT),但分流意图会悄悄改变,所以要显式处理。 diff --git a/docs/SEEDING.md b/docs/SEEDING.md new file mode 100644 index 0000000..d136941 --- /dev/null +++ b/docs/SEEDING.md @@ -0,0 +1,33 @@ +# 节点导入指引 + +proxy-station 不直接读取 edge-nodes 工作区(那里全是真实凭据,保持解耦),节点通过 Web 界面批量粘贴分享链接导入。 + +## 从 edge-nodes 交付文档导入 + +每个节点目录 `-edge-node-docs/` 已生成好分享链接: + +- `-.link`——VMess/Trojan 的分享链接文件(`vmess://`、`trojan://`) +- Shadowsocks 线路没有通用分享链接,用 sing-box JSON(`ss-*-singbox.json`)里的参数手动添加,或按 SIP002 + v2ray-plugin 格式自拼: + `ss://base64url(chacha20-poly1305:密码)@域名:443?plugin=v2ray-plugin%3Btls%3Bmode%3Dwebsocket%3Bhost%3D域名%3Bpath%3D%2F路径#名称` +- Hysteria2 线路在 `scripts/node-links/` 下有 `hysteria2://` 链接(含 `obfs=salamander&obfs-password=…`) + +操作: + +1. 在 edge-nodes 侧汇总链接:`cat ~/Development/Fusion/Projects/edge-nodes/*-edge-node-docs/*.link` +2. 打开 proxy-station「节点」页 →「批量导入」→ 粘贴(每行一条)→「解析预览」确认字段 →「导入」 +3. SOCKS5 线路为内部使用,不要导入本系统 + +## 命名建议(影响地区组归类) + +模板的地区策略组按**节点名正则**筛选(如 `坡|🇸🇬|新加坡|狮城|SG|Singapore`、`美|🇺🇸|美国|US`)。命名时带上地区词与出口词,例如: + +- `SG-vmess-direct`、`SG-trojan-warp`(命中 Singapore 组) +- `US-LA-trojan-direct`、`US-RN-vmess-warp`(命中 United States 组;只写 LA/RN 不会命中任何地区组,会落入 Other) + +名称含 `direct`/`warp` 会自动打出口标签,线路矩阵按此归位(琥珀 = 直连,青 = WARP)。 + +## 导入后核对 + +- 「节点」页线路矩阵:每个站点的协议 × 出口格子是否齐全 +- 「策略组」页:地区组的「命中」行是否圈中了预期节点 +- 「订阅」页预览:Surge 端确认 SS over WS 节点已被自动屏蔽(预期行为,Surge 不支持该组合) diff --git a/docs/design-demos.html b/docs/design-demos.html new file mode 100644 index 0000000..5061415 --- /dev/null +++ b/docs/design-demos.html @@ -0,0 +1,720 @@ + + + + + +Proxy Station · 设计方向 Demo + + + + + + + +
+
+
+
方向 A
+

站牌导视

+

轨道交通导视系统的语言:白底粗黑、线路色圆标、站名牌。系统本来就叫 Station——站点是车站,协议是线路,直连和 WARP 是两个站台。信息密度靠导视系统级的层级控制,而不是靠深色背景。

+
+ + +
+ + + +
+ + +
+ SG + sg.example.com + 7 条线路 · AWS 新加坡 +
+
+
+
+
+
直连出口 DIRECT
+
    +
  • VVMess/9d3f01
  • +
  • TTrojan/8a20c4
  • +
  • SShadowsocks/3f81b2
  • +
  • HHysteria2:443
  • +
+
+
+
WARP 净出口
+
    +
  • VVMess/72e5a9
  • +
  • TTrojan/93bf25
  • +
  • SShadowsocks/29c1b3
  • +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
名称协议出口服务器路径
SG-vmess-directV直连cc.sg.example.com:443/9d3f01编辑
SG-trojan-warpTWARPcc.sg.example.com:443/93bf25编辑
LA-ss-direct(已停用)S已停用cc.la.example.com:443/3f81b2编辑
+
+
+ + +
+
+
+
No. B / 登记簿式
+

纸上台账

+

档案登记簿的秩序感:纸面底色、衬线标题、墨蓝正文、点线引导。出口语义不用高亮色块,而是盖两枚印章——朱砂是直连,藏青是 WARP。数据库本来就是台账,界面照着台账长。

+
+ + +
+ + + +
+ + +
+
+ 新加坡 SG + sg.example.com + REG-2026-001 +
+
    +
  • 01VMess/9d3f01
  • +
  • 02VMess/72e5a9W
  • +
  • 03Trojan/8a20c4
  • +
  • 04Shadowsocks/29c1b3W
  • +
  • 05Hysteria2:443
  • +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
名称协议出口服务器路径
SG-vmess-directVMess直连cc.sg.example.com:443/9d3f01编辑
SG-trojan-warpTrojanWARPcc.sg.example.com:443/93bf25编辑
LA-ss-direct(已停用)Shadowsocks已停用cc.la.example.com:443/3f81b2编辑
+
+
+ + +
+
+
+
方向 C
+

克制暗色

+

保留暗色,但把「AI 味」全部拿掉:中性炭黑而非深蓝、零发光零亮边条、层级全部交给字重与留白。琥珀与青只出现在数据本身(WS 路径)上——颜色只在承载信息时才出现。

+
+ + +
+ + + +
+ + +
+
+ SG · 新加坡 + sg.example.com + 7 条线路 +
+
    +
  • VMess/9d3f01直连
  • +
  • VMess/72e5a9WARP
  • +
  • Trojan/8a20c4直连
  • +
  • Shadowsocks/29c1b3WARP
  • +
  • Hysteria2:443直连
  • +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
名称协议出口服务器路径
SG-vmess-directvmess直连cc.sg.example.com:443/9d3f01编辑
SG-trojan-warptrojanWARPcc.sg.example.com:443/93bf25编辑
LA-ss-direct(已停用)ss已停用cc.la.example.com:443/3f81b2编辑
+
+
+ + +
+
+
+
MODULE D
+

仪表工业

+

Dieter Rams 式仪器面板:冷灰机身、白色面板、实体按键的按压手感、大写字母间距的小标签。出口状态是两枚指示灯——橙灯直连、绿灯 WARP,像老式功放上的通道指示。

+
+ + +
+ + + +
+ + +
+
+ SG + sg.example.com +
+ DIRECT + WARP +
+
+
+
VMess/9d3f01
+
VMess/72e5a9
+
Trojan/8a20c4
+
Shadowsocks/29c1b3
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
名称协议出口服务器路径
SG-vmess-directvmessDirectcc.sg.example.com:443/9d3f01EDIT
SG-trojan-warptrojanWARPcc.sg.example.com:443/93bf25EDIT
LA-ss-direct(已停用)ssOFFcc.la.example.com:443/3f81b2EDIT
+
+
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..532e18d --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "proxy-station", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "concurrently -n server,web -c cyan,yellow \"pnpm --filter @proxy-station/server dev\" \"pnpm --filter @proxy-station/web dev\"", + "build": "pnpm --filter @proxy-station/shared build && pnpm --filter @proxy-station/web build && pnpm --filter @proxy-station/server build", + "typecheck": "pnpm -r typecheck", + "test": "pnpm --filter @proxy-station/server test", + "start": "pnpm --filter @proxy-station/server start" + }, + "devDependencies": { + "concurrently": "^9.1.2" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..7bc0ba2 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2733 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + concurrently: + specifier: ^9.1.2 + version: 9.2.4 + + server: + dependencies: + '@hono/node-server': + specifier: ^1.13.7 + version: 1.19.17(hono@4.13.3) + '@hono/zod-validator': + specifier: ^0.4.2 + version: 0.4.3(hono@4.13.3)(zod@3.25.76) + '@proxy-station/shared': + specifier: workspace:* + version: link:../shared + better-sqlite3: + specifier: ^11.7.0 + version: 11.10.0 + drizzle-orm: + specifier: ^0.38.3 + version: 0.38.4(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0) + hono: + specifier: ^4.6.14 + version: 4.13.3 + nanoid: + specifier: ^5.0.9 + version: 5.1.16 + tsx: + specifier: ^4.19.2 + version: 4.23.12 + undici: + specifier: ^7.2.0 + version: 7.29.0 + yaml: + specifier: ^2.6.1 + version: 2.9.0 + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.12 + version: 7.6.13 + '@types/node': + specifier: ^22.10.2 + version: 22.20.1 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.1) + + shared: + dependencies: + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + typescript: + specifier: ^5.7.2 + version: 5.9.3 + + web: + dependencies: + '@fontsource/ibm-plex-mono': + specifier: ^5.1.0 + version: 5.3.0 + '@proxy-station/shared': + specifier: workspace:* + version: link:../shared + naive-ui: + specifier: ^2.40.4 + version: 2.45.2(vue@3.5.41(typescript@5.9.3)) + pinia: + specifier: ^2.3.0 + version: 2.3.1(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)) + vue: + specifier: ^3.5.13 + version: 3.5.41(typescript@5.9.3) + vue-router: + specifier: ^4.5.0 + version: 4.6.4(vue@3.5.41(typescript@5.9.3)) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^5.2.1 + version: 5.2.4(vite@6.4.3(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^6.0.6 + version: 6.4.3(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0) + vue-tsc: + specifier: ^2.2.0 + version: 2.2.12(typescript@5.9.3) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@css-render/plugin-bem@0.15.14': + resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==} + peerDependencies: + css-render: ~0.15.14 + + '@css-render/vue3-ssr@0.15.14': + resolution: {integrity: sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==} + peerDependencies: + vue: ^3.0.11 + + '@emotion/hash@0.8.0': + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fontsource/ibm-plex-mono@5.3.0': + resolution: {integrity: sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==} + + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@hono/zod-validator@0.4.3': + resolution: {integrity: sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ==} + peerDependencies: + hono: '>=3.9.0' + zod: ^3.19.1 + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@juggle/resize-observer@3.4.0': + resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + + '@rollup/rollup-android-arm-eabi@4.62.5': + resolution: {integrity: sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.5': + resolution: {integrity: sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.5': + resolution: {integrity: sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.5': + resolution: {integrity: sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.5': + resolution: {integrity: sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.5': + resolution: {integrity: sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.5': + resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.5': + resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.5': + resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.5': + resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.5': + resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.5': + resolution: {integrity: sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + resolution: {integrity: sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + resolution: {integrity: sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.5': + resolution: {integrity: sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.5': + resolution: {integrity: sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==} + cpu: [x64] + os: [win32] + + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.41': + resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} + + '@vue/compiler-dom@3.5.41': + resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} + + '@vue/compiler-sfc@3.5.41': + resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==} + + '@vue/compiler-ssr@3.5.41': + resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.41': + resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==} + + '@vue/runtime-core@3.5.41': + resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==} + + '@vue/runtime-dom@3.5.41': + resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==} + + '@vue/server-renderer@3.5.41': + resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==} + + '@vue/shared@3.5.41': + resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@11.10.0: + resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concurrently@9.2.4: + resolution: {integrity: sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==} + engines: {node: '>=18'} + hasBin: true + + css-render@0.15.14: + resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==} + + csstype@3.0.11: + resolution: {integrity: sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + date-fns-tz@3.2.0: + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} + peerDependencies: + date-fns: ^3.0.0 || ^4.0.0 + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + drizzle-orm@0.38.4: + resolution: {integrity: sha512-s7/5BpLKO+WJRHspvpqTydxFob8i1vo2rEx4pY6TGY7QSMuUfWUuzaY0DIpXCkgHOo37BaFC+SJQb99dDUXT3Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/react': '>=18' + '@types/sql.js': '*' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + react: '>=18' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/react': + optional: true + '@types/sql.js': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + react: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + evtd@0.2.4: + resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + highlight.js@11.12.0: + resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} + engines: {node: '>=12.0.0'} + + hono@4.13.3: + resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==} + engines: {node: '>=16.9.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + naive-ui@2.45.2: + resolution: {integrity: sha512-KshetbFOX/uZ/Pe+60hJoUAo47x5QO1JpZaUVPQCQkNhFfJ7hKsX55A8oMFQHccEpLuQUMPkJ41cX94R4nWUjg==} + engines: {node: '>=20'} + peerDependencies: + vue: ^3.0.0 + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + rollup@4.62.5: + resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + seemly@0.3.10: + resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shell-quote@1.9.0: + resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + treemate@0.3.11: + resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vdirs@0.1.8: + resolution: {integrity: sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==} + peerDependencies: + vue: ^3.0.11 + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vooks@0.2.12: + resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==} + peerDependencies: + vue: ^3.0.0 + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.41: + resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + vueuc@0.4.66: + resolution: {integrity: sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg==} + peerDependencies: + vue: ^3.0.11 + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@css-render/plugin-bem@0.15.14(css-render@0.15.14)': + dependencies: + css-render: 0.15.14 + + '@css-render/vue3-ssr@0.15.14(vue@3.5.41(typescript@5.9.3))': + dependencies: + vue: 3.5.41(typescript@5.9.3) + + '@emotion/hash@0.8.0': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@fontsource/ibm-plex-mono@5.3.0': {} + + '@hono/node-server@1.19.17(hono@4.13.3)': + dependencies: + hono: 4.13.3 + + '@hono/zod-validator@0.4.3(hono@4.13.3)(zod@3.25.76)': + dependencies: + hono: 4.13.3 + zod: 3.25.76 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@juggle/resize-observer@3.4.0': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.62.5': + optional: true + + '@rollup/rollup-android-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-x64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.5': + optional: true + + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 22.20.1 + + '@types/estree@1.0.9': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.25 + + '@types/lodash@4.17.25': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3))': + dependencies: + vite: 6.4.3(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0) + vue: 3.5.41(typescript@5.9.3) + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.20.1))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.20.1) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.41 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.41': + dependencies: + '@vue/compiler-core': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/compiler-sfc@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.41 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-ssr': 3.5.41 + '@vue/shared': 3.5.41 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.41': + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.41 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.41': + dependencies: + '@vue/shared': 3.5.41 + + '@vue/runtime-core@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/runtime-dom@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/runtime-core': 3.5.41 + '@vue/shared': 3.5.41 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.41': + dependencies: + '@vue/compiler-ssr': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/shared@3.5.41': {} + + alien-signals@1.0.13: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + assertion-error@2.0.1: {} + + async-validator@4.2.5: {} + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + better-sqlite3@11.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + chownr@1.1.4: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concurrently@9.2.4: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.9.0 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + css-render@0.15.14: + dependencies: + '@emotion/hash': 0.8.0 + csstype: 3.0.11 + + csstype@3.0.11: {} + + csstype@3.2.3: {} + + date-fns-tz@3.2.0(date-fns@4.4.0): + dependencies: + date-fns: 4.4.0 + + date-fns@4.4.0: {} + + de-indent@1.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-eql@5.0.2: {} + + deep-extend@0.6.0: {} + + detect-libc@2.1.2: {} + + drizzle-orm@0.38.4(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0): + optionalDependencies: + '@types/better-sqlite3': 7.6.13 + better-sqlite3: 11.10.0 + + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + entities@7.0.1: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + evtd@0.2.4: {} + + expand-template@2.0.3: {} + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-uri-to-path@1.0.0: {} + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + github-from-package@0.0.0: {} + + has-flag@4.0.0: {} + + he@1.2.0: {} + + highlight.js@11.12.0: {} + + hono@4.13.3: {} + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + is-fullwidth-code-point@3.0.0: {} + + lodash-es@4.18.1: {} + + lodash@4.18.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mimic-response@3.1.0: {} + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + naive-ui@2.45.2(vue@3.5.41(typescript@5.9.3)): + dependencies: + '@css-render/plugin-bem': 0.15.14(css-render@0.15.14) + '@css-render/vue3-ssr': 0.15.14(vue@3.5.41(typescript@5.9.3)) + '@types/lodash': 4.17.25 + '@types/lodash-es': 4.17.12 + async-validator: 4.2.5 + css-render: 0.15.14 + csstype: 3.2.3 + date-fns: 4.4.0 + date-fns-tz: 3.2.0(date-fns@4.4.0) + evtd: 0.2.4 + highlight.js: 11.12.0 + lodash: 4.18.1 + lodash-es: 4.18.1 + seemly: 0.3.10 + treemate: 0.3.11 + vdirs: 0.1.8(vue@3.5.41(typescript@5.9.3)) + vooks: 0.2.12(vue@3.5.41(typescript@5.9.3)) + vue: 3.5.41(typescript@5.9.3) + vueuc: 0.4.66(vue@3.5.41(typescript@5.9.3)) + + nanoid@3.3.18: {} + + nanoid@5.1.16: {} + + napi-build-utils@2.0.0: {} + + node-abi@3.94.0: + dependencies: + semver: 7.8.5 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + path-browserify@1.0.1: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pinia@2.3.1(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.41(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.41(typescript@5.9.3)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@vue/composition-api' + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + require-directory@2.1.1: {} + + rollup@4.62.5: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.5 + '@rollup/rollup-android-arm64': 4.62.5 + '@rollup/rollup-darwin-arm64': 4.62.5 + '@rollup/rollup-darwin-x64': 4.62.5 + '@rollup/rollup-freebsd-arm64': 4.62.5 + '@rollup/rollup-freebsd-x64': 4.62.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.5 + '@rollup/rollup-linux-arm-musleabihf': 4.62.5 + '@rollup/rollup-linux-arm64-gnu': 4.62.5 + '@rollup/rollup-linux-arm64-musl': 4.62.5 + '@rollup/rollup-linux-loong64-gnu': 4.62.5 + '@rollup/rollup-linux-loong64-musl': 4.62.5 + '@rollup/rollup-linux-ppc64-gnu': 4.62.5 + '@rollup/rollup-linux-ppc64-musl': 4.62.5 + '@rollup/rollup-linux-riscv64-gnu': 4.62.5 + '@rollup/rollup-linux-riscv64-musl': 4.62.5 + '@rollup/rollup-linux-s390x-gnu': 4.62.5 + '@rollup/rollup-linux-x64-gnu': 4.62.5 + '@rollup/rollup-linux-x64-musl': 4.62.5 + '@rollup/rollup-openbsd-x64': 4.62.5 + '@rollup/rollup-openharmony-arm64': 4.62.5 + '@rollup/rollup-win32-arm64-msvc': 4.62.5 + '@rollup/rollup-win32-ia32-msvc': 4.62.5 + '@rollup/rollup-win32-x64-gnu': 4.62.5 + '@rollup/rollup-win32-x64-msvc': 4.62.5 + fsevents: 2.3.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + seemly@0.3.10: {} + + semver@7.8.5: {} + + shell-quote@1.9.0: {} + + siginfo@2.0.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@2.0.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tree-kill@1.2.2: {} + + treemate@0.3.11: {} + + tslib@2.8.1: {} + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + undici@7.29.0: {} + + util-deprecate@1.0.2: {} + + vdirs@0.1.8(vue@3.5.41(typescript@5.9.3)): + dependencies: + evtd: 0.2.4 + vue: 3.5.41(typescript@5.9.3) + + vite-node@2.1.9(@types/node@22.20.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.20.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.20.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.26 + rollup: 4.62.5 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + + vite@6.4.3(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + tsx: 4.23.12 + yaml: 2.9.0 + + vitest@2.1.9(@types/node@22.20.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.20.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.20.1) + vite-node: 2.1.9(@types/node@22.20.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vooks@0.2.12(vue@3.5.41(typescript@5.9.3)): + dependencies: + evtd: 0.2.4 + vue: 3.5.41(typescript@5.9.3) + + vscode-uri@3.1.0: {} + + vue-demi@0.14.10(vue@3.5.41(typescript@5.9.3)): + dependencies: + vue: 3.5.41(typescript@5.9.3) + + vue-router@4.6.4(vue@3.5.41(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.41(typescript@5.9.3) + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue@3.5.41(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-sfc': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/server-renderer': 3.5.41 + '@vue/shared': 3.5.41 + optionalDependencies: + typescript: 5.9.3 + + vueuc@0.4.66(vue@3.5.41(typescript@5.9.3)): + dependencies: + '@css-render/vue3-ssr': 0.15.14(vue@3.5.41(typescript@5.9.3)) + '@juggle/resize-observer': 3.4.0 + css-render: 0.15.14 + evtd: 0.2.4 + seemly: 0.3.10 + vdirs: 0.1.8(vue@3.5.41(typescript@5.9.3)) + vooks: 0.2.12(vue@3.5.41(typescript@5.9.3)) + vue: 3.5.41(typescript@5.9.3) + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + y18n@5.0.8: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..779d1b2 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +packages: + - shared + - server + - web +allowBuilds: + better-sqlite3: true + esbuild: true + vue-demi: true diff --git a/server/assets/surge-template.conf b/server/assets/surge-template.conf new file mode 100644 index 0000000..671fd30 --- /dev/null +++ b/server/assets/surge-template.conf @@ -0,0 +1,303 @@ +[General] +# --- GENERAL / 通用设置 --- +# Enhanced Wi-Fi Assist / 增强 Wi-Fi 助手 +wifi-assist = true +# Hybrid Network / 混合网络 +all-hybrid = false +# Gaming Optimization / 游戏优化 +udp-priority = false +# Latency Benchmark / 延迟基准测试 +internet-test-url = http://connectivitycheck.gstatic.com/generate_204 +proxy-test-url = http://captive.apple.com/generate_204 +test-timeout = 3 +# GeoIP Database / GeoIP 数据库 +geoip-maxmind-url = https://github.com/Hackl0us/GeoIP2-CN/raw/release/Country.mmdb +# IPv6 Support / IPv6 支持 +ipv6 = false +ipv6-vif = auto +# --- Wi-Fi ACCESS / Wi-Fi 访问 --- +allow-wifi-access = false +wifi-access-http-port = 6152 +wifi-access-socks5-port = 6153 +http-listen = 0.0.0.0 +socks5-listen = 0.0.0.0 +allow-hotspot-access = true +# --- REMOTE CONTROLLER / 远程控制器 --- +external-controller-access = surge@0.0.0.0:6160 +http-api = clashconnectrules@0.0.0.0:6166 +http-api-tls = true +http-api-web-dashboard = true +# --- COMPATIBILITY / 兼容性 --- +compatibility-mode = 1 +# --- NETWORK / 网络 --- +skip-proxy = localhost, *.local, captive.apple.com, guzzoni.apple.com, 0.0.0.0/8, 10.0.0.0/8, 17.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 192.88.99.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4, 255.255.255.255/32 +exclude-simple-hostnames = true +read-etc-hosts = true +# --- DNS / 域名系统 --- +use-local-host-item-for-proxy = true +encrypted-dns-follow-outbound-mode = false +include-all-networks = false +include-local-networks = false +dns-server = 223.5.5.5, 223.6.6.6, 119.29.29.29 +encrypted-dns-server = https://doh.pub/dns-query, https://dns.alidns.com/dns-query +loglevel = notify +# --- ADVANCED / 高级设置 --- +show-error-page-for-reject = true +force-http-engine-hosts = *.ott.cibntv.net, 123.59.31.1, 119.18.193.135, 122.14.246.33, 175.102.178.52, 116.253.24.*, 175.6.26.*, 220.169.153.* +always-real-ip = link-ip.nextdns.io, *.msftconnecttest.com, *.msftncsi.com, *.srv.nintendo.net, *.stun.playstation.net, xbox.*.microsoft.com, *.xboxlive.com, *.logon.battlenet.com.cn, *.logon.battle.net, stun.l.google.com +hijack-dns = 8.8.8.8:53, 8.8.4.4:53 +udp-policy-not-supported-behaviour = REJECT +proxy-restricted-to-lan = false + +[Proxy] + + + +[Proxy Group] +# ==================== 核心策略分组 / Core Policy Groups ==================== +Mainland = select, DIRECT, no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/05icon/direct.png + +AllServer = select, policy-path="Your Surge subscription link goes here", update-interval=86400, no-alert=0, hidden=0, include-all-proxies=0, policy-regex-filter=^((?!Remain|Expired|官网|如需|套餐|去除|剩余|距离|Reset|重置|流量).)+$, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/05icon/rocket(1).png + +# ==================== 地区自动选择组 / Region Auto-Select Groups ==================== +Hong Kong = url-test, update-interval=0, policy-regex-filter=港|🇭🇰|香港|HK|Hong, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/Hongkong.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3 + +Taiwan = url-test, update-interval=0, policy-regex-filter=台|🇹🇼|台湾|TW|Tai, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/taiwan.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3 + +Japan = url-test, update-interval=0, policy-regex-filter=日|🇯🇵|日本|JP|Japan, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/Japan.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3 + +Singapore = url-test, update-interval=0, policy-regex-filter=坡|🇸🇬|新加坡|狮城|SG|Singapore, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/singapore.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3 + +United States = url-test, update-interval=0, policy-regex-filter=美|🇺🇸|美国|US|States|American, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/US.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3 + +United Kingdom = url-test, update-interval=0, policy-regex-filter=🇬🇧|英国|英|英格兰|UK|United Kingdom, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/UnitedKingdom.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3 + +Korea = url-test, update-interval=0, policy-regex-filter=韩|韩国|Korea|KR|🇰🇷, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/Korea.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3 + +Other = url-test, update-interval=0, policy-regex-filter=^((?!港|🇭🇰|香港|HK|Hong|台|🇹🇼|台湾|TW|Tai|日|🇯🇵|日本|JP|Japan|坡|🇸🇬|新加坡|狮城|SG|Singapore|美|🇺🇸|美国|US|States|American|🇬🇧|英国|英|英格兰|UK|United Kingdom|韩|韩国|Korea|KR|🇰🇷|Remain|Expired|官网|如需|套餐|去除|剩余|距离|Reset|重置|流量).)+$, no-alert=0, hidden=0, include-all-proxies=0, include-other-group=AllServer, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/01Country/EuropeanUnion.png, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3 + +# ==================== 组合策略 / Combined Policies ==================== +Automatic = select, "Hong Kong", Taiwan, Japan, Singapore, "United States", "United Kingdom", Korea, url=http://cp.cloudflare.com/generate_204, interval=300, tolerance=50, timeout=3, no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/05icon/lightning(5).png + +Proxy = select, Automatic, "Hong Kong", Taiwan, Singapore, Japan, "United States", "United Kingdom", no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/05icon/rocket(1).png + +NoAuto = select, Mainland, Automatic, no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/05icon/lightning.png + +# ==================== 应用服务策略 / App Service Policies ==================== +# --- 苹果服务 / Apple Services --- +Apple = select, Mainland, "Hong Kong", "United States", icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/03CNSoft/apple.png + +# --- AI 服务 / AI Services --- +AI = select, Automatic, "United States", Japan, Singapore, no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/04ProxySoft/chatgpt(1).png + +# --- 微软服务 / Microsoft Services --- +Microsoft = select, Mainland, "Hong Kong", Singapore, "United States", icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/03CNSoft/microsoft.png +OneDrive = select, Mainland, "Hong Kong", Singapore, "United States", icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/03CNSoft/OneDrive.png + +# --- 社交通讯 / Social & Messaging --- +Telegram = select, Automatic, Singapore, "United States", "Hong Kong", Taiwan, Japan, no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/04ProxySoft/telegram.png +WeChat = select, Mainland, "Hong Kong", Singapore, "United States", icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/03CNSoft/wechat.png +X = select, Automatic, "Hong Kong", Taiwan, Singapore, Japan, "United States", no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/04ProxySoft/twitter.png + +# --- 流媒体服务 / Streaming Services --- +Bilibili = select, Mainland, "Hong Kong", Taiwan, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/03CNSoft/bilibili.png +Disney+ = select, "Hong Kong", Singapore, no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/04ProxySoft/disney.png +Netflix = select, "Hong Kong", Taiwan, Singapore, Japan, "United States", icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/04ProxySoft/netflix.png +TikTok = select, Taiwan, Singapore, Japan, "United States", no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/04ProxySoft/tiktok.png +YouTube = select, Automatic, "Hong Kong", Taiwan, Singapore, Japan, "United States", no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/04ProxySoft/Youtube.png + +# --- 工具服务 / Utility Services --- +Speedtest = select, Mainland, Automatic, AllServer, no-alert=0, hidden=0, include-all-proxies=0, icon-url=https://raw.githubusercontent.com/lige47/QuanX-icon-rule/main/icon/03CNSoft/speedtest.png + + +[Rule] +# >> 规则修正 (Unbreak Rules) +RULE-SET,https://raw.githubusercontent.com/zxfccmm4/Profiles/refs/heads/main/Surge/Ruleset/Unbreak.list,DIRECT + +# >> 广告拦截 (Ad Blocking) +DOMAIN-SET,https://ruleset.skk.moe/List/domainset/reject.conf,REJECT +RULE-SET,https://ruleset.skk.moe/List/non_ip/reject-drop.conf,REJECT-DROP +RULE-SET,https://ruleset.skk.moe/List/non_ip/reject.conf,REJECT +RULE-SET,https://ruleset.skk.moe/List/ip/reject.conf,REJECT-DROP + +# >> 隐私保护 (Privacy Protection) +DOMAIN,app-site-association.cdn-apple.com,REJECT +DOMAIN,prpr.96110.cn.com,DIRECT +DOMAIN-KEYWORD,96110,REJECT +DOMAIN-SUFFIX,gjfzpt.cn,REJECT + +# >> 国内应用 (Mainland Apps) +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/WeChat/WeChat.list,WeChat +RULE-SET,https://ruleset.skk.moe/List/non_ip/neteasemusic.conf,Mainland +RULE-SET,https://ruleset.skk.moe/List/ip/neteasemusic.conf,Mainland +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/BiliBili/BiliBili.list,Bilibili +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Weibo/Weibo.list,Mainland +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/XiaoHongShu/XiaoHongShu.list,Mainland + +# >> Apple 服务 (Apple Services) +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/AppStore/AppStore.list,DIRECT +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/AppleNews/AppleNews.list,"United States" +DOMAIN-SET,https://ruleset.skk.moe/List/domainset/apple_cdn.conf,Apple +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/AppleTV/AppleTV.list,Apple +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Apple/Apple.list,Apple + +# >> AI 服务 (AI Services) +DOMAIN-KEYWORD,diabrowser.engineering,AI +DOMAIN-SUFFIX,apple-relay.apple.com,AI +DOMAIN-SUFFIX,apple-relay.cloudflare.com,AI +DOMAIN-KEYWORD,apple-relay,AI +DOMAIN-SUFFIX,googleapis.com,AI +RULE-SET,https://ruleset.skk.moe/List/non_ip/ai.conf,AI +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Bing/Bing.list,AI +DOMAIN-SUFFIX,diabrowser.engineering,AI + +# >> 国外流媒体 (International Streaming) +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Disney/Disney.list,Disney+ +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Netflix/Netflix.list,Netflix +RULE-SET,https://raw.githubusercontent.com/Semporia/TikTok-Unlock/master/Surge/TikTok.list,TikTok +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/YouTube/YouTube.list,YouTube +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/GoogleSearch/GoogleSearch.list,YouTube +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Emby/Emby.list,Automatic + +# >> 地区流媒体解锁 (Regional Streaming Unlock) +RULE-SET,https://ruleset.skk.moe/List/non_ip/stream_us.conf,"United States" +RULE-SET,https://ruleset.skk.moe/List/ip/stream_us.conf,"United States" +RULE-SET,https://ruleset.skk.moe/List/non_ip/stream_eu.conf,"United Kingdom" +RULE-SET,https://ruleset.skk.moe/List/ip/stream_eu.conf,"United Kingdom" +RULE-SET,https://ruleset.skk.moe/List/non_ip/stream_jp.conf,Japan +RULE-SET,https://ruleset.skk.moe/List/ip/stream_jp.conf,Japan +RULE-SET,https://ruleset.skk.moe/List/non_ip/stream_kr.conf,Korea +RULE-SET,https://ruleset.skk.moe/List/ip/stream_kr.conf,Korea +RULE-SET,https://ruleset.skk.moe/List/non_ip/stream_hk.conf,"Hong Kong" +RULE-SET,https://ruleset.skk.moe/List/ip/stream_hk.conf,"Hong Kong" +RULE-SET,https://ruleset.skk.moe/List/non_ip/stream_tw.conf,Taiwan +RULE-SET,https://ruleset.skk.moe/List/ip/stream_tw.conf,Taiwan + +# >> 国外社交媒体 (International Social Media) +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Twitter/Twitter.list,X +RULE-SET,https://ruleset.skk.moe/List/non_ip/telegram.conf,Telegram +RULE-SET,https://ruleset.skk.moe/List/ip/telegram.conf,Telegram +RULE-SET,https://ruleset.skk.moe/List/ip/telegram_asn.conf,Telegram +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Facebook/Facebook.list,Automatic +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Instagram/Instagram.list,Automatic +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Threads/Threads.list,Automatic +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Discord/Discord.list,Automatic + +# >> 其他国外服务 (Other International Services) +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/OneDrive/OneDrive.list,OneDrive +RULE-SET,https://ruleset.skk.moe/List/non_ip/microsoft.conf,Microsoft +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/GitHub/GitHub.list,Automatic +RULE-SET,https://raw.githubusercontent.com/getsomecat/GetSomeCats/Surge/rule/substore.list,Automatic +DOMAIN-SUFFIX,vercel.app,Automatic +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Speedtest/Speedtest.list,Speedtest +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Riot/Riot.list,"United States" +DOMAIN-SUFFIX,mypikpak.com,"United States" +DOMAIN-SUFFIX,nssurge.com,Automatic +DOMAIN-SUFFIX,surgee.me,Automatic +DOMAIN-SUFFIX,www.torrentmac.net,Automatic +DOMAIN-SUFFIX,cloudflare.com,Automatic +DOMAIN,jable.tv,Automatic +DOMAIN,api.amplitude.com,Automatic +DOMAIN,m.cmx.im,Automatic +DOMAIN-SUFFIX,api.themoviedb.org,Automatic + +# >> 国内规则 (Mainland China Rules) +RULE-SET,https://ruleset.skk.moe/List/non_ip/direct.conf,Mainland +RULE-SET,https://ruleset.skk.moe/List/non_ip/domestic.conf,Mainland +RULE-SET,https://ruleset.skk.moe/List/ip/domestic.conf,Mainland +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/ChinaMax/ChinaMax.list,Mainland +RULE-SET,https://raw.githubusercontent.com/VirgilClyne/GetSomeFries/main/ruleset/ASN.China.list,DIRECT +DOMAIN-SET,https://ruleset.skk.moe/List/domainset/download.conf,Mainland +RULE-SET,https://ruleset.skk.moe/List/non_ip/download.conf,Mainland +RULE-SET,https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Tesla/Tesla.list,DIRECT +DOMAIN-SUFFIX,appstorrent.ru,DIRECT + +# >> 国外规则 (Global Rules) +DOMAIN-SET,https://ruleset.skk.moe/List/domainset/cdn.conf,Automatic +RULE-SET,https://ruleset.skk.moe/List/non_ip/cdn.conf,Automatic +RULE-SET,https://ruleset.skk.moe/List/non_ip/global_plus.conf,Automatic +RULE-SET,https://ruleset.skk.moe/List/non_ip/global.conf,Automatic + +# >> 网络优化与特殊规则 (Network Optimization) +AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP +DOMAIN-SUFFIX,smtp,DIRECT +URL-REGEX,(Subject|HELO|SMTP),DIRECT + +# >> 本地网络 (LAN) +RULE-SET,LAN,DIRECT + +# >> 兜底规则 (Final Rule) +FINAL,NoAuto,dns-failed + +[Host] +*.taobao.com = server:223.5.5.5 +*.tmall.com = server:223.5.5.5 +*.alipay.com = server:223.5.5.5 +*.alicdn.com = server:223.5.5.5 +*.aliyun.com = server:223.5.5.5 +*.jd.com = server:119.28.28.28 +*.qq.com = server:119.28.28.28 +*.tencent.com = server:119.28.28.28 +*.weixin.com = server:119.28.28.28 +*.bilibili.com = server:119.29.29.29 +hdslb.com = server:119.29.29.29 +*.163.com = server:119.29.29.29 +*.126.com = server:119.29.29.29 +*.126.net = server:119.29.29.29 +*.127.net = server:119.29.29.29 +*.netease.com = server:119.29.29.29 +*.mi.com = server:119.29.29.29 +*.xiaomi.com = server:119.29.29.29 +*testflight.apple.com = server:8.8.4.4 +# > Firebase Cloud Messaging / Firebase 云消息 +mtalk.google.com = 108.177.125.188 +# > Google Download / Google 下载 +dl.google.com = server:119.29.29.29 +dl.l.google.com = server:119.29.29.29 +update.googleapis.com = server:119.29.29.29 +# > Router Admin Panel / 路由器管理面板 +amplifi.lan = server:system // Ubiquiti Amplifi Router +router.synology.com = server:system // Synology Router +sila.razer.com = server:system // Razer Sila Router +router.asus.com = server:system // Asus Router +routerlogin.net = server:system // Netgear Router +orbilogin.com = server:system // Netgear Obri Router +www.LinksysSmartWiFi.com = server:system // Linksys Router +LinksysSmartWiFi.com = server:system // Linksys Router +myrouter.local = server:system // Linksys Router +www.miwifi.com = server:system // Xiaomi Mi WiFi Router +miwifi.com = server:system // Xiaomi Mi WiFi Router +mediarouter.home = server:system // Huawei Router +tplogin.cn = server:system // TP-Link Router +tplinklogin.net = server:system // TP-Link Router +melogin.cn = server:system // MERCURY Router +falogin.cn = server:system // FAST Router + +[URL Rewrite] +# > Redirect Google Services / 重定向 Google 服务 +^https?:\/\/(www\.)?(g|google)\.cn https://www.google.com 302 +^https?:\/\/(ditu|maps)\.google\.cn https://maps.google.com 302 + +# > HTTPS Redirects / HTTPS 重定向 +^https?:\/\/(www\.)?taobao\.com\/ https://taobao.com/ 302 +^https?:\/\/(www\.)?jd\.com\/ https://www.jd.com/ 302 +^https?:\/\/(www\.)?mi\.com\/ https://www.mi.com/ 302 +^https?:\/\/you\.163\.com\/ https://you.163.com/ 302 +^https?:\/\/(www\.)?suning\.com\/ https://suning.com/ 302 +^https?:\/\/(www\.)?yhd\.com\/ https://yhd.com/ 302 + +# > Service Optimizations / 服务优化 +^https?:\/\/api\.abema\.io\/v\d\/ip\/check - reject + +[Header Rewrite] +# 解决github速率限制返回429问题 / Fix GitHub 429 Rate Limit Issue +http-request ^https:\/\/github\.com\/ header-replace Accept-Language en-us +http-request ^https:\/\/(raw|gist)\.githubusercontent\.com\/ header-replace Accept-Language en-us + +[MITM] +skip-server-cert-verify = true +tcp-connection = true +h2 = true +hostname = www.google.cn, api.abema.io, *.zhihu.com, -CUSTOMMitM, sub.store +[Script] +# > Flush DNS, show the DNS delay and server / 刷新 DNS,显示延迟和服务器 diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..08e2546 --- /dev/null +++ b/server/package.json @@ -0,0 +1,32 @@ +{ + "name": "@proxy-station/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc --noEmit", + "start": "tsx src/index.ts", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@hono/node-server": "^1.13.7", + "@hono/zod-validator": "^0.4.2", + "@proxy-station/shared": "workspace:*", + "better-sqlite3": "^11.7.0", + "drizzle-orm": "^0.38.3", + "hono": "^4.6.14", + "nanoid": "^5.0.9", + "tsx": "^4.19.2", + "undici": "^7.2.0", + "yaml": "^2.6.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.10.2", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/server/src/db/index.ts b/server/src/db/index.ts new file mode 100644 index 0000000..459b088 --- /dev/null +++ b/server/src/db/index.ts @@ -0,0 +1,125 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { env } from '../env.js'; +import * as schema from './schema.js'; + +const BOOTSTRAP_SQL = ` +CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + protocol TEXT NOT NULL, + server TEXT NOT NULL, + port INTEGER NOT NULL, + network TEXT NOT NULL DEFAULT 'tcp', + ws_path TEXT, + ws_host TEXT, + tls INTEGER NOT NULL DEFAULT 0, + sni TEXT, + skip_cert_verify INTEGER NOT NULL DEFAULT 0, + uuid TEXT, + alter_id INTEGER NOT NULL DEFAULT 0, + security TEXT NOT NULL DEFAULT 'auto', + password TEXT, + method TEXT, + obfs_type TEXT, + obfs_password TEXT, + up_mbps INTEGER, + down_mbps INTEGER, + region TEXT, + tags TEXT NOT NULL DEFAULT '[]', + enabled INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + extra TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS policy_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + test_url TEXT, + interval INTEGER, + tolerance INTEGER, + filter_regex TEXT, + include_all_nodes INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'custom' +); +CREATE TABLE IF NOT EXISTS group_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id TEXT NOT NULL REFERENCES policy_groups(id) ON DELETE CASCADE, + member_kind TEXT NOT NULL, + node_id TEXT REFERENCES nodes(id) ON DELETE CASCADE, + ref_name TEXT, + sort_order INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS rules ( + id TEXT PRIMARY KEY, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + value TEXT, + policy TEXT NOT NULL, + params TEXT NOT NULL DEFAULT '[]', + enabled INTEGER NOT NULL DEFAULT 1, + source TEXT NOT NULL DEFAULT 'custom' +); +CREATE TABLE IF NOT EXISTS rulesets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + surge_url TEXT NOT NULL UNIQUE, + clash_url TEXT, + clash_behavior TEXT NOT NULL DEFAULT 'classical', + clash_format TEXT NOT NULL DEFAULT 'yaml', + needs_convert INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS ruleset_cache ( + url TEXT PRIMARY KEY, + content TEXT NOT NULL, + etag TEXT, + fetched_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS sub_tokens ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + node_ids TEXT NOT NULL DEFAULT '[]', + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + last_access_at INTEGER, + access_count INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +`; + +export function createDb(dbPath?: string) { + const file = dbPath ?? path.join(env.dataDir, 'proxy-station.db'); + if (file !== ':memory:') { + fs.mkdirSync(path.dirname(file), { recursive: true }); + } + const sqlite = new Database(file); + sqlite.pragma('journal_mode = WAL'); + sqlite.pragma('foreign_keys = ON'); + sqlite.exec(BOOTSTRAP_SQL); + // 轻量迁移:旧库补列 + for (const stmt of [ + 'ALTER TABLE policy_groups ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1', + "ALTER TABLE sub_tokens ADD COLUMN node_ids TEXT NOT NULL DEFAULT '[]'", + ]) { + try { + sqlite.exec(stmt); + } catch { + // 列已存在 + } + } + return drizzle(sqlite, { schema }); +} + +export type Db = ReturnType; + +export const db = createDb(); diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts new file mode 100644 index 0000000..40a4588 --- /dev/null +++ b/server/src/db/schema.ts @@ -0,0 +1,101 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +export const nodes = sqliteTable('nodes', { + id: text('id').primaryKey(), + name: text('name').notNull().unique(), + protocol: text('protocol', { enum: ['vmess', 'trojan', 'shadowsocks', 'hysteria2'] }).notNull(), + server: text('server').notNull(), + port: integer('port').notNull(), + network: text('network', { enum: ['tcp', 'ws'] }).notNull().default('tcp'), + wsPath: text('ws_path'), + wsHost: text('ws_host'), + tls: integer('tls', { mode: 'boolean' }).notNull().default(false), + sni: text('sni'), + skipCertVerify: integer('skip_cert_verify', { mode: 'boolean' }).notNull().default(false), + uuid: text('uuid'), + alterId: integer('alter_id').notNull().default(0), + security: text('security').notNull().default('auto'), + password: text('password'), + method: text('method'), + obfsType: text('obfs_type'), + obfsPassword: text('obfs_password'), + upMbps: integer('up_mbps'), + downMbps: integer('down_mbps'), + region: text('region'), + tags: text('tags').notNull().default('[]'), + enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), + sortOrder: integer('sort_order').notNull().default(0), + extra: text('extra').notNull().default('{}'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), +}); + +export const policyGroups = sqliteTable('policy_groups', { + id: text('id').primaryKey(), + name: text('name').notNull().unique(), + type: text('type', { enum: ['select', 'url-test', 'fallback'] }).notNull(), + testUrl: text('test_url'), + interval: integer('interval'), + tolerance: integer('tolerance'), + filterRegex: text('filter_regex'), + includeAllNodes: integer('include_all_nodes', { mode: 'boolean' }).notNull().default(false), + enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), + sortOrder: integer('sort_order').notNull().default(0), + source: text('source', { enum: ['template', 'custom'] }).notNull().default('custom'), +}); + +export const groupMembers = sqliteTable('group_members', { + id: integer('id').primaryKey({ autoIncrement: true }), + groupId: text('group_id') + .notNull() + .references(() => policyGroups.id, { onDelete: 'cascade' }), + memberKind: text('member_kind', { enum: ['node', 'group', 'builtin'] }).notNull(), + nodeId: text('node_id').references(() => nodes.id, { onDelete: 'cascade' }), + refName: text('ref_name'), + sortOrder: integer('sort_order').notNull().default(0), +}); + +export const rules = sqliteTable('rules', { + id: text('id').primaryKey(), + seq: integer('seq').notNull(), + type: text('type').notNull(), + value: text('value'), + policy: text('policy').notNull(), + params: text('params').notNull().default('[]'), + enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), + source: text('source', { enum: ['template', 'custom'] }).notNull().default('custom'), +}); + +export const rulesets = sqliteTable('rulesets', { + id: text('id').primaryKey(), + name: text('name').notNull(), + surgeUrl: text('surge_url').notNull().unique(), + clashUrl: text('clash_url'), + clashBehavior: text('clash_behavior').notNull().default('classical'), + clashFormat: text('clash_format').notNull().default('yaml'), + needsConvert: integer('needs_convert', { mode: 'boolean' }).notNull().default(false), +}); + +export const rulesetCache = sqliteTable('ruleset_cache', { + url: text('url').primaryKey(), + content: text('content').notNull(), + etag: text('etag'), + fetchedAt: integer('fetched_at').notNull(), +}); + +export const subTokens = sqliteTable('sub_tokens', { + id: text('id').primaryKey(), + token: text('token').notNull().unique(), + name: text('name').notNull(), + /** 分配给该订阅的节点 id(JSON 数组);空数组 = 不限制,下发全部启用节点 */ + nodeIds: text('node_ids').notNull().default('[]'), + enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), + createdAt: integer('created_at').notNull(), + lastAccessAt: integer('last_access_at'), + accessCount: integer('access_count').notNull().default(0), +}); + +export const settings = sqliteTable('settings', { + key: text('key').primaryKey(), + value: text('value').notNull(), +}); diff --git a/server/src/db/seed.ts b/server/src/db/seed.ts new file mode 100644 index 0000000..97f0382 --- /dev/null +++ b/server/src/db/seed.ts @@ -0,0 +1,131 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { nanoid } from 'nanoid'; +import { eq } from 'drizzle-orm'; +import { env } from '../env.js'; +import type { Db } from './index.js'; +import { policyGroups, groupMembers, rules, rulesets, settings } from './schema.js'; +import { parseSurgeConf, type ParsedGroup } from '../services/template/surge-parser.js'; +import { mapRulesetUrl, rulesetNameFromUrl } from '../services/rulesets/mapping.js'; + +const TEMPLATE_PATH = path.resolve(env.serverRoot, 'assets/surge-template.conf'); + +const BUILTIN_POLICIES = new Set(['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP', 'REJECT-TINYGIF']); + +function seedGroups(db: Db, groups: ParsedGroup[], source: 'template' | 'custom') { + groups.forEach((g, i) => { + const groupId = nanoid(10); + db.insert(policyGroups) + .values({ + id: groupId, + name: g.name, + type: g.type, + testUrl: g.testUrl ?? null, + interval: g.interval ?? null, + tolerance: g.tolerance ?? null, + filterRegex: g.filterRegex ?? null, + // AllServer 在模板中挂上游订阅(policy-path),在本系统中即「全部节点」组 + includeAllNodes: !!(g.hasPolicyPath || g.includeAllProxies), + sortOrder: i, + source, + }) + .run(); + const members = [...g.members]; + if (g.includeOtherGroup && !members.includes(g.includeOtherGroup)) { + members.push(g.includeOtherGroup); + } + members.forEach((m, j) => { + db.insert(groupMembers) + .values({ + groupId, + memberKind: BUILTIN_POLICIES.has(m) ? 'builtin' : 'group', + nodeId: null, + refName: m, + sortOrder: j, + }) + .run(); + }); + }); +} + +export function seedRulesFromTemplate(db: Db, opts: { keepCustom: boolean }) { + const text = fs.readFileSync(TEMPLATE_PATH, 'utf8'); + const parsed = parseSurgeConf(text); + + const custom = opts.keepCustom + ? db.select().from(rules).where(eq(rules.source, 'custom')).all() + : []; + db.delete(rules).where(eq(rules.source, 'template')).run(); + if (!opts.keepCustom) db.delete(rules).where(eq(rules.source, 'custom')).run(); + + let seq = 0; + for (const r of parsed.rules) { + let value = r.value; + if ((r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') && value && /^https?:\/\//.test(value)) { + const existing = db.select().from(rulesets).where(eq(rulesets.surgeUrl, value)).get(); + if (existing) { + value = existing.id; + } else { + const mapping = mapRulesetUrl(value, r.type); + const rulesetId = nanoid(10); + db.insert(rulesets) + .values({ + id: rulesetId, + name: rulesetNameFromUrl(value), + surgeUrl: value, + clashUrl: mapping.clashUrl, + clashBehavior: mapping.clashBehavior, + clashFormat: mapping.clashFormat, + needsConvert: mapping.needsConvert, + }) + .run(); + value = rulesetId; + } + } + db.insert(rules) + .values({ + id: nanoid(10), + seq: seq++, + type: r.type, + value, + policy: r.policy, + params: JSON.stringify(r.params), + enabled: true, + source: 'template', + }) + .run(); + } + // 自定义规则重新插回:保持原相对顺序,排在 FINAL 之前 + if (custom.length > 0) { + const finalRow = db.select().from(rules).where(eq(rules.type, 'FINAL')).get(); + let base = finalRow ? finalRow.seq : seq; + // 给自定义行腾出 seq 空间:FINAL 及之后行整体后移 + for (const c of custom.sort((a, b) => a.seq - b.seq)) { + db.insert(rules) + .values({ ...c, seq: base++ }) + .run(); + } + if (finalRow) db.update(rules).set({ seq: base }).where(eq(rules.id, finalRow.id)).run(); + } +} + +export function seedIfEmpty(db: Db) { + const hasGroups = db.select().from(policyGroups).limit(1).all().length > 0; + if (hasGroups) return; + + const text = fs.readFileSync(TEMPLATE_PATH, 'utf8'); + const parsed = parseSurgeConf(text); + + seedGroups(db, parsed.groups, 'template'); + seedRulesFromTemplate(db, { keepCustom: false }); + + for (const [name, content] of Object.entries(parsed.sections)) { + db.insert(settings) + .values({ key: `surge:${name}`, value: content }) + .onConflictDoNothing() + .run(); + } + console.log( + `seeded from template: ${parsed.groups.length} groups, ${parsed.rules.length} rules, sections: ${Object.keys(parsed.sections).join(', ')}` + ); +} diff --git a/server/src/env.ts b/server/src/env.ts new file mode 100644 index 0000000..1106fa6 --- /dev/null +++ b/server/src/env.ts @@ -0,0 +1,11 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +export const env = { + port: Number(process.env.PORT || 3000), + dataDir: path.resolve(serverRoot, process.env.DATA_DIR || './data'), + adminToken: process.env.ADMIN_TOKEN || '', + serverRoot, +}; diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..778e8bb --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,54 @@ +import { serve } from '@hono/node-server'; +import { serveStatic } from '@hono/node-server/serve-static'; +import { Hono } from 'hono'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { env } from './env.js'; +import { db } from './db/index.js'; +import { seedIfEmpty } from './db/seed.js'; +import { adminAuth } from './middleware/admin-auth.js'; +import { nodesRoute } from './routes/nodes.js'; +import { groupsRoute } from './routes/groups.js'; +import { rulesRoute, rulesetsRoute } from './routes/rules.js'; +import { tokensRoute } from './routes/tokens.js'; +import { subRoute } from './routes/sub.js'; +import { settingsRoute } from './routes/settings.js'; + +seedIfEmpty(db); + +const app = new Hono(); + +app.get('/api/health', (c) => c.json({ ok: true, name: 'proxy-station' })); + +app.use('/api/*', adminAuth); +app.route('/api/nodes', nodesRoute); +app.route('/api/groups', groupsRoute); +app.route('/api/rules', rulesRoute); +app.route('/api/rulesets', rulesetsRoute); +app.route('/api/tokens', tokensRoute); +app.route('/api/settings', settingsRoute); + +// 公开订阅端点:token 即凭证 +app.route('/sub', subRoute); + +// 未匹配的接口路径返回 404,不落到下面的 SPA 回退(否则会拿到一页 HTML) +app.all('/api/*', (c) => c.json({ error: '接口不存在' }, 404)); + +// 生产模式:托管 web 构建产物(web/dist 相对 server 根目录) +const webDist = path.resolve(env.serverRoot, '../web/dist'); +if (fs.existsSync(webDist)) { + const relRoot = path.relative(process.cwd(), webDist); + app.use('/*', serveStatic({ root: relRoot })); + app.get('*', serveStatic({ path: path.join(relRoot, 'index.html') })); +} + +// 监听 0.0.0.0:局域网内的手机等设备可直接访问管理界面与订阅 +serve({ fetch: app.fetch, port: env.port, hostname: '0.0.0.0' }, (info) => { + const lan = Object.values(os.networkInterfaces()) + .flat() + .filter((i): i is NonNullable => !!i && i.family === 'IPv4' && !i.internal) + .map((i) => i.address); + console.log(`proxy-station server listening on http://localhost:${info.port}`); + for (const addr of lan) console.log(` http://${addr}:${info.port}`); +}); diff --git a/server/src/middleware/admin-auth.ts b/server/src/middleware/admin-auth.ts new file mode 100644 index 0000000..46ea94d --- /dev/null +++ b/server/src/middleware/admin-auth.ts @@ -0,0 +1,31 @@ +import type { MiddlewareHandler } from 'hono'; +import { env } from '../env.js'; + +/** 本机或 RFC1918 私有网段(局域网) */ +function isLocalOrPrivate(addr: string): boolean { + const ip = addr.replace(/^::ffff:/, ''); + if (!ip || ip === '127.0.0.1' || ip === '::1') return true; + if (/^10\./.test(ip)) return true; + if (/^192\.168\./.test(ip)) return true; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true; + return false; +} + +/** + * /api/* 鉴权:设置了 ADMIN_TOKEN 则要求 Bearer;未设置时仅放行本机与局域网来源 + *(本机/局域网开发免配置,公网部署必须设 token)。 + */ +export const adminAuth: MiddlewareHandler = async (c, next) => { + if (env.adminToken) { + const header = c.req.header('Authorization') || ''; + if (header !== `Bearer ${env.adminToken}`) { + return c.json({ error: '未授权' }, 401); + } + } else { + const addr = c.env?.incoming?.socket?.remoteAddress ?? ''; + if (!isLocalOrPrivate(addr)) { + return c.json({ error: '服务未设置 ADMIN_TOKEN,仅允许本机与局域网访问管理接口' }, 403); + } + } + await next(); +}; diff --git a/server/src/routes/groups.ts b/server/src/routes/groups.ts new file mode 100644 index 0000000..8bdd68f --- /dev/null +++ b/server/src/routes/groups.ts @@ -0,0 +1,107 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { asc, eq } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { z } from 'zod'; +import { groupInputSchema, type GroupDto } from '@proxy-station/shared'; +import { db } from '../db/index.js'; +import { groupMembers, policyGroups, rules } from '../db/schema.js'; + +function loadGroups(): GroupDto[] { + const groups = db.select().from(policyGroups).orderBy(asc(policyGroups.sortOrder)).all(); + const members = db.select().from(groupMembers).orderBy(asc(groupMembers.sortOrder)).all(); + return groups.map((g) => ({ + ...g, + members: members + .filter((m) => m.groupId === g.id) + .map((m) => ({ id: m.id, kind: m.memberKind, nodeId: m.nodeId, refName: m.refName, sortOrder: m.sortOrder })), + })); +} + +function replaceMembers(groupId: string, members: z.output['members']) { + db.delete(groupMembers).where(eq(groupMembers.groupId, groupId)).run(); + members.forEach((m, i) => { + db.insert(groupMembers) + .values({ + groupId, + memberKind: m.kind, + nodeId: m.kind === 'node' ? (m.nodeId ?? null) : null, + refName: m.kind === 'node' ? null : (m.refName ?? null), + sortOrder: i, + }) + .run(); + }); +} + +export const groupsRoute = new Hono() + .get('/', (c) => c.json(loadGroups())) + .post('/', zValidator('json', groupInputSchema), (c) => { + const input = c.req.valid('json'); + const id = nanoid(10); + try { + db.insert(policyGroups) + .values({ + id, + name: input.name, + type: input.type, + testUrl: input.testUrl ?? null, + interval: input.interval ?? null, + tolerance: input.tolerance ?? null, + filterRegex: input.filterRegex || null, + includeAllNodes: input.includeAllNodes, + sortOrder: input.sortOrder, + source: 'custom', + }) + .run(); + } catch (e: any) { + if (String(e.message).includes('UNIQUE')) return c.json({ error: `策略组「${input.name}」已存在` }, 409); + throw e; + } + replaceMembers(id, input.members); + return c.json({ id }, 201); + }) + .put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => { + const { ids } = c.req.valid('json'); + ids.forEach((id, i) => db.update(policyGroups).set({ sortOrder: i }).where(eq(policyGroups.id, id)).run()); + return c.json({ ok: true }); + }) + .patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => { + const res = db + .update(policyGroups) + .set({ enabled: c.req.valid('json').enabled }) + .where(eq(policyGroups.id, c.req.param('id'))) + .run(); + if (res.changes === 0) return c.json({ error: '策略组不存在' }, 404); + return c.json({ ok: true }); + }) + .put('/:id', zValidator('json', groupInputSchema), (c) => { + const id = c.req.param('id'); + const existing = db.select().from(policyGroups).where(eq(policyGroups.id, id)).get(); + if (!existing) return c.json({ error: '策略组不存在' }, 404); + const input = c.req.valid('json'); + const oldName = existing.name; + db.update(policyGroups) + .set({ + name: input.name, + type: input.type, + testUrl: input.testUrl ?? null, + interval: input.interval ?? null, + tolerance: input.tolerance ?? null, + filterRegex: input.filterRegex || null, + includeAllNodes: input.includeAllNodes, + }) + .where(eq(policyGroups.id, id)) + .run(); + replaceMembers(id, input.members); + // 组名变更时同步引用它的成员与规则策略 + if (oldName !== input.name) { + db.update(groupMembers).set({ refName: input.name }).where(eq(groupMembers.refName, oldName)).run(); + db.update(rules).set({ policy: input.name }).where(eq(rules.policy, oldName)).run(); + } + return c.json({ ok: true }); + }) + .delete('/:id', (c) => { + const res = db.delete(policyGroups).where(eq(policyGroups.id, c.req.param('id'))).run(); + if (res.changes === 0) return c.json({ error: '策略组不存在' }, 404); + return c.json({ ok: true }); + }); diff --git a/server/src/routes/nodes.ts b/server/src/routes/nodes.ts new file mode 100644 index 0000000..eb52674 --- /dev/null +++ b/server/src/routes/nodes.ts @@ -0,0 +1,132 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { asc, eq } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { z } from 'zod'; +import { nodeInputSchema, type NodeDto } from '@proxy-station/shared'; +import { db } from '../db/index.js'; +import { nodes } from '../db/schema.js'; +import { encodeShareLink, parseShareLinkBatch } from '../services/sharelink/parse.js'; + +type NodeRow = typeof nodes.$inferSelect; + +export function rowToDto(row: NodeRow): NodeDto { + return { ...row, tags: JSON.parse(row.tags) } as unknown as NodeDto; +} + +function dtoToRowPatch(input: z.output) { + return { + name: input.name, + protocol: input.protocol, + server: input.server, + port: input.port, + network: input.network, + wsPath: input.wsPath ?? null, + wsHost: input.wsHost ?? null, + tls: input.tls, + sni: input.sni ?? null, + skipCertVerify: input.skipCertVerify, + uuid: input.uuid ?? null, + alterId: input.alterId, + security: input.security, + password: input.password ?? null, + method: input.method ?? null, + obfsType: input.obfsType ?? null, + obfsPassword: input.obfsPassword ?? null, + upMbps: input.upMbps ?? null, + downMbps: input.downMbps ?? null, + region: input.region ?? null, + tags: JSON.stringify(input.tags), + enabled: input.enabled, + sortOrder: input.sortOrder, + }; +} + +/** 名称冲突时自动追加 -2 / -3 … 后缀 */ +function uniqueName(base: string, taken: Set): string { + if (!taken.has(base)) return base; + for (let i = 2; ; i++) { + const candidate = `${base}-${i}`; + if (!taken.has(candidate)) return candidate; + } +} + +export const nodesRoute = new Hono() + .get('/', (c) => { + const rows = db.select().from(nodes).orderBy(asc(nodes.sortOrder), asc(nodes.name)).all(); + return c.json(rows.map(rowToDto)); + }) + .post('/', zValidator('json', nodeInputSchema), (c) => { + const input = c.req.valid('json'); + const now = Date.now(); + const row = { id: nanoid(10), ...dtoToRowPatch(input), extra: '{}', createdAt: now, updatedAt: now }; + try { + db.insert(nodes).values(row).run(); + } catch (e: any) { + if (String(e.message).includes('UNIQUE')) return c.json({ error: `节点名「${input.name}」已存在` }, 409); + throw e; + } + return c.json(rowToDto(row as NodeRow), 201); + }) + .post( + '/import', + zValidator('json', z.object({ text: z.string().min(1), commit: z.boolean().default(false) })), + (c) => { + const { text, commit } = c.req.valid('json'); + const parsed = parseShareLinkBatch(text); + const taken = new Set(db.select({ name: nodes.name }).from(nodes).all().map((r) => r.name)); + const now = Date.now(); + const results = parsed.map((item) => { + if (!item.node) return { ok: false as const, uri: item.uri, error: item.error }; + const name = uniqueName(item.node.name, taken); + taken.add(name); + const node = { ...item.node, name }; + if (commit) { + const validated = nodeInputSchema.parse(node); + const row = { id: nanoid(10), ...dtoToRowPatch(validated), extra: '{}', createdAt: now, updatedAt: now }; + db.insert(nodes).values(row).run(); + } + return { ok: true as const, uri: item.uri, node }; + }); + return c.json({ + committed: commit, + total: results.length, + succeeded: results.filter((r) => r.ok).length, + items: results, + }); + } + ) + .get('/:id/uri', (c) => { + const row = db.select().from(nodes).where(eq(nodes.id, c.req.param('id'))).get(); + if (!row) return c.json({ error: '节点不存在' }, 404); + const dto = rowToDto(row); + return c.json({ uri: encodeShareLink({ ...dto, obfsType: dto.obfsType as 'salamander' | null }) }); + }) + .put('/:id', zValidator('json', nodeInputSchema), (c) => { + const id = c.req.param('id'); + const existing = db.select().from(nodes).where(eq(nodes.id, id)).get(); + if (!existing) return c.json({ error: '节点不存在' }, 404); + const input = c.req.valid('json'); + try { + db.update(nodes) + .set({ ...dtoToRowPatch(input), updatedAt: Date.now() }) + .where(eq(nodes.id, id)) + .run(); + } catch (e: any) { + if (String(e.message).includes('UNIQUE')) return c.json({ error: `节点名「${input.name}」已存在` }, 409); + throw e; + } + return c.json(rowToDto(db.select().from(nodes).where(eq(nodes.id, id)).get()!)); + }) + .patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => { + const id = c.req.param('id'); + const { enabled } = c.req.valid('json'); + const res = db.update(nodes).set({ enabled, updatedAt: Date.now() }).where(eq(nodes.id, id)).run(); + if (res.changes === 0) return c.json({ error: '节点不存在' }, 404); + return c.json({ ok: true }); + }) + .delete('/:id', (c) => { + const res = db.delete(nodes).where(eq(nodes.id, c.req.param('id'))).run(); + if (res.changes === 0) return c.json({ error: '节点不存在' }, 404); + return c.json({ ok: true }); + }); diff --git a/server/src/routes/rules.ts b/server/src/routes/rules.ts new file mode 100644 index 0000000..b32b8fe --- /dev/null +++ b/server/src/routes/rules.ts @@ -0,0 +1,116 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { asc, eq } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { z } from 'zod'; +import { ruleInputSchema, type RuleDto } from '@proxy-station/shared'; +import { db } from '../db/index.js'; +import { rules, rulesets } from '../db/schema.js'; +import { seedRulesFromTemplate } from '../db/seed.js'; + +function loadRules(): RuleDto[] { + const rows = db.select().from(rules).orderBy(asc(rules.seq)).all(); + const sets = new Map(db.select().from(rulesets).all().map((r) => [r.id, r])); + return rows.map((r) => { + const rs = r.value ? sets.get(r.value) : undefined; + return { + ...r, + params: JSON.parse(r.params), + rulesetName: rs?.name ?? null, + rulesetUrl: rs?.surgeUrl ?? null, + }; + }); +} + +function nextSeq(): number { + const rows = db.select({ seq: rules.seq }).from(rules).all(); + return rows.length ? Math.max(...rows.map((r) => r.seq)) + 1 : 0; +} + +export const rulesRoute = new Hono() + .get('/', (c) => c.json(loadRules())) + .post('/', zValidator('json', ruleInputSchema), (c) => { + const input = c.req.valid('json'); + // 新自定义规则插到 FINAL 之前 + const finalRow = db.select().from(rules).where(eq(rules.type, 'FINAL')).get(); + let seq: number; + if (finalRow) { + seq = finalRow.seq; + db.update(rules).set({ seq: finalRow.seq + 1 }).where(eq(rules.id, finalRow.id)).run(); + } else { + seq = nextSeq(); + } + const id = nanoid(10); + db.insert(rules) + .values({ + id, + seq, + type: input.type, + value: input.value ?? null, + policy: input.policy, + params: JSON.stringify(input.params), + enabled: input.enabled, + source: 'custom', + }) + .run(); + return c.json({ id }, 201); + }) + .put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => { + const { ids } = c.req.valid('json'); + ids.forEach((id, i) => db.update(rules).set({ seq: i }).where(eq(rules.id, id)).run()); + return c.json({ ok: true }); + }) + .post('/reset', (c) => { + seedRulesFromTemplate(db, { keepCustom: true }); + return c.json({ ok: true, rules: loadRules().length }); + }) + .put('/:id', zValidator('json', ruleInputSchema), (c) => { + const id = c.req.param('id'); + const existing = db.select().from(rules).where(eq(rules.id, id)).get(); + if (!existing) return c.json({ error: '规则不存在' }, 404); + const input = c.req.valid('json'); + db.update(rules) + .set({ + type: input.type, + value: input.value ?? null, + policy: input.policy, + params: JSON.stringify(input.params), + enabled: input.enabled, + }) + .where(eq(rules.id, id)) + .run(); + return c.json({ ok: true }); + }) + .patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => { + const res = db + .update(rules) + .set({ enabled: c.req.valid('json').enabled }) + .where(eq(rules.id, c.req.param('id'))) + .run(); + if (res.changes === 0) return c.json({ error: '规则不存在' }, 404); + return c.json({ ok: true }); + }) + .delete('/:id', (c) => { + const res = db.delete(rules).where(eq(rules.id, c.req.param('id'))).run(); + if (res.changes === 0) return c.json({ error: '规则不存在' }, 404); + return c.json({ ok: true }); + }); + +export const rulesetsRoute = new Hono() + .get('/', (c) => { + const rows = db.select().from(rulesets).all(); + return c.json(rows); + }) + .get('/:id/content', async (c) => { + const rs = db.select().from(rulesets).where(eq(rulesets.id, c.req.param('id'))).get(); + if (!rs) return c.json({ error: '规则集不存在' }, 404); + const refresh = c.req.query('refresh') === '1'; + try { + const { fetchRulesetContent } = await import('../services/rulesets/fetch.js'); + const { content, fetchedAt, stale } = await fetchRulesetContent(rs.surgeUrl, { refresh }); + const lineCount = content.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith('#')).length; + return c.json({ content, fetchedAt, stale, lineCount }); + } catch (e: any) { + return c.json({ error: `拉取失败:${e.message}` }, 502); + } + }); diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts new file mode 100644 index 0000000..6c38822 --- /dev/null +++ b/server/src/routes/settings.ts @@ -0,0 +1,22 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { getSetting, setSetting } from '../services/ir.js'; + +const SURGE_SECTION_KEYS = ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']; + +export const settingsRoute = new Hono() + .get('/', (c) => { + const surgeSections: Record = {}; + for (const key of SURGE_SECTION_KEYS) surgeSections[key] = getSetting(`surge:${key}`); + return c.json({ surgeSections }); + }) + .put('/', zValidator('json', z.object({ surgeSections: z.record(z.string()).optional() })), (c) => { + const input = c.req.valid('json'); + if (input.surgeSections) { + for (const [key, value] of Object.entries(input.surgeSections)) { + if (SURGE_SECTION_KEYS.includes(key)) setSetting(`surge:${key}`, value); + } + } + return c.json({ ok: true }); + }); diff --git a/server/src/routes/sub.ts b/server/src/routes/sub.ts new file mode 100644 index 0000000..f87f939 --- /dev/null +++ b/server/src/routes/sub.ts @@ -0,0 +1,104 @@ +import { Hono } from 'hono'; +import { eq } from 'drizzle-orm'; +import crypto from 'node:crypto'; +import { db } from '../db/index.js'; +import { rulesets, subTokens } from '../db/schema.js'; +import { buildProfileIR } from '../services/ir.js'; +import { fetchRulesetContent } from '../services/rulesets/fetch.js'; +import { convertSurgeList } from '../services/rulesets/convert.js'; +import { generateSurge } from '../services/generators/surge.js'; +import { generateShadowrocket } from '../services/generators/shadowrocket.js'; +import { generateShadowrocketConf } from '../services/generators/shadowrocket-conf.js'; +import { SHADOWROCKET_SUB_NAME } from '@proxy-station/shared'; +import { generateClash } from '../services/generators/clash.js'; + +function constantTimeEqual(a: string, b: string): boolean { + const ha = crypto.createHash('sha256').update(a).digest(); + const hb = crypto.createHash('sha256').update(b).digest(); + return crypto.timingSafeEqual(ha, hb); +} + +export function findToken(token: string) { + const rows = db.select().from(subTokens).where(eq(subTokens.enabled, true)).all(); + return rows.find((r) => constantTimeEqual(r.token, token)) ?? null; +} + +/** 该订阅可见的节点范围 */ +function irFor(row: { nodeIds: string }) { + return buildProfileIR({ nodeIds: JSON.parse(row.nodeIds) }); +} + +function touchToken(id: string, count: number) { + db.update(subTokens) + .set({ lastAccessAt: Date.now(), accessCount: count + 1 }) + .where(eq(subTokens.id, id)) + .run(); +} + +/** 订阅 URL 基址:直接取本次请求的 origin——客户端能请求到这里,origin 就一定可达 */ +function baseUrl(c: { req: { url: string } }): string { + return new URL(c.req.url).origin; +} + +export const subRoute = new Hono() + .get('/:token/surge', (c) => { + const row = findToken(c.req.param('token')); + if (!row) return c.text('not found', 404); + touchToken(row.id, row.accessCount); + const ir = irFor(row); + const selfUrl = `${baseUrl(c)}/sub/${row.token}/surge`; + const { content } = generateSurge(ir, { selfUrl }); + c.header('content-type', 'text/plain; charset=utf-8'); + c.header('content-disposition', 'attachment; filename="Proxy Station.conf"; filename*=UTF-8\'\'Proxy%20Station.conf'); + return c.body(content); + }) + .get('/:token/shadowrocket', (c) => { + const row = findToken(c.req.param('token')); + if (!row) return c.text('not found', 404); + touchToken(row.id, row.accessCount); + const { content } = generateShadowrocket(irFor(row)); + c.header('content-type', 'text/plain; charset=utf-8'); + return c.body(content); + }) + // ShadowRocket 的策略组与规则(节点走上面的节点订阅端点) + .get('/:token/shadowrocket-conf', (c) => { + const row = findToken(c.req.param('token')); + if (!row) return c.text('not found', 404); + touchToken(row.id, row.accessCount); + const { content } = generateShadowrocketConf(irFor(row), { + subscriptionName: SHADOWROCKET_SUB_NAME, + }); + c.header('content-type', 'text/plain; charset=utf-8'); + c.header('content-disposition', 'attachment; filename="Proxy Station Rules.conf"; filename*=UTF-8\'\'Proxy%20Station%20Rules.conf'); + return c.body(content); + }) + .get('/:token/clash', (c) => { + const row = findToken(c.req.param('token')); + if (!row) return c.text('not found', 404); + touchToken(row.id, row.accessCount); + const ir = irFor(row); + const convertBaseUrl = `${baseUrl(c)}/sub/${row.token}`; + const { content } = generateClash(ir, { convertBaseUrl }); + c.header('content-type', 'text/yaml; charset=utf-8'); + c.header('content-disposition', 'attachment; filename="Proxy Station.yaml"; filename*=UTF-8\'\'Proxy%20Station.yaml'); + c.header('profile-update-interval', '24'); + return c.body(content); + }) + // Clash 兜底转换端点:无 Clash 等价 URL 的规则集,由本服务拉取 Surge 版并转换 + .get('/:token/ruleset/:file', async (c) => { + const row = findToken(c.req.param('token')); + if (!row) return c.text('not found', 404); + const id = c.req.param('file').replace(/\.yaml$/, ''); + const rs = db.select().from(rulesets).where(eq(rulesets.id, id)).get(); + if (!rs) return c.text('not found', 404); + try { + const { content, stale } = await fetchRulesetContent(rs.surgeUrl); + const result = convertSurgeList(content, rs.clashBehavior as 'classical' | 'domain' | 'ipcidr'); + c.header('content-type', 'text/yaml; charset=utf-8'); + c.header('x-proxy-station-converted', `${result.converted}/${result.total}`); + if (stale) c.header('x-proxy-station-stale', '1'); + return c.body(result.yaml); + } catch (e: any) { + return c.text(`upstream fetch failed: ${e.message}`, 502); + } + }); diff --git a/server/src/routes/tokens.ts b/server/src/routes/tokens.ts new file mode 100644 index 0000000..dd4705a --- /dev/null +++ b/server/src/routes/tokens.ts @@ -0,0 +1,78 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { asc, eq } from 'drizzle-orm'; +import crypto from 'node:crypto'; +import { nanoid } from 'nanoid'; +import { z } from 'zod'; +import { CLIENTS, type TokenDto } from '@proxy-station/shared'; +import { db } from '../db/index.js'; +import { subTokens } from '../db/schema.js'; + +/** 只返回路径,前端用当前页面的 origin 拼完整 URL */ +function tokenUrls(token: string): TokenDto['urls'] { + return { + ...(Object.fromEntries(CLIENTS.map((c) => [c, `/sub/${token}/${c}`])) as Record< + (typeof CLIENTS)[number], + string + >), + shadowrocketConf: `/sub/${token}/shadowrocket-conf`, + }; +} + +type TokenRow = typeof subTokens.$inferSelect; + +function toDto(row: TokenRow): TokenDto { + return { ...row, nodeIds: JSON.parse(row.nodeIds), urls: tokenUrls(row.token) }; +} + +const tokenInput = z.object({ + name: z.string().min(1).max(64), + /** 空数组 = 不限制,下发全部启用节点 */ + nodeIds: z.array(z.string()).default([]), +}); + +export const tokensRoute = new Hono() + .get('/', (c) => { + const rows = db.select().from(subTokens).orderBy(asc(subTokens.createdAt)).all(); + return c.json(rows.map(toDto)); + }) + .post('/', zValidator('json', tokenInput), (c) => { + const { name, nodeIds } = c.req.valid('json'); + const row: TokenRow = { + id: nanoid(10), + token: crypto.randomBytes(16).toString('hex'), + name, + nodeIds: JSON.stringify(nodeIds), + enabled: true, + createdAt: Date.now(), + lastAccessAt: null, + accessCount: 0, + }; + db.insert(subTokens).values(row).run(); + return c.json(toDto(row), 201); + }) + .put('/:id', zValidator('json', tokenInput), (c) => { + const id = c.req.param('id'); + const { name, nodeIds } = c.req.valid('json'); + const res = db + .update(subTokens) + .set({ name, nodeIds: JSON.stringify(nodeIds) }) + .where(eq(subTokens.id, id)) + .run(); + if (res.changes === 0) return c.json({ error: '订阅不存在' }, 404); + return c.json(toDto(db.select().from(subTokens).where(eq(subTokens.id, id)).get()!)); + }) + .patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => { + const res = db + .update(subTokens) + .set({ enabled: c.req.valid('json').enabled }) + .where(eq(subTokens.id, c.req.param('id'))) + .run(); + if (res.changes === 0) return c.json({ error: '订阅不存在' }, 404); + return c.json({ ok: true }); + }) + .delete('/:id', (c) => { + const res = db.delete(subTokens).where(eq(subTokens.id, c.req.param('id'))).run(); + if (res.changes === 0) return c.json({ error: '订阅不存在' }, 404); + return c.json({ ok: true }); + }); diff --git a/server/src/services/generators/clash.ts b/server/src/services/generators/clash.ts new file mode 100644 index 0000000..7bea8d5 --- /dev/null +++ b/server/src/services/generators/clash.ts @@ -0,0 +1,192 @@ +import YAML from 'yaml'; +import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared'; +import type { ProfileIR, RuleIR } from '../ir.js'; + +export function clashProxy(n: NodeDto): Record { + const base: Record = { name: n.name, server: n.server, port: n.port }; + switch (n.protocol) { + case 'vmess': { + Object.assign(base, { type: 'vmess', uuid: n.uuid, alterId: n.alterId, cipher: n.security || 'auto' }); + if (n.tls) { + base.tls = true; + if (n.sni) base.servername = n.sni; + } + if (n.network === 'ws') { + base.network = 'ws'; + base['ws-opts'] = { + path: n.wsPath, + ...(n.wsHost ? { headers: { Host: n.wsHost } } : {}), + }; + } + break; + } + case 'trojan': { + Object.assign(base, { type: 'trojan', password: n.password, sni: n.sni || n.server }); + if (n.network === 'ws') { + base.network = 'ws'; + base['ws-opts'] = { + path: n.wsPath, + ...(n.wsHost ? { headers: { Host: n.wsHost } } : {}), + }; + } + if (n.skipCertVerify) base['skip-cert-verify'] = true; + break; + } + case 'shadowsocks': { + Object.assign(base, { type: 'ss', cipher: normalizeSsCipher(n.method || ''), password: n.password }); + if (n.network === 'ws') { + // SS over WS+TLS 在 Clash 侧唯一可行方案是 v2ray-plugin + base.plugin = 'v2ray-plugin'; + base['plugin-opts'] = { + mode: 'websocket', + tls: n.tls, + host: n.wsHost || n.sni || n.server, + path: n.wsPath, + }; + } + break; + } + case 'hysteria2': { + Object.assign(base, { type: 'hysteria2', password: n.password }); + if (n.sni) base.sni = n.sni; + if (n.obfsType === 'salamander' && n.obfsPassword) { + base.obfs = 'salamander'; + base['obfs-password'] = n.obfsPassword; + } + if (n.upMbps) base.up = `${n.upMbps} Mbps`; + if (n.downMbps) base.down = `${n.downMbps} Mbps`; + if (n.skipCertVerify) base['skip-cert-verify'] = true; + break; + } + } + return base; +} + +/** Surge 独有策略 → mihomo 等价策略 */ +function mapPolicy(policy: string): string { + if (policy === 'REJECT-NO-DROP' || policy === 'REJECT-TINYGIF') return 'REJECT'; + return policy; +} + +/** Surge 逻辑规则里的子类型 → mihomo 命名 */ +function mapLogicValue(value: string): string { + return value.replace(/\bPROTOCOL\b/g, 'NETWORK'); +} + +const CLASH_UNSUPPORTED_RULE_TYPES = new Set(['URL-REGEX', 'USER-AGENT']); + +export interface ClashOutput { + content: string; + excluded: ExcludedNode[]; + skippedRules: string[]; +} + +export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): ClashOutput { + const excluded: ExcludedNode[] = []; + const usable = ir.nodes.filter((n) => { + const verdict = clientSupports('clash', n); + if (!verdict.ok) excluded.push({ name: n.name, reason: verdict.reason! }); + return verdict.ok; + }); + const usableNames = new Set(usable.map((n) => n.name)); + + const proxies = usable.map(clashProxy); + + const proxyGroups = ir.groups.map((g) => { + const group: Record = { name: g.name, type: g.type }; + const members = g.members + .filter((m) => m.kind !== 'node' || usableNames.has(m.name)) + .map((m) => m.name); + if (g.filterRegex) { + group['include-all-proxies'] = true; + group.filter = g.filterRegex; + } else if (g.includeAllNodes) { + group['include-all-proxies'] = true; + const extra = members.filter((m) => !usableNames.has(m)); + if (extra.length) group.proxies = extra; + } else { + group.proxies = members.length ? members : ['DIRECT']; + } + if (g.type !== 'select') { + group.url = g.testUrl || 'http://cp.cloudflare.com/generate_204'; + group.interval = g.interval ?? 300; + if (g.tolerance) group.tolerance = g.tolerance; + } + return group; + }); + + // rule-providers:从启用规则引用的规则集生成,名称唯一化 + const providerNameByRulesetId = new Map(); + const providers: Record = {}; + const takenNames = new Set(); + const usedRulesets = ir.rules.flatMap((r) => (r.ruleset ? [r.ruleset] : [])); + for (const rs of usedRulesets) { + if (providerNameByRulesetId.has(rs.id)) continue; + let name = rs.name; + for (let i = 2; takenNames.has(name); i++) name = `${rs.name}-${i}`; + takenNames.add(name); + providerNameByRulesetId.set(rs.id, name); + const url = rs.needsConvert || !rs.clashUrl ? `${opts.convertBaseUrl}/ruleset/${rs.id}.yaml` : rs.clashUrl; + providers[name] = { + type: 'http', + url, + behavior: rs.clashBehavior, + format: rs.needsConvert || !rs.clashUrl ? 'yaml' : rs.clashFormat === 'yaml' ? 'yaml' : 'text', + interval: 86400, + }; + } + + const skippedRules: string[] = []; + const ruleLines: string[] = []; + for (const r of ir.rules) { + if (r.type === 'FINAL') { + ruleLines.push(`MATCH,${mapPolicy(r.policy)}`); + continue; + } + if (CLASH_UNSUPPORTED_RULE_TYPES.has(r.type)) { + skippedRules.push(`${r.type},${r.value},${r.policy}`); + continue; + } + const policy = mapPolicy(r.policy); + if (r.ruleset) { + ruleLines.push(`RULE-SET,${providerNameByRulesetId.get(r.ruleset.id)},${policy}`); + continue; + } + // Surge 内置规则集(非 URL):LAN → 私网 GEOIP;SYSTEM 无对应物,跳过 + if (r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') { + if (r.value === 'LAN') { + ruleLines.push(`GEOIP,private,${policy},no-resolve`); + } else { + skippedRules.push(`${r.type},${r.value},${r.policy}`); + } + continue; + } + if (r.type === 'AND' || r.type === 'OR' || r.type === 'NOT') { + ruleLines.push(`${r.type},${mapLogicValue(r.value || '')},${policy}`); + continue; + } + const params = r.params.filter((p) => p === 'no-resolve'); + ruleLines.push([r.type, r.value, policy, ...params].join(',')); + } + + const doc = { + 'mixed-port': 7890, + 'allow-lan': false, + mode: 'rule', + 'log-level': 'info', + 'unified-delay': true, + ipv6: false, + dns: { + enable: true, + listen: '0.0.0.0:1053', + 'enhanced-mode': 'fake-ip', + nameserver: ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query'], + }, + proxies, + 'proxy-groups': proxyGroups, + 'rule-providers': providers, + rules: ruleLines, + }; + + return { content: YAML.stringify(doc, { lineWidth: 0 }), excluded, skippedRules }; +} diff --git a/server/src/services/generators/shadowrocket-conf.ts b/server/src/services/generators/shadowrocket-conf.ts new file mode 100644 index 0000000..c423f2f --- /dev/null +++ b/server/src/services/generators/shadowrocket-conf.ts @@ -0,0 +1,110 @@ +import type { ProfileIR } from '../ir.js'; + +/** 成员名含空格/逗号时加引号 */ +function quoteName(name: string): string { + return /[\s,]/.test(name) ? `"${name}"` : name; +} + +/** + * ShadowRocket 的 [General] 只保留它认识的通用键——模板里的 Surge 专有键 + * (all-hybrid / udp-priority / http-api / compatibility-mode 等)会被过滤掉。 + */ +const SR_GENERAL_KEYS = new Set([ + 'bypass-system', + 'skip-proxy', + 'bypass-tun', + 'dns-server', + 'fallback-dns-server', + 'ipv6', + 'prefer-ipv6', + 'dns-direct-system', + 'icmp-auto-reply', + 'always-real-ip', + 'hijack-dns', + 'udp-policy-not-supported-behaviour', + 'interface-mode', +]); + +function shadowrocketGeneral(surgeGeneral: string): string { + const lines: string[] = []; + for (const raw of surgeGeneral.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith('#') || line.startsWith('//')) continue; + const key = line.split('=')[0].trim(); + if (SR_GENERAL_KEYS.has(key)) lines.push(line); + } + // ShadowRocket 的常用默认值,模板里没有就补上 + if (!lines.some((l) => l.startsWith('bypass-system'))) lines.push('bypass-system = true'); + if (!lines.some((l) => l.startsWith('ipv6'))) lines.push('ipv6 = false'); + if (!lines.some((l) => l.startsWith('dns-server'))) lines.push('dns-server = 223.5.5.5, 119.29.29.29'); + return lines.join('\n'); +} + +/** ShadowRocket 不认识的规则类型 */ +const SR_UNSUPPORTED_RULE_TYPES = new Set(['DOMAIN-SET', 'AND', 'OR', 'NOT', 'URL-REGEX']); + +export interface ShadowrocketConfOutput { + content: string; + skippedRules: string[]; +} + +/** + * 生成 ShadowRocket 配置文件:策略组 + 规则,不含节点。 + * 节点由「节点订阅」单独导入,策略组通过 `use=true` 引用该订阅的名称, + * 因此用户在 App 里给订阅起的名字必须与 subscriptionName 一致。 + */ +export function generateShadowrocketConf( + ir: ProfileIR, + opts: { subscriptionName: string } +): ShadowrocketConfOutput { + const sub = quoteName(opts.subscriptionName); + const lines: string[] = []; + + lines.push('# 由 Proxy Station 生成的 ShadowRocket 配置(策略组 + 规则)'); + lines.push(`# 节点请另行导入「节点订阅」,并在 App 中将其命名为:${opts.subscriptionName}`); + lines.push(''); + lines.push('[General]'); + lines.push(shadowrocketGeneral(ir.sections['General'] ?? '')); + + lines.push('', '[Proxy Group]'); + for (const g of ir.groups) { + const parts: string[] = [g.type]; + if (g.filterRegex) { + // 地区组:引用订阅 + 正则筛选(ShadowRocket 原生支持 policy-regex-filter) + parts.push(sub, 'use=true', `policy-regex-filter=${g.filterRegex}`); + } else if (g.includeAllNodes) { + // 收纳全部节点:引用订阅 + parts.push(sub, 'use=true'); + } else { + const members = g.members.map((m) => quoteName(m.name)); + parts.push(...(members.length ? members : ['DIRECT'])); + } + if (g.type !== 'select') { + parts.push(`url=${g.testUrl || 'http://cp.cloudflare.com/generate_204'}`); + if (g.interval) parts.push(`interval=${g.interval}`); + if (g.tolerance) parts.push(`tolerance=${g.tolerance}`); + } + lines.push(`${g.name} = ${parts.join(', ')}`); + } + + const skippedRules: string[] = []; + lines.push('', '[Rule]'); + for (const r of ir.rules) { + if (r.type === 'FINAL') { + lines.push(['FINAL', quoteName(r.policy)].join(',')); + continue; + } + if (SR_UNSUPPORTED_RULE_TYPES.has(r.type)) { + skippedRules.push(`${r.type},${r.ruleset ? r.ruleset.name : r.value},${r.policy}`); + continue; + } + const value = r.ruleset ? r.ruleset.surgeUrl : r.value; + lines.push([r.type, value, quoteName(r.policy), ...r.params].join(',')); + } + + for (const section of ['Host', 'URL Rewrite', 'MITM']) { + if (ir.sections[section]) lines.push('', `[${section}]`, ir.sections[section]); + } + + return { content: lines.join('\n') + '\n', skippedRules }; +} diff --git a/server/src/services/generators/shadowrocket.ts b/server/src/services/generators/shadowrocket.ts new file mode 100644 index 0000000..3aae084 --- /dev/null +++ b/server/src/services/generators/shadowrocket.ts @@ -0,0 +1,27 @@ +import { clientSupports, type ExcludedNode } from '@proxy-station/shared'; +import type { ProfileIR } from '../ir.js'; +import { encodeShareLink } from '../sharelink/parse.js'; + +export interface ShadowrocketOutput { + content: string; // base64 后的 URI 列表(ShadowRocket 原生订阅格式) + excluded: ExcludedNode[]; + uris: string[]; +} + +export function generateShadowrocket(ir: ProfileIR): ShadowrocketOutput { + const excluded: ExcludedNode[] = []; + const uris: string[] = []; + for (const n of ir.nodes) { + const verdict = clientSupports('shadowrocket', n); + if (!verdict.ok) { + excluded.push({ name: n.name, reason: verdict.reason! }); + continue; + } + uris.push(encodeShareLink({ ...n, obfsType: n.obfsType as 'salamander' | null })); + } + return { + content: Buffer.from(uris.join('\n'), 'utf8').toString('base64'), + excluded, + uris, + }; +} diff --git a/server/src/services/generators/surge.ts b/server/src/services/generators/surge.ts new file mode 100644 index 0000000..914a659 --- /dev/null +++ b/server/src/services/generators/surge.ts @@ -0,0 +1,124 @@ +import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared'; +import type { ProfileIR } from '../ir.js'; + +/** 成员名含空格/逗号时按 Surge 习惯加引号 */ +function quoteName(name: string): string { + return /[\s,]/.test(name) ? `"${name}"` : name; +} + +export function surgeNodeLine(n: NodeDto): string { + const parts: string[] = []; + switch (n.protocol) { + case 'vmess': { + parts.push('vmess', n.server, String(n.port), `username=${n.uuid}`); + if (n.network === 'ws') { + parts.push('ws=true', `ws-path=${n.wsPath}`); + if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`); + } + if (n.tls) { + parts.push('tls=true'); + if (n.sni) parts.push(`sni=${n.sni}`); + } + // alterId=0 的 AEAD 头必须显式打开,否则 Surge 连不上且症状隐晦 + parts.push('vmess-aead=true'); + break; + } + case 'trojan': { + parts.push('trojan', n.server, String(n.port), `password=${n.password}`); + if (n.network === 'ws') { + parts.push('ws=true', `ws-path=${n.wsPath}`); + if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`); + } + if (n.sni) parts.push(`sni=${n.sni}`); + if (n.skipCertVerify) parts.push('skip-cert-verify=true'); + break; + } + case 'shadowsocks': { + parts.push('ss', n.server, String(n.port), `encrypt-method=${normalizeSsCipher(n.method || '')}`, `password=${n.password}`); + break; + } + case 'hysteria2': { + parts.push('hysteria2', n.server, String(n.port), `password=${n.password}`); + if (n.sni) parts.push(`sni=${n.sni}`); + // Surge 的 salamander 混淆参数名(sub-router 生产验证过) + if (n.obfsType === 'salamander' && n.obfsPassword) parts.push(`salamander-password=${n.obfsPassword}`); + if (n.downMbps) parts.push(`download-bandwidth=${n.downMbps}`); + if (n.skipCertVerify) parts.push('skip-cert-verify=true'); + break; + } + } + return `${n.name} = ${parts.join(', ')}`; +} + +export interface SurgeOutput { + content: string; + excluded: ExcludedNode[]; +} + +export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOutput { + const excluded: ExcludedNode[] = []; + const usable = ir.nodes.filter((n) => { + const verdict = clientSupports('surge', n); + if (!verdict.ok) excluded.push({ name: n.name, reason: verdict.reason! }); + return verdict.ok; + }); + const usableNames = new Set(usable.map((n) => n.name)); + + const lines: string[] = []; + lines.push(`#!MANAGED-CONFIG ${opts.selfUrl} interval=86400 strict=true`); + lines.push('# 由 Proxy Station 生成,请勿手动编辑(改动会在下次更新时被覆盖)'); + lines.push(''); + lines.push('[General]'); + lines.push(ir.sections['General'] ?? ''); + + lines.push('', '[Proxy]'); + for (const n of usable) lines.push(surgeNodeLine(n)); + + lines.push('', '[Proxy Group]'); + for (const g of ir.groups) { + const parts: string[] = [g.type]; + const memberNames = g.members + .filter((m) => m.kind !== 'node' || usableNames.has(m.name)) + .map((m) => quoteName(m.name)); + if (g.filterRegex) { + // 按名称正则动态筛选:节点来源可以是全部节点,也可以是另一个组 + if (g.includeAllNodes) { + parts.push('include-all-proxies=true'); + } else { + const source = g.members.find((m) => m.kind === 'group'); + if (source) parts.push(`include-other-group=${quoteName(source.name)}`); + } + parts.push(`policy-regex-filter=${g.filterRegex}`); + } else if (g.includeAllNodes) { + // 「全部节点」组:显式展开当前启用且 Surge 支持的节点 + parts.push(...usable.map((n) => quoteName(n.name)), ...memberNames.filter((m) => !usableNames.has(m))); + if (usable.length === 0) parts.push('DIRECT'); + } else { + parts.push(...(memberNames.length ? memberNames : ['DIRECT'])); + } + if (g.type !== 'select') { + parts.push(`url=${g.testUrl || 'http://cp.cloudflare.com/generate_204'}`); + if (g.interval) parts.push(`interval=${g.interval}`); + if (g.tolerance) parts.push(`tolerance=${g.tolerance}`); + } + lines.push(`${g.name} = ${parts.join(', ')}`); + } + + lines.push('', '[Rule]'); + for (const r of ir.rules) { + if (r.type === 'FINAL') { + lines.push(['FINAL', quoteName(r.policy), ...r.params].join(',')); + continue; + } + const value = r.ruleset ? r.ruleset.surgeUrl : r.value; + lines.push([r.type, value, quoteName(r.policy), ...r.params].join(',')); + } + + for (const section of ['Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) { + if (ir.sections[section]) { + lines.push('', `[${section}]`, ir.sections[section]); + } + } + + return { content: lines.join('\n') + '\n', excluded }; +} diff --git a/server/src/services/ir.ts b/server/src/services/ir.ts new file mode 100644 index 0000000..0eefb50 --- /dev/null +++ b/server/src/services/ir.ts @@ -0,0 +1,131 @@ +import { asc, eq } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { groupMembers, nodes, policyGroups, rules, rulesets, settings } from '../db/schema.js'; +import type { NodeDto } from '@proxy-station/shared'; +import { rowToDto } from '../routes/nodes.js'; + +export interface GroupIR { + name: string; + type: 'select' | 'url-test' | 'fallback'; + testUrl: string | null; + interval: number | null; + tolerance: number | null; + filterRegex: string | null; + includeAllNodes: boolean; + /** 有序成员:节点名 / 组名 / DIRECT 等内置策略 */ + members: { kind: 'node' | 'group' | 'builtin'; name: string }[]; +} + +export interface RuleIR { + type: string; + value: string | null; + policy: string; + params: string[]; + ruleset: { + id: string; + name: string; + surgeUrl: string; + clashUrl: string | null; + clashBehavior: string; + clashFormat: string; + needsConvert: boolean; + } | null; +} + +export interface ProfileIR { + nodes: NodeDto[]; // 仅启用节点,按 sortOrder + groups: GroupIR[]; + rules: RuleIR[]; // 仅启用规则,按 seq + sections: Record; // Surge 原文段落(General/Host/…) +} + +export function getSetting(key: string): string { + const row = db.select().from(settings).where(eq(settings.key, key)).get(); + return row?.value ?? ''; +} + +export function setSetting(key: string, value: string) { + db.insert(settings) + .values({ key, value }) + .onConflictDoUpdate({ target: settings.key, set: { value } }) + .run(); +} + +/** + * @param opts.nodeIds 订阅分配的节点范围;为空或未提供时下发全部启用节点 + */ +export function buildProfileIR(opts: { nodeIds?: string[] } = {}): ProfileIR { + const allowed = opts.nodeIds && opts.nodeIds.length > 0 ? new Set(opts.nodeIds) : null; + const nodeRows = db + .select() + .from(nodes) + .where(eq(nodes.enabled, true)) + .orderBy(asc(nodes.sortOrder), asc(nodes.name)) + .all() + .map(rowToDto) + .filter((n) => !allowed || allowed.has(n.id)); + const nodeById = new Map(nodeRows.map((n) => [n.id, n])); + + const memberRows = db.select().from(groupMembers).orderBy(asc(groupMembers.sortOrder)).all(); + const allGroupRows = db.select().from(policyGroups).orderBy(asc(policyGroups.sortOrder)).all(); + // 停用的组从输出中剔除;引用它的成员与规则一并跳过,保证产物有效 + const groupRows = allGroupRows.filter((g) => g.enabled); + const liveNames = new Set(groupRows.map((g) => g.name)); + const BUILTIN_POLICIES = new Set(['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP', 'REJECT-TINYGIF']); + /** 组已被删除或停用时,引用它的成员/规则应当跳过 */ + const isDeadRef = (name: string) => !liveNames.has(name) && !BUILTIN_POLICIES.has(name); + const groups: GroupIR[] = groupRows.map((g) => ({ + name: g.name, + type: g.type, + testUrl: g.testUrl, + interval: g.interval, + tolerance: g.tolerance, + filterRegex: g.filterRegex, + includeAllNodes: g.includeAllNodes, + members: memberRows + .filter((m) => m.groupId === g.id) + .flatMap((m): GroupIR['members'] => { + if (m.memberKind === 'node') { + const node = m.nodeId ? nodeById.get(m.nodeId) : undefined; + return node ? [{ kind: 'node', name: node.name }] : []; // 被禁用/删除的节点成员跳过 + } + if (!m.refName) return []; + if (m.memberKind === 'group' && isDeadRef(m.refName)) return []; + return [{ kind: m.memberKind, name: m.refName }]; + }), + })); + + const setRows = new Map(db.select().from(rulesets).all().map((r) => [r.id, r])); + const ruleRows = db + .select() + .from(rules) + .where(eq(rules.enabled, true)) + .orderBy(asc(rules.seq)) + .all() + .filter((r) => r.type === 'FINAL' || !isDeadRef(r.policy)) + // FINAL 必须保留:其策略组被停用或删除时兜底 DIRECT + .map((r) => (r.type === 'FINAL' && isDeadRef(r.policy) ? { ...r, policy: 'DIRECT' } : r)); + const ruleIRs: RuleIR[] = ruleRows.map((r) => { + const rs = r.value ? setRows.get(r.value) : undefined; + return { + type: r.type, + value: r.value, + policy: r.policy, + params: JSON.parse(r.params), + ruleset: rs ?? null, + }; + }); + + const sections: Record = {}; + for (const key of ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) { + const v = getSetting(`surge:${key}`); + if (v) sections[key] = v; + } + + return { + nodes: nodeRows, + groups, + rules: ruleIRs, + sections, + }; +} diff --git a/server/src/services/rulesets/convert.test.ts b/server/src/services/rulesets/convert.test.ts new file mode 100644 index 0000000..991b1db --- /dev/null +++ b/server/src/services/rulesets/convert.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import YAML from 'yaml'; +import { convertSurgeList } from './convert.js'; + +describe('convertSurgeList', () => { + it('classical:保留支持类型与 no-resolve,丢弃 USER-AGENT', () => { + const input = [ + '# 注释', + 'DOMAIN,example.com', + 'DOMAIN-SUFFIX,example.org', + 'IP-CIDR,10.0.0.0/8,no-resolve', + 'USER-AGENT,Foo*', + 'URL-REGEX,^http://bad', + ].join('\n'); + const result = convertSurgeList(input, 'classical'); + expect(YAML.parse(result.yaml).payload).toEqual([ + 'DOMAIN,example.com', + 'DOMAIN-SUFFIX,example.org', + 'IP-CIDR,10.0.0.0/8,no-resolve', + ]); + expect(result.dropped).toHaveLength(2); + expect(result.total).toBe(5); + }); + + it('domain(domainset):前导点转 +.', () => { + const result = convertSurgeList('example.com\n.cdn.example.com', 'domain'); + expect(YAML.parse(result.yaml).payload).toEqual(['example.com', '+.cdn.example.com']); + }); +}); diff --git a/server/src/services/rulesets/convert.ts b/server/src/services/rulesets/convert.ts new file mode 100644 index 0000000..6f3f8a9 --- /dev/null +++ b/server/src/services/rulesets/convert.ts @@ -0,0 +1,65 @@ +import YAML from 'yaml'; + +/** mihomo classical provider 支持的规则类型 */ +const CLASSICAL_TYPES = new Set([ + 'DOMAIN', + 'DOMAIN-SUFFIX', + 'DOMAIN-KEYWORD', + 'DOMAIN-REGEX', + 'IP-CIDR', + 'IP-CIDR6', + 'IP-ASN', + 'GEOIP', + 'PROCESS-NAME', + 'PROCESS-PATH', + 'DST-PORT', + 'SRC-PORT', +]); + +export interface ConvertResult { + yaml: string; + total: number; + converted: number; + dropped: string[]; // 被丢弃的行(Clash 不支持的类型) +} + +/** + * Surge .list/.conf 规则集 → Clash rule-provider yaml。 + * behavior=domain 时按 domainset 处理(每行一个域名,.former 前缀转 +.)。 + */ +export function convertSurgeList(content: string, behavior: 'classical' | 'domain' | 'ipcidr'): ConvertResult { + const lines = content + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('#') && !l.startsWith('//') && !l.startsWith(';')); + + const payload: string[] = []; + const dropped: string[] = []; + + for (const line of lines) { + if (behavior === 'domain') { + // Surge domainset:`example.com` 或 `.example.com`(含子域)→ Clash `example.com` / `+.example.com` + payload.push(line.startsWith('.') ? `+${line}` : line); + continue; + } + if (behavior === 'ipcidr') { + payload.push(line); + continue; + } + const parts = line.split(',').map((p) => p.trim()); + const type = parts[0]; + if (!CLASSICAL_TYPES.has(type)) { + dropped.push(line); + continue; + } + const params = parts.slice(2).filter((p) => p === 'no-resolve'); + payload.push([type, parts[1], ...params].join(',')); + } + + return { + yaml: YAML.stringify({ payload }, { lineWidth: 0 }), + total: lines.length, + converted: payload.length, + dropped, + }; +} diff --git a/server/src/services/rulesets/fetch.ts b/server/src/services/rulesets/fetch.ts new file mode 100644 index 0000000..8675ffb --- /dev/null +++ b/server/src/services/rulesets/fetch.ts @@ -0,0 +1,48 @@ +import { eq } from 'drizzle-orm'; +import { Agent, fetch as undiciFetch } from 'undici'; +import { db } from '../../db/index.js'; +import { rulesetCache } from '../../db/schema.js'; + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +// 规则集拉取一律直连(显式 Agent,隔离环境注入的 HTTPS_PROXY) +function dispatcher() { + return new Agent(); +} + +export interface FetchResult { + content: string; + fetchedAt: number; + stale: boolean; // 拉取失败时回落陈旧缓存 +} + +export async function fetchRulesetContent(url: string, opts: { refresh?: boolean } = {}): Promise { + const cached = db.select().from(rulesetCache).where(eq(rulesetCache.url, url)).get(); + const fresh = cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS; + if (cached && fresh && !opts.refresh) { + return { content: cached.content, fetchedAt: cached.fetchedAt, stale: false }; + } + + try { + const headers: Record = { 'user-agent': 'proxy-station/0.1' }; + if (cached?.etag) headers['if-none-match'] = cached.etag; + const res = await undiciFetch(url, { dispatcher: dispatcher(), headers }); + if (res.status === 304 && cached) { + const fetchedAt = Date.now(); + db.update(rulesetCache).set({ fetchedAt }).where(eq(rulesetCache.url, url)).run(); + return { content: cached.content, fetchedAt, stale: false }; + } + if (res.status !== 200) throw new Error(`HTTP ${res.status}`); + const content = await res.text(); + const etag = res.headers.get('etag'); + const fetchedAt = Date.now(); + db.insert(rulesetCache) + .values({ url, content, etag, fetchedAt }) + .onConflictDoUpdate({ target: rulesetCache.url, set: { content, etag, fetchedAt } }) + .run(); + return { content, fetchedAt, stale: false }; + } catch (e) { + if (cached) return { content: cached.content, fetchedAt: cached.fetchedAt, stale: true }; + throw e; + } +} diff --git a/server/src/services/rulesets/mapping.ts b/server/src/services/rulesets/mapping.ts new file mode 100644 index 0000000..d3c6a60 --- /dev/null +++ b/server/src/services/rulesets/mapping.ts @@ -0,0 +1,53 @@ +/** + * Surge 规则集 URL → Clash 等价 URL 的静态映射。 + * 映射不到的返回 needsConvert=true,走自托管兜底转换端点。 + */ + +export interface RulesetMapping { + clashUrl: string | null; + clashBehavior: 'classical' | 'domain' | 'ipcidr'; + clashFormat: 'yaml' | 'text'; + needsConvert: boolean; +} + +export function mapRulesetUrl(surgeUrl: string, ruleType: 'RULE-SET' | 'DOMAIN-SET'): RulesetMapping { + // skk.moe:List/{non_ip|ip|domainset}/{name}.conf → Clash/{同路径}/{name}.txt + const skk = surgeUrl.match(/^https:\/\/ruleset\.skk\.moe\/List\/(non_ip|ip|domainset)\/([\w-]+)\.conf$/); + if (skk) { + const [, dir, name] = skk; + return { + clashUrl: `https://ruleset.skk.moe/Clash/${dir}/${name}.txt`, + clashBehavior: dir === 'domainset' ? 'domain' : 'classical', + clashFormat: 'text', + needsConvert: false, + }; + } + + // blackmatrix7:rule/Surge/{Name}/{Name}.list → rule/Clash/{Name}/{Name}.yaml + const bm7 = surgeUrl.match( + /^https:\/\/raw\.githubusercontent\.com\/blackmatrix7\/ios_rule_script\/master\/rule\/Surge\/([\w-]+)\/([\w-]+)\.list$/ + ); + if (bm7) { + const [, dir, name] = bm7; + return { + clashUrl: `https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/${dir}/${name}.yaml`, + clashBehavior: 'classical', + clashFormat: 'yaml', + needsConvert: false, + }; + } + + // 其余来源(zxfccmm4/Profiles、Semporia/TikTok-Unlock 等):走兜底转换 + return { + clashUrl: null, + clashBehavior: ruleType === 'DOMAIN-SET' ? 'domain' : 'classical', + clashFormat: 'yaml', + needsConvert: true, + }; +} + +/** 从 URL 提取展示名:最后一段文件名去扩展名 */ +export function rulesetNameFromUrl(url: string): string { + const last = url.split('/').pop() || url; + return last.replace(/\.(list|conf|txt|yaml)$/, ''); +} diff --git a/server/src/services/sharelink/hysteria2.ts b/server/src/services/sharelink/hysteria2.ts new file mode 100644 index 0000000..61c6865 --- /dev/null +++ b/server/src/services/sharelink/hysteria2.ts @@ -0,0 +1,44 @@ +import { inferTags, readInsecure } from './util.js'; +import type { ParsedShareLink } from './types.js'; + +// hysteria2://auth@host:port?sni=s&insecure=1&obfs=salamander&obfs-password=x#name +// hy2:// 是等价别名 +export function parseHysteria2(uri: string): ParsedShareLink { + const url = new URL(uri); + const server = url.hostname; + const port = parseInt(url.port || '443', 10); + const user = decodeURIComponent(url.username); + const pass = decodeURIComponent(url.password); + const auth = pass ? `${user}:${pass}` : user; + if (!server || !auth) throw new Error('hysteria2 链接缺少 host/auth'); + const name = decodeURIComponent(url.hash.slice(1)) || 'Hysteria2'; + const params = url.searchParams; + const obfs = params.get('obfs'); + + return { + name, + protocol: 'hysteria2', + server, + port, + network: 'tcp', + tls: true, + sni: params.get('sni') || null, + skipCertVerify: readInsecure(params) || !!(params.get('pinSHA256') || params.get('pinsha256')), + password: auth, + obfsType: obfs === 'salamander' ? 'salamander' : null, + obfsPassword: params.get('obfs-password') || params.get('obfs_password') || null, + tags: inferTags(name), + }; +} + +export function encodeHysteria2(n: ParsedShareLink): string { + const params = new URLSearchParams(); + if (n.sni) params.set('sni', n.sni); + if (n.skipCertVerify) params.set('insecure', '1'); + if (n.obfsType === 'salamander' && n.obfsPassword) { + params.set('obfs', 'salamander'); + params.set('obfs-password', n.obfsPassword); + } + const qs = params.toString(); + return `hysteria2://${encodeURIComponent(n.password || '')}@${n.server}:${n.port}${qs ? `?${qs}` : ''}#${encodeURIComponent(n.name)}`; +} diff --git a/server/src/services/sharelink/parse.test.ts b/server/src/services/sharelink/parse.test.ts new file mode 100644 index 0000000..02fa7b9 --- /dev/null +++ b/server/src/services/sharelink/parse.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import { encodeShareLink, parseShareLink } from './parse.js'; + +const PLACEHOLDER_UUID = '00000000-1111-2222-3333-444444444444'; +const PLACEHOLDER_PASS = 'placeholder-password'; + +describe('vmess', () => { + const json = { + v: '2', + ps: 'Edge Node XX-vmess-direct', + add: 'cc.xx.example.com', + port: '443', + id: PLACEHOLDER_UUID, + aid: '0', + scy: 'auto', + net: 'ws', + type: 'none', + host: 'cc.xx.example.com', + path: '/abc123', + tls: 'tls', + sni: 'cc.xx.example.com', + alpn: '', + }; + const uri = 'vmess://' + Buffer.from(JSON.stringify(json)).toString('base64'); + + it('解析 edge-nodes 交付格式', () => { + const n = parseShareLink(uri); + expect(n).toMatchObject({ + protocol: 'vmess', + server: 'cc.xx.example.com', + port: 443, + network: 'ws', + wsPath: '/abc123', + wsHost: 'cc.xx.example.com', + tls: true, + sni: 'cc.xx.example.com', + uuid: PLACEHOLDER_UUID, + alterId: 0, + tags: ['direct'], + }); + }); + + it('round-trip 一致', () => { + const n = parseShareLink(uri); + expect(parseShareLink(encodeShareLink(n))).toEqual(n); + }); +}); + +describe('trojan', () => { + const uri = `trojan://${PLACEHOLDER_PASS}@cc.xx.example.com:443?security=tls&type=ws&host=cc.xx.example.com&path=%2F4688c6&sni=cc.xx.example.com#Edge%20Node%20XX-trojan-warp-clean-exit`; + + it('解析 edge-nodes 交付格式', () => { + const n = parseShareLink(uri); + expect(n).toMatchObject({ + protocol: 'trojan', + server: 'cc.xx.example.com', + port: 443, + network: 'ws', + wsPath: '/4688c6', + wsHost: 'cc.xx.example.com', + tls: true, + password: PLACEHOLDER_PASS, + tags: ['warp'], + }); + expect(n.name).toBe('Edge Node XX-trojan-warp-clean-exit'); + }); + + it('round-trip 一致', () => { + const n = parseShareLink(uri); + expect(parseShareLink(encodeShareLink(n))).toEqual(n); + }); +}); + +describe('shadowsocks', () => { + const userinfo = Buffer.from(`chacha20-poly1305:${PLACEHOLDER_PASS}`).toString('base64url'); + const plugin = encodeURIComponent('v2ray-plugin;tls;mode=websocket;host=cc.xx.example.com;path=/22a3d7'); + const uri = `ss://${userinfo}@cc.xx.example.com:443?plugin=${plugin}#ss-direct`; + + it('解析 SIP002 + v2ray-plugin(SS over WS+TLS)', () => { + const n = parseShareLink(uri); + expect(n).toMatchObject({ + protocol: 'shadowsocks', + server: 'cc.xx.example.com', + port: 443, + network: 'ws', + wsPath: '/22a3d7', + wsHost: 'cc.xx.example.com', + tls: true, + method: 'chacha20-poly1305', + password: PLACEHOLDER_PASS, + }); + }); + + it('round-trip 一致', () => { + const n = parseShareLink(uri); + expect(parseShareLink(encodeShareLink(n))).toEqual(n); + }); + + it('解析纯 TCP 的 SIP002', () => { + const n = parseShareLink(`ss://${userinfo}@1.2.3.4:8388#plain`); + expect(n).toMatchObject({ network: 'tcp', tls: false, server: '1.2.3.4', port: 8388 }); + }); + + it('解析 Xray 风格参数(3x-ui 导出格式)', () => { + const uri = `ss://${userinfo}@cc.xx.example.com:443?type=ws&security=tls&path=%2F22a3d7&sni=cc.xx.example.com#ss-warp`; + expect(parseShareLink(uri)).toMatchObject({ + network: 'ws', + wsPath: '/22a3d7', + wsHost: 'cc.xx.example.com', + tls: true, + sni: 'cc.xx.example.com', + method: 'chacha20-poly1305', + }); + }); +}); + +describe('hysteria2', () => { + const uri = `hysteria2://${PLACEHOLDER_PASS}@hy2.xx.example.com:443?sni=hy2.xx.example.com&obfs=salamander&obfs-password=obfs-placeholder#hy2-direct`; + + it('解析含 salamander obfs 的链接', () => { + const n = parseShareLink(uri); + expect(n).toMatchObject({ + protocol: 'hysteria2', + server: 'hy2.xx.example.com', + port: 443, + tls: true, + password: PLACEHOLDER_PASS, + obfsType: 'salamander', + obfsPassword: 'obfs-placeholder', + }); + }); + + it('round-trip 一致', () => { + const n = parseShareLink(uri); + expect(parseShareLink(encodeShareLink(n))).toEqual(n); + }); + + it('hy2:// 别名可解析', () => { + expect(parseShareLink(uri.replace('hysteria2://', 'hy2://')).protocol).toBe('hysteria2'); + }); +}); + +describe('节点命名规范', () => { + // 与 web/src/utils/nodeName.ts 的 TRAILING_EGRESS 保持一致 + const strip = (name: string) => name.replace(/[\s·・_-]+(direct|warp|直连)\s*$/iu, '').trim(); + + it('界面显示名剥掉末尾出口词', () => { + expect(strip('🇺🇸 US LA Trojan Warp')).toBe('🇺🇸 US LA Trojan'); + expect(strip('🇸🇬 SG VMess Direct')).toBe('🇸🇬 SG VMess'); + expect(strip('🇸🇬 SG · HY2 · 直连')).toBe('🇸🇬 SG · HY2'); // 兼容旧格式 + expect(strip('无出口词的节点')).toBe('无出口词的节点'); + }); + + it('新命名仍能被地区组正则命中', () => { + const sg = /坡|🇸🇬|新加坡|狮城|SG|Singapore/u; + const us = /美|🇺🇸|美国|US|States|American/u; + expect(sg.test('🇸🇬 SG VMess Direct')).toBe(true); + expect(us.test('🇺🇸 US RN HY2 Warp')).toBe(true); + expect(sg.test('🇺🇸 US LA Trojan Warp')).toBe(false); + }); +}); + +describe('批量与异常', () => { + it('未知协议抛错', () => { + expect(() => parseShareLink('vless://x@y:1')).toThrow(); + }); +}); diff --git a/server/src/services/sharelink/parse.ts b/server/src/services/sharelink/parse.ts new file mode 100644 index 0000000..a94f3a7 --- /dev/null +++ b/server/src/services/sharelink/parse.ts @@ -0,0 +1,44 @@ +import { parseVmess, encodeVmess } from './vmess.js'; +import { parseTrojan, encodeTrojan } from './trojan.js'; +import { parseSs, encodeSs } from './ss.js'; +import { parseHysteria2, encodeHysteria2 } from './hysteria2.js'; +import type { ParsedShareLink } from './types.js'; + +export type { ParsedShareLink }; + +export function parseShareLink(uri: string): ParsedShareLink { + const s = uri.trim(); + if (s.startsWith('vmess://')) return parseVmess(s); + if (s.startsWith('trojan://')) return parseTrojan(s); + if (s.startsWith('ss://')) return parseSs(s); + if (s.startsWith('hysteria2://') || s.startsWith('hy2://')) return parseHysteria2(s); + throw new Error('不支持的链接协议(支持 vmess:// trojan:// ss:// hysteria2://)'); +} + +export function encodeShareLink(n: ParsedShareLink): string { + switch (n.protocol) { + case 'vmess': + return encodeVmess(n); + case 'trojan': + return encodeTrojan(n); + case 'shadowsocks': + return encodeSs(n); + case 'hysteria2': + return encodeHysteria2(n); + } +} + +/** 多行文本 → 逐行解析(跳过空行与注释行) */ +export function parseShareLinkBatch(text: string): { uri: string; node?: ParsedShareLink; error?: string }[] { + return text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('#') && !l.startsWith('//')) + .map((uri) => { + try { + return { uri, node: parseShareLink(uri) }; + } catch (e: any) { + return { uri, error: e.message || '解析失败' }; + } + }); +} diff --git a/server/src/services/sharelink/ss.ts b/server/src/services/sharelink/ss.ts new file mode 100644 index 0000000..c3c7d45 --- /dev/null +++ b/server/src/services/sharelink/ss.ts @@ -0,0 +1,102 @@ +import { b64decode, inferTags, normalizeWsPath } from './util.js'; +import type { ParsedShareLink } from './types.js'; + +// SIP002: ss://base64url(method:password)@host:port?plugin=v2ray-plugin%3Btls%3Bmode%3Dwebsocket%3Bpath%3D%2Fx%3Bhost%3Dh#name +// 旧格式: ss://base64(method:password@host:port)#name +export function parseSs(uri: string): ParsedShareLink { + const url = new URL(uri); + let method: string; + let password: string; + let server: string; + let port: number; + + if (url.username) { + const decoded = b64decode(decodeURIComponent(url.username)); + const idx = decoded.indexOf(':'); + if (idx === -1) throw new Error('ss userinfo 缺少 method:password'); + method = decoded.slice(0, idx); + password = decoded.slice(idx + 1); + server = url.hostname; + port = parseInt(url.port, 10); + } else { + // 整体 base64 的旧格式 + const decoded = b64decode(uri.slice('ss://'.length).split('#')[0]); + const at = decoded.lastIndexOf('@'); + if (at === -1) throw new Error('无法解析 ss 链接'); + const [m, ...rest] = decoded.slice(0, at).split(':'); + method = m; + password = rest.join(':'); + const hostPort = decoded.slice(at + 1); + const colon = hostPort.lastIndexOf(':'); + server = hostPort.slice(0, colon); + port = parseInt(hostPort.slice(colon + 1), 10); + } + if (!server || !port || !method) throw new Error('ss 链接缺少 server/port/method'); + + const name = decodeURIComponent(url.hash.slice(1)) || 'Shadowsocks'; + const node: ParsedShareLink = { + name, + protocol: 'shadowsocks', + server, + port, + network: 'tcp', + tls: false, + method, + password, + tags: inferTags(name), + }; + + // Xray 风格参数(3x-ui 导出即此形式):type=ws&security=tls&path=&host=&sni= + const params = url.searchParams; + if (params.get('type') === 'ws') { + node.network = 'ws'; + node.wsPath = normalizeWsPath(params.get('path')); + node.wsHost = params.get('host') || params.get('sni') || null; + } + if (params.get('security') === 'tls') { + node.tls = true; + node.sni = params.get('sni') || node.wsHost || null; + } + + // v2ray-plugin 的 websocket/tls 参数(ShadowRocket、ClashMeta 通用写法) + const plugin = params.get('plugin'); + if (plugin && plugin.includes('v2ray-plugin')) { + const opts = new Map( + plugin + .split(';') + .slice(1) + .map((kv) => { + const eq = kv.indexOf('='); + return eq === -1 ? [kv, 'true'] : [kv.slice(0, eq), kv.slice(eq + 1)]; + }) as [string, string][] + ); + if (opts.get('mode') === 'websocket') { + node.network = 'ws'; + node.wsPath = normalizeWsPath(opts.get('path')); + node.wsHost = opts.get('host') || null; + } + if (opts.has('tls')) { + node.tls = true; + node.sni = opts.get('host') || null; + } + } + return node; +} + +export function encodeSs(n: ParsedShareLink): string { + const userinfo = Buffer.from(`${n.method}:${n.password}`, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + let uri = `ss://${userinfo}@${n.server}:${n.port}`; + if (n.network === 'ws') { + const parts = ['v2ray-plugin']; + if (n.tls) parts.push('tls'); + parts.push('mode=websocket'); + if (n.wsHost) parts.push(`host=${n.wsHost}`); + if (n.wsPath) parts.push(`path=${n.wsPath}`); + uri += `?plugin=${encodeURIComponent(parts.join(';'))}`; + } + return `${uri}#${encodeURIComponent(n.name)}`; +} diff --git a/server/src/services/sharelink/trojan.ts b/server/src/services/sharelink/trojan.ts new file mode 100644 index 0000000..879fae4 --- /dev/null +++ b/server/src/services/sharelink/trojan.ts @@ -0,0 +1,41 @@ +import { inferTags, normalizeWsPath, readInsecure } from './util.js'; +import type { ParsedShareLink } from './types.js'; + +// trojan://password@host:port?security=tls&type=ws&host=h&path=%2Fx&sni=s#name +export function parseTrojan(uri: string): ParsedShareLink { + const url = new URL(uri); + const server = url.hostname; + const port = parseInt(url.port || '443', 10); + const password = decodeURIComponent(url.username); + if (!server || !password) throw new Error('trojan 链接缺少 host/password'); + const name = decodeURIComponent(url.hash.slice(1)) || 'Trojan'; + const params = url.searchParams; + const network = params.get('type') === 'ws' ? 'ws' : 'tcp'; + + return { + name, + protocol: 'trojan', + server, + port, + network, + wsPath: network === 'ws' ? normalizeWsPath(params.get('path')) : null, + wsHost: network === 'ws' ? params.get('host') : null, + tls: true, + sni: params.get('sni') || server, + skipCertVerify: readInsecure(params), + password, + tags: inferTags(name), + }; +} + +export function encodeTrojan(n: ParsedShareLink): string { + const params = new URLSearchParams(); + params.set('security', 'tls'); + if (n.network === 'ws') { + params.set('type', 'ws'); + if (n.wsHost) params.set('host', n.wsHost); + if (n.wsPath) params.set('path', n.wsPath); + } + if (n.sni) params.set('sni', n.sni); + return `trojan://${encodeURIComponent(n.password || '')}@${n.server}:${n.port}?${params.toString()}#${encodeURIComponent(n.name)}`; +} diff --git a/server/src/services/sharelink/types.ts b/server/src/services/sharelink/types.ts new file mode 100644 index 0000000..069e872 --- /dev/null +++ b/server/src/services/sharelink/types.ts @@ -0,0 +1,23 @@ +import type { Protocol } from '@proxy-station/shared'; + +/** 分享链接解析出的节点字段(与 nodeInputSchema 对齐,未落库) */ +export interface ParsedShareLink { + name: string; + protocol: Protocol; + server: string; + port: number; + network: 'tcp' | 'ws'; + wsPath?: string | null; + wsHost?: string | null; + tls: boolean; + sni?: string | null; + skipCertVerify?: boolean; + uuid?: string | null; + alterId?: number; + security?: string; + password?: string | null; + method?: string | null; + obfsType?: 'salamander' | null; + obfsPassword?: string | null; + tags?: string[]; +} diff --git a/server/src/services/sharelink/util.ts b/server/src/services/sharelink/util.ts new file mode 100644 index 0000000..8494520 --- /dev/null +++ b/server/src/services/sharelink/util.ts @@ -0,0 +1,27 @@ +export function b64decode(s: string): string { + return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); +} + +export function b64encode(s: string): string { + return Buffer.from(s, 'utf8').toString('base64'); +} + +/** WS path 统一保证以 / 开头 */ +export function normalizeWsPath(p: string | null | undefined): string | null { + if (!p) return null; + return p.startsWith('/') ? p : `/${p}`; +} + +export function readInsecure(params: URLSearchParams): boolean { + const v = params.get('insecure') || params.get('allowInsecure') || params.get('skip-cert-verify'); + return v === '1' || v === 'true'; +} + +/** 从节点名推断出口 tag(warp/direct),供前端线路矩阵归位 */ +export function inferTags(name: string): string[] { + const lower = name.toLowerCase(); + const tags: string[] = []; + if (lower.includes('warp')) tags.push('warp'); + else if (lower.includes('direct')) tags.push('direct'); + return tags; +} diff --git a/server/src/services/sharelink/vmess.ts b/server/src/services/sharelink/vmess.ts new file mode 100644 index 0000000..77edf78 --- /dev/null +++ b/server/src/services/sharelink/vmess.ts @@ -0,0 +1,50 @@ +import { b64decode, inferTags, normalizeWsPath } from './util.js'; +import type { ParsedShareLink } from './types.js'; + +// vmess://base64({"v":"2","ps":name,"add":host,"port":"443","id":uuid,"aid":"0", +// "scy":"auto","net":"ws","type":"none","host":wsHost,"path":"/x","tls":"tls","sni":sni}) +export function parseVmess(uri: string): ParsedShareLink { + const json = JSON.parse(b64decode(uri.slice('vmess://'.length))); + const name = json.ps || 'VMess'; + const server = String(json.add || ''); + const port = parseInt(String(json.port || '0'), 10); + const uuid = String(json.id || ''); + if (!server || !port || !uuid) throw new Error('vmess 链接缺少 add/port/id'); + + const network = json.net === 'ws' ? 'ws' : 'tcp'; + return { + name, + protocol: 'vmess', + server, + port, + network, + wsPath: network === 'ws' ? normalizeWsPath(json.path) : null, + wsHost: network === 'ws' ? json.host || null : null, + tls: json.tls === 'tls', + sni: json.sni || null, + uuid, + alterId: parseInt(String(json.aid ?? '0'), 10) || 0, + security: json.scy || 'auto', + tags: inferTags(name), + }; +} + +export function encodeVmess(n: ParsedShareLink): string { + const json: Record = { + v: '2', + ps: n.name, + add: n.server, + port: String(n.port), + id: n.uuid || '', + aid: String(n.alterId ?? 0), + scy: n.security || 'auto', + net: n.network === 'ws' ? 'ws' : 'tcp', + type: 'none', + host: n.wsHost || '', + path: n.wsPath || '', + tls: n.tls ? 'tls' : '', + sni: n.sni || '', + alpn: '', + }; + return 'vmess://' + Buffer.from(JSON.stringify(json)).toString('base64'); +} diff --git a/server/src/services/template/surge-parser.test.ts b/server/src/services/template/surge-parser.test.ts new file mode 100644 index 0000000..39d12ac --- /dev/null +++ b/server/src/services/template/surge-parser.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { parseSurgeConf, splitTopLevel } from './surge-parser.js'; + +describe('splitTopLevel', () => { + it('引号内逗号不切分', () => { + expect(splitTopLevel('select, "Hong Kong", Taiwan')).toEqual(['select', '"Hong Kong"', 'Taiwan']); + }); + it('括号内逗号不切分(AND 复合规则)', () => { + expect(splitTopLevel('AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP')).toEqual([ + 'AND', + '((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com))', + 'REJECT-NO-DROP', + ]); + }); +}); + +describe('parseSurgeConf', () => { + const conf = ` +[General] +ipv6 = false + +[Proxy Group] +Auto = url-test, include-other-group=All, policy-regex-filter=SG|Singapore, url=http://x/gen, interval=300, tolerance=50, icon-url=http://icon +Pick = select, Auto, "Hong Kong", DIRECT +All = select, policy-path="placeholder", hidden=0 + +[Rule] +RULE-SET,https://example.com/a.list,Pick +DOMAIN-SUFFIX,example.com,"Hong Kong",no-resolve +FINAL,Pick,dns-failed + +[MITM] +hostname = *.example.com +`; + const parsed = parseSurgeConf(conf); + + it('组解析:类型、成员、regex、include-other-group', () => { + const auto = parsed.groups.find((g) => g.name === 'Auto')!; + expect(auto).toMatchObject({ type: 'url-test', filterRegex: 'SG|Singapore', includeOtherGroup: 'All', interval: 300, tolerance: 50 }); + const pick = parsed.groups.find((g) => g.name === 'Pick')!; + expect(pick.members).toEqual(['Auto', 'Hong Kong', 'DIRECT']); + expect(parsed.groups.find((g) => g.name === 'All')!.hasPolicyPath).toBe(true); + }); + + it('规则解析:RULE-SET、带引号策略、FINAL 参数', () => { + expect(parsed.rules[0]).toMatchObject({ type: 'RULE-SET', value: 'https://example.com/a.list', policy: 'Pick' }); + expect(parsed.rules[1]).toMatchObject({ type: 'DOMAIN-SUFFIX', policy: 'Hong Kong', params: ['no-resolve'] }); + expect(parsed.rules[2]).toMatchObject({ type: 'FINAL', policy: 'Pick', params: ['dns-failed'] }); + }); + + it('其他段落原文保留', () => { + expect(parsed.sections['MITM']).toBe('hostname = *.example.com'); + expect(parsed.sections['General']).toBe('ipv6 = false'); + }); +}); diff --git a/server/src/services/template/surge-parser.ts b/server/src/services/template/surge-parser.ts new file mode 100644 index 0000000..27a0a91 --- /dev/null +++ b/server/src/services/template/surge-parser.ts @@ -0,0 +1,159 @@ +/** + * 解析 Surge .conf 模板:分段原文 + [Proxy Group] / [Rule] 的结构化结果。 + * 只解析本项目建模所需的字段,未识别的组参数原样忽略。 + */ + +export interface ParsedGroup { + name: string; + type: 'select' | 'url-test' | 'fallback'; + members: string[]; // 组名或 DIRECT/REJECT,顺序保留 + testUrl?: string; + interval?: number; + tolerance?: number; + filterRegex?: string; + includeOtherGroup?: string; + includeAllProxies?: boolean; + hasPolicyPath?: boolean; // 上游订阅占位(AllServer)→ 我们的「全部节点」组 +} + +export interface ParsedRule { + type: string; + value: string | null; + policy: string; + params: string[]; // 如 no-resolve / dns-failed / extended-matching +} + +export interface ParsedSurgeConf { + sections: Record; // 段名(不含中括号)→ 原文 + groups: ParsedGroup[]; + rules: ParsedRule[]; +} + +/** 按逗号切分,但忽略引号与括号内部的逗号 */ +export function splitTopLevel(line: string): string[] { + const out: string[] = []; + let cur = ''; + let depth = 0; + let quote: string | null = null; + for (const ch of line) { + if (quote) { + cur += ch; + if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + cur += ch; + continue; + } + if (ch === '(') depth++; + if (ch === ')') depth--; + if (ch === ',' && depth === 0) { + out.push(cur.trim()); + cur = ''; + continue; + } + cur += ch; + } + if (cur.trim()) out.push(cur.trim()); + return out; +} + +function unquote(s: string): string { + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1); + } + return s; +} + +const KNOWN_GROUP_TYPES = new Set(['select', 'url-test', 'fallback']); +/** 这些参数不建模,序列化 Surge 时也不透传(icon 等纯装饰) */ +const RULE_PARAM_WHITELIST = new Set(['no-resolve', 'dns-failed', 'extended-matching', 'force-remote-dns']); + +export function parseSurgeConf(text: string): ParsedSurgeConf { + const sections: Record = {}; + let current = ''; + for (const raw of text.split(/\r?\n/)) { + const trimmed = raw.trim(); + const m = trimmed.match(/^\[(.+)\]$/); + if (m) { + current = m[1]; + sections[current] = []; + continue; + } + if (current) sections[current].push(raw); + } + + const groups: ParsedGroup[] = []; + for (const raw of sections['Proxy Group'] ?? []) { + const line = raw.trim(); + if (!line || line.startsWith('#') || line.startsWith('//')) continue; + const eq = line.indexOf('='); + if (eq === -1) continue; + const name = line.slice(0, eq).trim(); + const parts = splitTopLevel(line.slice(eq + 1)); + const type = parts[0]; + if (!KNOWN_GROUP_TYPES.has(type)) continue; + + const group: ParsedGroup = { name, type: type as ParsedGroup['type'], members: [] }; + for (const part of parts.slice(1)) { + const kvEq = part.indexOf('='); + // 无 = 的项是成员(可能带引号) + if (kvEq === -1 || part.startsWith('"') || part.startsWith("'")) { + group.members.push(unquote(part)); + continue; + } + const key = part.slice(0, kvEq).trim(); + const value = unquote(part.slice(kvEq + 1).trim()); + switch (key) { + case 'url': + group.testUrl = value; + break; + case 'interval': + group.interval = parseInt(value, 10) || undefined; + break; + case 'tolerance': + group.tolerance = parseInt(value, 10) || undefined; + break; + case 'policy-regex-filter': + group.filterRegex = value; + break; + case 'include-other-group': + group.includeOtherGroup = value; + break; + case 'include-all-proxies': + group.includeAllProxies = value === '1' || value === 'true'; + break; + case 'policy-path': + group.hasPolicyPath = true; + break; + // no-alert / hidden / icon-url / update-interval / timeout 等不建模 + } + } + groups.push(group); + } + + const rules: ParsedRule[] = []; + for (const raw of sections['Rule'] ?? []) { + const line = raw.trim(); + if (!line || line.startsWith('#') || line.startsWith('//')) continue; + const parts = splitTopLevel(line); + const type = parts[0]; + if (!type) continue; + if (type === 'FINAL') { + rules.push({ type, value: null, policy: unquote(parts[1] ?? 'DIRECT'), params: parts.slice(2) }); + continue; + } + if (parts.length < 3) continue; + const params = parts.slice(3).filter((p) => RULE_PARAM_WHITELIST.has(p)); + rules.push({ type, value: parts[1], policy: unquote(parts[2]), params }); + } + + const rawSections: Record = {}; + for (const [name, lines] of Object.entries(sections)) { + if (name === 'Proxy' || name === 'Proxy Group' || name === 'Rule') continue; + rawSections[name] = lines.join('\n').trim(); + } + + return { sections: rawSections, groups, rules }; +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..fe7c4fd --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..b3d9072 --- /dev/null +++ b/shared/package.json @@ -0,0 +1,21 @@ +{ + "name": "@proxy-station/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "zod": "^3.24.1" + }, + "devDependencies": { + "typescript": "^5.7.2" + } +} diff --git a/shared/src/dto.ts b/shared/src/dto.ts new file mode 100644 index 0000000..7704466 --- /dev/null +++ b/shared/src/dto.ts @@ -0,0 +1,119 @@ +import type { ClientType, Egress, Protocol } from './protocols.js'; + +export interface NodeDto { + id: string; + name: string; + protocol: Protocol; + server: string; + port: number; + network: 'tcp' | 'ws'; + wsPath: string | null; + wsHost: string | null; + tls: boolean; + sni: string | null; + skipCertVerify: boolean; + uuid: string | null; + alterId: number; + security: string; + password: string | null; + method: string | null; + obfsType: string | null; + obfsPassword: string | null; + upMbps: number | null; + downMbps: number | null; + region: string | null; + tags: string[]; + enabled: boolean; + sortOrder: number; + createdAt: number; + updatedAt: number; +} + +/** 从节点 tags/名称推断的出口类型,仅前端展示用 */ +export function nodeEgress(node: Pick): Egress { + const haystack = [...node.tags, node.name].join(' ').toLowerCase(); + if (haystack.includes('warp')) return 'warp'; + if (haystack.includes('direct')) return 'direct'; + return 'unknown'; +} + +export interface ImportPreviewItem { + ok: boolean; + uri: string; + error?: string; + node?: Partial & { name: string; protocol: Protocol }; +} + +export interface GroupMemberDto { + id: number; + kind: 'node' | 'group' | 'builtin'; + nodeId: string | null; + refName: string | null; + sortOrder: number; +} + +export interface GroupDto { + id: string; + name: string; + type: 'select' | 'url-test' | 'fallback'; + testUrl: string | null; + interval: number | null; + tolerance: number | null; + filterRegex: string | null; + includeAllNodes: boolean; + enabled: boolean; + sortOrder: number; + source: 'template' | 'custom'; + members: GroupMemberDto[]; +} + +export interface RuleDto { + id: string; + seq: number; + type: string; + value: string | null; + policy: string; + params: string[]; + enabled: boolean; + source: 'template' | 'custom'; + rulesetName?: string | null; + rulesetUrl?: string | null; +} + +export interface RulesetDto { + id: string; + name: string; + surgeUrl: string; + clashUrl: string | null; + clashBehavior: string; + needsConvert: boolean; +} + +export interface TokenDto { + id: string; + token: string; + name: string; + /** 分配给该订阅的节点 id;空数组 = 全部节点 */ + nodeIds: string[]; + enabled: boolean; + createdAt: number; + lastAccessAt: number | null; + accessCount: number; + /** 各客户端订阅路径;ShadowRocket 额外有 shadowrocketConf(策略组与规则) */ + urls: Record & { shadowrocketConf: string }; +} + +export interface ExcludedNode { + name: string; + reason: string; +} + +export interface PreviewResult { + client: ClientType; + content: string; + excluded: ExcludedNode[]; +} + +export interface SettingsDto { + surgeSections: Record; +} diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 0000000..f55423f --- /dev/null +++ b/shared/src/index.ts @@ -0,0 +1,3 @@ +export * from './protocols.js'; +export * from './schemas.js'; +export * from './dto.js'; diff --git a/shared/src/protocols.ts b/shared/src/protocols.ts new file mode 100644 index 0000000..0dbaf6f --- /dev/null +++ b/shared/src/protocols.ts @@ -0,0 +1,50 @@ +export const PROTOCOLS = ['vmess', 'trojan', 'shadowsocks', 'hysteria2'] as const; +export type Protocol = (typeof PROTOCOLS)[number]; + +export const CLIENTS = ['surge', 'shadowrocket', 'clash'] as const; +export type ClientType = (typeof CLIENTS)[number]; + +export const CLIENT_LABELS: Record = { + surge: 'Surge (macOS)', + shadowrocket: 'ShadowRocket (iOS)', + clash: 'ClashMeta (Android)', +}; + +/** + * ShadowRocket 分两步导入:节点走订阅、策略组与规则走配置文件。 + * 配置文件里的策略组用 `use=true` 引用订阅,因此 App 内的订阅名必须与此一致。 + */ +export const SHADOWROCKET_SUB_NAME = 'Proxy Station'; + +export type Egress = 'direct' | 'warp' | 'unknown'; + +/** Surge 与 Clash 使用 IETF 命名,Xray 分享链接使用简名 */ +export const SS_CIPHER_ALIASES: Record = { + 'chacha20-poly1305': 'chacha20-ietf-poly1305', + 'xchacha20-poly1305': 'xchacha20-ietf-poly1305', +}; + +export function normalizeSsCipher(method: string): string { + return SS_CIPHER_ALIASES[method] ?? method; +} + +export interface NodeCapabilityInput { + protocol: Protocol; + network: 'tcp' | 'ws'; +} + +export interface CapabilityVerdict { + ok: boolean; + reason?: string; +} + +/** + * 客户端能力矩阵:不支持的组合在下发订阅时自动屏蔽。 + * Surge 的 ss 类型没有 WebSocket 传输(仅 simple-obfs),SS over WS 线路必须排除。 + */ +export function clientSupports(client: ClientType, node: NodeCapabilityInput): CapabilityVerdict { + if (client === 'surge' && node.protocol === 'shadowsocks' && node.network === 'ws') { + return { ok: false, reason: 'Surge 不支持 Shadowsocks over WebSocket' }; + } + return { ok: true }; +} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts new file mode 100644 index 0000000..8d6148f --- /dev/null +++ b/shared/src/schemas.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; +import { PROTOCOLS } from './protocols.js'; + +export const nodeInputSchema = z + .object({ + name: z.string().min(1).max(64), + protocol: z.enum(PROTOCOLS), + server: z.string().min(1), + port: z.number().int().min(1).max(65535), + network: z.enum(['tcp', 'ws']).default('tcp'), + wsPath: z.string().startsWith('/').optional().nullable(), + wsHost: z.string().optional().nullable(), + tls: z.boolean().default(false), + sni: z.string().optional().nullable(), + skipCertVerify: z.boolean().default(false), + uuid: z.string().uuid().optional().nullable(), + alterId: z.number().int().min(0).default(0), + security: z.string().default('auto'), + password: z.string().optional().nullable(), + method: z.string().optional().nullable(), + obfsType: z.enum(['salamander']).optional().nullable(), + obfsPassword: z.string().optional().nullable(), + upMbps: z.number().int().positive().optional().nullable(), + downMbps: z.number().int().positive().optional().nullable(), + region: z.string().max(8).optional().nullable(), + tags: z.array(z.string()).default([]), + enabled: z.boolean().default(true), + sortOrder: z.number().int().default(0), + }) + .superRefine((v, ctx) => { + if (v.protocol === 'vmess' && !v.uuid) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['uuid'], message: 'VMess 需要 UUID' }); + } + if ((v.protocol === 'trojan' || v.protocol === 'shadowsocks' || v.protocol === 'hysteria2') && !v.password) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['password'], message: '该协议需要密码' }); + } + if (v.protocol === 'shadowsocks' && !v.method) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['method'], message: 'Shadowsocks 需要加密方式' }); + } + if (v.network === 'ws' && !v.wsPath) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['wsPath'], message: 'WebSocket 传输需要路径' }); + } + }); + +export type NodeInput = z.input; +export type NodeParsed = z.output; + +export const groupInputSchema = z.object({ + name: z.string().min(1).max(64), + type: z.enum(['select', 'url-test', 'fallback']), + testUrl: z.string().url().optional().nullable(), + interval: z.number().int().positive().optional().nullable(), + tolerance: z.number().int().positive().optional().nullable(), + filterRegex: z.string().optional().nullable(), + includeAllNodes: z.boolean().default(false), + sortOrder: z.number().int().default(0), + members: z + .array( + z.object({ + kind: z.enum(['node', 'group', 'builtin']), + nodeId: z.string().optional().nullable(), + refName: z.string().optional().nullable(), + }) + ) + .default([]), +}); +export type GroupInput = z.input; + +export const RULE_TYPES = [ + 'RULE-SET', + 'DOMAIN-SET', + 'DOMAIN', + 'DOMAIN-SUFFIX', + 'DOMAIN-KEYWORD', + 'IP-CIDR', + 'IP-CIDR6', + 'IP-ASN', + 'GEOIP', + 'PROCESS-NAME', + 'USER-AGENT', + 'URL-REGEX', + 'AND', + 'OR', + 'NOT', + 'FINAL', +] as const; + +export const ruleInputSchema = z.object({ + type: z.enum(RULE_TYPES), + value: z.string().optional().nullable(), + policy: z.string().min(1), + params: z.array(z.string()).default([]), + enabled: z.boolean().default(true), +}); +export type RuleInput = z.input; + +export const tokenInputSchema = z.object({ + name: z.string().min(1).max(64), +}); diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..aca6172 --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..63b9678 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Proxy Station + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..a8de8c8 --- /dev/null +++ b/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "@proxy-station/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@fontsource/ibm-plex-mono": "^5.1.0", + "@proxy-station/shared": "workspace:*", + "naive-ui": "^2.40.4", + "pinia": "^2.3.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.2", + "vite": "^6.0.6", + "vue-tsc": "^2.2.0" + } +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..f0eeee4 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..3f71020 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,44 @@ +const ADMIN_TOKEN_KEY = 'proxy-station:admin-token'; + +export function getAdminToken(): string { + return localStorage.getItem(ADMIN_TOKEN_KEY) || ''; +} + +export function setAdminToken(token: string) { + if (token) localStorage.setItem(ADMIN_TOKEN_KEY, token); + else localStorage.removeItem(ADMIN_TOKEN_KEY); +} + +export class ApiError extends Error { + constructor( + public status: number, + message: string + ) { + super(message); + } +} + +async function request(method: string, path: string, body?: unknown): Promise { + const headers: Record = {}; + const token = getAdminToken(); + if (token) headers.Authorization = `Bearer ${token}`; + if (body !== undefined) headers['content-type'] = 'application/json'; + const res = await fetch(path, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new ApiError(res.status, (data as any)?.error || `请求失败(${res.status})`); + } + return data as T; +} + +export const api = { + get: (path: string) => request('GET', path), + post: (path: string, body?: unknown) => request('POST', path, body), + put: (path: string, body?: unknown) => request('PUT', path, body), + patch: (path: string, body?: unknown) => request('PATCH', path, body), + delete: (path: string) => request('DELETE', path), +}; diff --git a/web/src/components/EgressBadge.vue b/web/src/components/EgressBadge.vue new file mode 100644 index 0000000..0061c48 --- /dev/null +++ b/web/src/components/EgressBadge.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/web/src/components/ImportModal.vue b/web/src/components/ImportModal.vue new file mode 100644 index 0000000..8eae40a --- /dev/null +++ b/web/src/components/ImportModal.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/web/src/components/NodeFormModal.vue b/web/src/components/NodeFormModal.vue new file mode 100644 index 0000000..d473c25 --- /dev/null +++ b/web/src/components/NodeFormModal.vue @@ -0,0 +1,184 @@ + + + diff --git a/web/src/components/ProtoDot.vue b/web/src/components/ProtoDot.vue new file mode 100644 index 0000000..c89d638 --- /dev/null +++ b/web/src/components/ProtoDot.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/web/src/components/RulesetPreviewDrawer.vue b/web/src/components/RulesetPreviewDrawer.vue new file mode 100644 index 0000000..9119cb0 --- /dev/null +++ b/web/src/components/RulesetPreviewDrawer.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/web/src/components/TokenFormModal.vue b/web/src/components/TokenFormModal.vue new file mode 100644 index 0000000..a226501 --- /dev/null +++ b/web/src/components/TokenFormModal.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/web/src/components/stations.ts b/web/src/components/stations.ts new file mode 100644 index 0000000..ef4a17f --- /dev/null +++ b/web/src/components/stations.ts @@ -0,0 +1,23 @@ +import type { NodeDto } from '@proxy-station/shared'; + +/** + * 站点归组:优先 region 字段,否则从域名猜测(cc.sg.example.com → SG), + * 最后回落到 server 本身。 + */ +export function stationOf(node: NodeDto): string { + if (node.region) return node.region.toUpperCase(); + const parts = node.server.split('.'); + if (parts.length >= 4 && parts[1].length <= 4) return parts[1].toUpperCase(); + return node.server; +} + +/** 站点展示名:国旗 + 国家 + 站点代号 */ +const STATION_LABELS: Record = { + SG: '🇸🇬 SG AWS', + LA: '🇺🇸 US LA', + RN: '🇺🇸 US RN', +}; + +export function stationLabel(code: string): string { + return STATION_LABELS[code] ?? code; +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..7919739 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,10 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import App from './App.vue'; +import { router } from './router'; + +import '@fontsource/ibm-plex-mono/400.css'; +import '@fontsource/ibm-plex-mono/500.css'; +import './styles/tokens.css'; + +createApp(App).use(createPinia()).use(router).mount('#app'); diff --git a/web/src/pages/GroupsPage.vue b/web/src/pages/GroupsPage.vue new file mode 100644 index 0000000..253344a --- /dev/null +++ b/web/src/pages/GroupsPage.vue @@ -0,0 +1,353 @@ + + + + + diff --git a/web/src/pages/NodesPage.vue b/web/src/pages/NodesPage.vue new file mode 100644 index 0000000..4a81f58 --- /dev/null +++ b/web/src/pages/NodesPage.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/web/src/pages/RulesPage.vue b/web/src/pages/RulesPage.vue new file mode 100644 index 0000000..ff2e8c0 --- /dev/null +++ b/web/src/pages/RulesPage.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/web/src/pages/SettingsPage.vue b/web/src/pages/SettingsPage.vue new file mode 100644 index 0000000..821c05d --- /dev/null +++ b/web/src/pages/SettingsPage.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/web/src/pages/SubscriptionsPage.vue b/web/src/pages/SubscriptionsPage.vue new file mode 100644 index 0000000..711d22e --- /dev/null +++ b/web/src/pages/SubscriptionsPage.vue @@ -0,0 +1,404 @@ + + + + + diff --git a/web/src/router.ts b/web/src/router.ts new file mode 100644 index 0000000..56ca98b --- /dev/null +++ b/web/src/router.ts @@ -0,0 +1,13 @@ +import { createRouter, createWebHistory } from 'vue-router'; + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', redirect: '/nodes' }, + { path: '/nodes', component: () => import('./pages/NodesPage.vue'), meta: { title: '节点' } }, + { path: '/groups', component: () => import('./pages/GroupsPage.vue'), meta: { title: '策略组' } }, + { path: '/rules', component: () => import('./pages/RulesPage.vue'), meta: { title: '规则' } }, + { path: '/subscriptions', component: () => import('./pages/SubscriptionsPage.vue'), meta: { title: '订阅' } }, + { path: '/settings', component: () => import('./pages/SettingsPage.vue'), meta: { title: '设置' } }, + ], +}); diff --git a/web/src/stores/groups.ts b/web/src/stores/groups.ts new file mode 100644 index 0000000..016412a --- /dev/null +++ b/web/src/stores/groups.ts @@ -0,0 +1,41 @@ +import { defineStore } from 'pinia'; +import type { GroupDto, GroupInput } from '@proxy-station/shared'; +import { api } from '../api/client'; + +export const useGroupsStore = defineStore('groups', { + state: () => ({ + groups: [] as GroupDto[], + loading: false, + }), + actions: { + async fetchAll() { + this.loading = true; + try { + this.groups = await api.get('/api/groups'); + } finally { + this.loading = false; + } + }, + async create(input: GroupInput) { + await api.post('/api/groups', input); + await this.fetchAll(); + }, + async update(id: string, input: GroupInput) { + await api.put(`/api/groups/${id}`, input); + await this.fetchAll(); + }, + async remove(id: string) { + await api.delete(`/api/groups/${id}`); + this.groups = this.groups.filter((g) => g.id !== id); + }, + async toggle(id: string, enabled: boolean) { + await api.patch(`/api/groups/${id}/enabled`, { enabled }); + const group = this.groups.find((g) => g.id === id); + if (group) group.enabled = enabled; + }, + async reorder(ids: string[]) { + await api.put('/api/groups/reorder', { ids }); + await this.fetchAll(); + }, + }, +}); diff --git a/web/src/stores/nodes.ts b/web/src/stores/nodes.ts new file mode 100644 index 0000000..9ecb84f --- /dev/null +++ b/web/src/stores/nodes.ts @@ -0,0 +1,53 @@ +import { defineStore } from 'pinia'; +import type { NodeDto, NodeInput } from '@proxy-station/shared'; +import { api } from '../api/client'; + +export interface ImportResult { + committed: boolean; + total: number; + succeeded: number; + items: { ok: boolean; uri: string; error?: string; node?: Partial & { name: string } }[]; +} + +export const useNodesStore = defineStore('nodes', { + state: () => ({ + nodes: [] as NodeDto[], + loading: false, + }), + actions: { + async fetchAll() { + this.loading = true; + try { + this.nodes = await api.get('/api/nodes'); + } finally { + this.loading = false; + } + }, + async create(input: NodeInput) { + await api.post('/api/nodes', input); + await this.fetchAll(); + }, + async update(id: string, input: NodeInput) { + await api.put(`/api/nodes/${id}`, input); + await this.fetchAll(); + }, + async remove(id: string) { + await api.delete(`/api/nodes/${id}`); + this.nodes = this.nodes.filter((n) => n.id !== id); + }, + async toggle(id: string, enabled: boolean) { + await api.patch(`/api/nodes/${id}/enabled`, { enabled }); + const node = this.nodes.find((n) => n.id === id); + if (node) node.enabled = enabled; + }, + async importText(text: string, commit: boolean): Promise { + const result = await api.post('/api/nodes/import', { text, commit }); + if (commit) await this.fetchAll(); + return result; + }, + async exportUri(id: string): Promise { + const { uri } = await api.get<{ uri: string }>(`/api/nodes/${id}/uri`); + return uri; + }, + }, +}); diff --git a/web/src/stores/rules.ts b/web/src/stores/rules.ts new file mode 100644 index 0000000..c003bf9 --- /dev/null +++ b/web/src/stores/rules.ts @@ -0,0 +1,49 @@ +import { defineStore } from 'pinia'; +import type { RuleDto, RuleInput, RulesetDto } from '@proxy-station/shared'; +import { api } from '../api/client'; + +export const useRulesStore = defineStore('rules', { + state: () => ({ + rules: [] as RuleDto[], + rulesets: [] as RulesetDto[], + loading: false, + }), + actions: { + async fetchAll() { + this.loading = true; + try { + [this.rules, this.rulesets] = await Promise.all([ + api.get('/api/rules'), + api.get('/api/rulesets'), + ]); + } finally { + this.loading = false; + } + }, + async create(input: RuleInput) { + await api.post('/api/rules', input); + await this.fetchAll(); + }, + async update(id: string, input: RuleInput) { + await api.put(`/api/rules/${id}`, input); + await this.fetchAll(); + }, + async remove(id: string) { + await api.delete(`/api/rules/${id}`); + this.rules = this.rules.filter((r) => r.id !== id); + }, + async toggle(id: string, enabled: boolean) { + await api.patch(`/api/rules/${id}/enabled`, { enabled }); + const rule = this.rules.find((r) => r.id === id); + if (rule) rule.enabled = enabled; + }, + async reorder(ids: string[]) { + await api.put('/api/rules/reorder', { ids }); + await this.fetchAll(); + }, + async reset() { + await api.post('/api/rules/reset'); + await this.fetchAll(); + }, + }, +}); diff --git a/web/src/stores/subscriptions.ts b/web/src/stores/subscriptions.ts new file mode 100644 index 0000000..5d0dc4c --- /dev/null +++ b/web/src/stores/subscriptions.ts @@ -0,0 +1,49 @@ +import { defineStore } from 'pinia'; +import type { TokenDto } from '@proxy-station/shared'; +import { api } from '../api/client'; + +export interface SettingsData { + surgeSections: Record; +} + +export const useSubscriptionsStore = defineStore('subscriptions', { + state: () => ({ + tokens: [] as TokenDto[], + settings: null as SettingsData | null, + loading: false, + }), + actions: { + async fetchAll() { + this.loading = true; + try { + [this.tokens, this.settings] = await Promise.all([ + api.get('/api/tokens'), + api.get('/api/settings'), + ]); + } finally { + this.loading = false; + } + }, + async createToken(payload: { name: string; nodeIds: string[] }) { + await api.post('/api/tokens', payload); + await this.fetchAll(); + }, + async updateToken(id: string, payload: { name: string; nodeIds: string[] }) { + await api.put(`/api/tokens/${id}`, payload); + await this.fetchAll(); + }, + async removeToken(id: string) { + await api.delete(`/api/tokens/${id}`); + this.tokens = this.tokens.filter((t) => t.id !== id); + }, + async toggleToken(id: string, enabled: boolean) { + await api.patch(`/api/tokens/${id}/enabled`, { enabled }); + const token = this.tokens.find((t) => t.id === id); + if (token) token.enabled = enabled; + }, + async saveSettings(patch: Partial) { + await api.put('/api/settings', patch); + await this.fetchAll(); + }, + }, +}); diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css new file mode 100644 index 0000000..220be91 --- /dev/null +++ b/web/src/styles/tokens.css @@ -0,0 +1,98 @@ +/* 站牌导视(轨道交通导视系统):白底粗黑、线路色圆标、站名牌。 + 颜色语义:琥珀 = direct 直连出口,青 = WARP 净出口;协议各有线路色。 */ +:root { + --bg: #f7f7f4; + --panel: #ffffff; + --panel-raised: #ffffff; + --line: #e2e2dd; + --line-soft: #ecece8; + --direct: #b26a00; + --direct-dim: #faf1e2; + --warp: #007a72; + --warp-dim: #e2f2f0; + --text: #212529; + --muted: #6e7073; + --danger: #c0392b; + /* 重点色:深藏青(站名牌、主按钮、导航下划线),比纯黑柔和 */ + --ink: #2b3f5c; + --ink-hover: #24354e; + + /* 协议线路色(导视系统的线路标识) */ + --proto-vmess: #275ad1; + --proto-trojan: #8348c7; + --proto-shadowsocks: #2f7d3b; + --proto-hysteria2: #c23d5d; + + --font-body: -apple-system, 'PingFang SC', 'Helvetica Neue', 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace; +} + +html, +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font-body); + -webkit-font-smoothing: antialiased; +} + +/* 页面大标题:导视级粗黑 */ +.display { + font-weight: 800; + letter-spacing: -0.01em; +} + +.mono { + font-family: var(--font-mono); +} + +/* 导视小标签:加粗 + 字距 */ +.label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + color: var(--muted); +} + +.egress-direct { + color: var(--direct); +} + +.egress-warp { + color: var(--warp); +} + +/* 语义圆点(图例、标签内) */ +.lamp { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.lamp-direct { + background: var(--direct); +} + +.lamp-warp { + background: var(--warp); +} + +.lamp-off { + background: #b9bab5; +} + +:focus-visible { + outline: 2px solid var(--text); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/web/src/theme.ts b/web/src/theme.ts new file mode 100644 index 0000000..97873ef --- /dev/null +++ b/web/src/theme.ts @@ -0,0 +1,46 @@ +import type { GlobalThemeOverrides } from 'naive-ui'; + +/* 站牌导视主题:白底粗黑 + 胶囊按钮 + 墨黑主按钮 */ +export const themeOverrides: GlobalThemeOverrides = { + common: { + bodyColor: '#f7f7f4', + cardColor: '#ffffff', + modalColor: '#ffffff', + popoverColor: '#ffffff', + tableColor: '#ffffff', + tableHeaderColor: '#ffffff', + borderColor: '#e2e2dd', + dividerColor: '#ecece8', + primaryColor: '#2b3f5c', + primaryColorHover: '#24354e', + primaryColorPressed: '#1d2b40', + primaryColorSuppl: '#2b3f5c', + infoColor: '#275ad1', + successColor: '#007a72', + warningColor: '#b26a00', + warningColorHover: '#9a5c00', + errorColor: '#c0392b', + textColorBase: '#212529', + textColor1: '#212529', + textColor2: '#3c3e42', + textColor3: '#6e7073', + fontFamily: "-apple-system, 'PingFang SC', 'Helvetica Neue', 'Segoe UI', sans-serif", + fontFamilyMono: "'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace", + borderRadius: '10px', + borderRadiusSmall: '6px', + }, + Button: { + fontWeight: '600', + borderRadiusMedium: '999px', + borderRadiusSmall: '999px', + borderRadiusTiny: '999px', + borderRadiusLarge: '999px', + }, + Tag: { + borderRadius: '999px', + }, + DataTable: { + thFontWeight: '700', + thTextColor: '#6e7073', + }, +}; diff --git a/web/src/utils/clipboard.ts b/web/src/utils/clipboard.ts new file mode 100644 index 0000000..bdce119 --- /dev/null +++ b/web/src/utils/clipboard.ts @@ -0,0 +1,45 @@ +/** + * 复制文本到剪贴板。 + * + * navigator.clipboard 仅在安全上下文(HTTPS 或 localhost)可用, + * 通过局域网 IP 以 HTTP 访问时它是 undefined,此时回落到 execCommand。 + */ +export async function copyText(text: string): Promise { + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // 继续尝试回落方案 + } + } + + try { + const ta = document.createElement('textarea'); + ta.value = text; + // 置于视口内但不可见,否则 iOS Safari 不会执行复制 + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.top = '0'; + ta.style.left = '0'; + ta.style.width = '1px'; + ta.style.height = '1px'; + ta.style.padding = '0'; + ta.style.border = 'none'; + ta.style.outline = 'none'; + ta.style.boxShadow = 'none'; + ta.style.background = 'transparent'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + + ta.focus(); + ta.select(); + ta.setSelectionRange(0, text.length); // iOS 需要显式指定范围 + + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + return ok; + } catch { + return false; + } +} diff --git a/web/src/utils/nodeName.ts b/web/src/utils/nodeName.ts new file mode 100644 index 0000000..8e842d6 --- /dev/null +++ b/web/src/utils/nodeName.ts @@ -0,0 +1,12 @@ +import type { NodeDto } from '@proxy-station/shared'; + +/** 名字末尾的出口词:Direct / Warp / 直连 / WARP,前面可以是空格、点号或连字符 */ +const TRAILING_EGRESS = /[\s·・_-]+(direct|warp|直连)\s*$/iu; + +/** + * 界面上与出口标签并排时的节点名:剥掉名字里的出口词,避免和标签重复。 + * 只影响显示——下发给客户端的名字保持原样,用户要靠它区分同协议的两条线路。 + */ +export function nodeDisplayName(node: Pick): string { + return node.name.replace(TRAILING_EGRESS, '').trim(); +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..9761c39 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "jsx": "preserve", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..ef4fb57 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + // 监听 0.0.0.0:局域网设备可访问开发中的管理界面 + host: true, + proxy: { + '/api': 'http://localhost:3000', + // 精确到 /sub/,避免把前端路由 /subscriptions 也代理走 + '^/sub/': 'http://localhost:3000', + }, + }, +});