From e998d4defc02c6d11116abf5d4967a3d878306f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=92=A8?= <1579649885@qq.com> Date: Sun, 27 Sep 2026 20:09:10 +0800 Subject: [PATCH] feat: initial commit for HookPilot --- .gitignore | 5 + README.md | 125 + deploy/Caddyfile.example | 16 + deploy/hookpilot.env.example | 38 + deploy/hookpilot.service | 26 + deploy/nginx.conf.example | 37 + docs/deployment.md | 155 ++ docs/lucky.md | 101 + package-lock.json | 3281 +++++++++++++++++++++++++ package.json | 21 + server/package.json | 28 + server/src/app.ts | 106 + server/src/auth/admin.ts | 70 + server/src/auth/password.ts | 28 + server/src/auth/rate-limit.ts | 45 + server/src/cli.ts | 42 + server/src/config.ts | 62 + server/src/db.ts | 168 ++ server/src/executor/runner.ts | 300 +++ server/src/executor/scheduler.ts | 187 ++ server/src/executor/store.ts | 285 +++ server/src/hooks/params.ts | 326 +++ server/src/hooks/repo.ts | 315 +++ server/src/index.ts | 82 + server/src/routes/auth.ts | 98 + server/src/routes/dto.ts | 98 + server/src/routes/executions.ts | 121 + server/src/routes/hooks.ts | 161 ++ server/src/routes/system.ts | 37 + server/src/routes/webhook.ts | 139 ++ server/src/settings.ts | 72 + server/src/util/crypto.ts | 24 + server/src/util/http-error.ts | 13 + server/src/warnings.ts | 11 + server/tsconfig.json | 18 + web/index.html | 13 + web/package.json | 30 + web/src/App.vue | 10 + web/src/api/client.ts | 130 + web/src/api/types.ts | 172 ++ web/src/components/AppLayout.vue | 158 ++ web/src/components/DialogHost.vue | 51 + web/src/components/ModalDialog.vue | 43 + web/src/components/OutputViewer.vue | 116 + web/src/components/ScriptEditor.vue | 80 + web/src/components/StatusBadge.vue | 29 + web/src/components/ToastHost.vue | 11 + web/src/components/TokenDialog.vue | 51 + web/src/composables/auth.ts | 22 + web/src/composables/dialog.ts | 48 + web/src/composables/toast.ts | 27 + web/src/env.d.ts | 7 + web/src/main.ts | 16 + web/src/router.ts | 72 + web/src/styles.css | 663 +++++ web/src/utils/format.ts | 90 + web/src/utils/templates.ts | 94 + web/src/views/AccountView.vue | 78 + web/src/views/ExecutionDetailView.vue | 239 ++ web/src/views/ExecutionListView.vue | 175 ++ web/src/views/HookDetailView.vue | 283 +++ web/src/views/HookListView.vue | 196 ++ web/src/views/LoginView.vue | 87 + web/src/views/OverviewView.vue | 158 ++ web/src/views/SettingsView.vue | 75 + web/src/views/hook/BasicTab.vue | 87 + web/src/views/hook/ParamsTab.vue | 148 ++ web/src/views/hook/PolicyTab.vue | 63 + web/src/views/hook/ScriptTab.vue | 148 ++ web/src/views/hook/TestTab.vue | 253 ++ web/tsconfig.json | 9 + web/vite.config.ts | 20 + 项目开发说明.md | 189 ++ 73 files changed, 10782 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 deploy/Caddyfile.example create mode 100644 deploy/hookpilot.env.example create mode 100644 deploy/hookpilot.service create mode 100644 deploy/nginx.conf.example create mode 100644 docs/deployment.md create mode 100644 docs/lucky.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server/package.json create mode 100644 server/src/app.ts create mode 100644 server/src/auth/admin.ts create mode 100644 server/src/auth/password.ts create mode 100644 server/src/auth/rate-limit.ts create mode 100644 server/src/cli.ts create mode 100644 server/src/config.ts create mode 100644 server/src/db.ts create mode 100644 server/src/executor/runner.ts create mode 100644 server/src/executor/scheduler.ts create mode 100644 server/src/executor/store.ts create mode 100644 server/src/hooks/params.ts create mode 100644 server/src/hooks/repo.ts create mode 100644 server/src/index.ts create mode 100644 server/src/routes/auth.ts create mode 100644 server/src/routes/dto.ts create mode 100644 server/src/routes/executions.ts create mode 100644 server/src/routes/hooks.ts create mode 100644 server/src/routes/system.ts create mode 100644 server/src/routes/webhook.ts create mode 100644 server/src/settings.ts create mode 100644 server/src/util/crypto.ts create mode 100644 server/src/util/http-error.ts create mode 100644 server/src/warnings.ts create mode 100644 server/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/api/types.ts create mode 100644 web/src/components/AppLayout.vue create mode 100644 web/src/components/DialogHost.vue create mode 100644 web/src/components/ModalDialog.vue create mode 100644 web/src/components/OutputViewer.vue create mode 100644 web/src/components/ScriptEditor.vue create mode 100644 web/src/components/StatusBadge.vue create mode 100644 web/src/components/ToastHost.vue create mode 100644 web/src/components/TokenDialog.vue create mode 100644 web/src/composables/auth.ts create mode 100644 web/src/composables/dialog.ts create mode 100644 web/src/composables/toast.ts create mode 100644 web/src/env.d.ts create mode 100644 web/src/main.ts create mode 100644 web/src/router.ts create mode 100644 web/src/styles.css create mode 100644 web/src/utils/format.ts create mode 100644 web/src/utils/templates.ts create mode 100644 web/src/views/AccountView.vue create mode 100644 web/src/views/ExecutionDetailView.vue create mode 100644 web/src/views/ExecutionListView.vue create mode 100644 web/src/views/HookDetailView.vue create mode 100644 web/src/views/HookListView.vue create mode 100644 web/src/views/LoginView.vue create mode 100644 web/src/views/OverviewView.vue create mode 100644 web/src/views/SettingsView.vue create mode 100644 web/src/views/hook/BasicTab.vue create mode 100644 web/src/views/hook/ParamsTab.vue create mode 100644 web/src/views/hook/PolicyTab.vue create mode 100644 web/src/views/hook/ScriptTab.vue create mode 100644 web/src/views/hook/TestTab.vue create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts create mode 100644 项目开发说明.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2e842a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +data/ +*.log +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca585d5 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# HookPilot + +面向**单管理员、单台 Linux 服务器**的 Webhook 脚本管理平台:在网页上创建多个 Webhook 接口,为每个接口配置参数映射和 Bash 脚本;外部应用调用接口后,平台在宿主机上执行脚本并记录结果。 + +**一个接口 = 调用地址 + 鉴权凭证 + 参数映射 + Bash 脚本 + 执行策略。** + +## 功能 + +- 单管理员登录、修改用户名与密码,不开放注册 +- 接口管理:创建、编辑、复制、启用、停用、删除,支持分组 +- 自定义接口标识(`/hooks/<标识>`),GET / POST,独立 Token(只保存哈希,创建时显示一次) +- 参数从 JSON、表单、Query 或指定 Header 提取,必填 / 默认值 / 字符串、数字、布尔、IP 校验,映射为 `HOOK_` 环境变量 +- Bash 编辑器(行号、缩进、语法高亮、示例模板),保存即生成脚本版本 +- 执行策略:工作目录、超时、同步 / 异步响应、接口并发数 +- 在线测试:预览参数映射,或真实执行并实时查看输出 +- 执行记录:来源、参数摘要(敏感参数掩码)、脚本版本、状态、耗时、退出码;stdout / stderr 增量查看 +- 任务控制:取消排队、终止运行(清理整个进程组)、使用原版本和参数再次执行 +- 系统设置:全局并发上限、日志保留天数、输出大小上限 + +## 技术栈 + +| 层级 | 技术 | +|---|---| +| 前端 | Vite 8 + Vue 3 + TypeScript + CodeMirror 6 | +| 后端 | Node.js 22 + TypeScript + Fastify 5 | +| 数据库 | SQLite(Node 内置 `node:sqlite`,无原生编译依赖) | +| 执行层 | 宿主机 Bash 子进程,独立进程组 | +| 部署 | systemd + HTTPS 反向代理 | + +## 目录结构 + +```text +server/ 后端(模块化单体) + src/ + index.ts 入口:初始化数据目录、恢复任务、启动服务 + app.ts Fastify 应用装配、静态文件托管 + config.ts 环境变量配置 + db.ts SQLite 访问与迁移 + settings.ts 系统设置 + cli.ts 命令行工具(重置密码) + auth/ 密码哈希、管理员与会话、登录限流 + hooks/ 接口配置存储、参数提取与校验 + executor/ 任务存储(持久化队列)、调度器、Bash 执行器 + routes/ 管理 API 与 Webhook 入口 +web/ 前端 + src/ + api/ 接口类型与请求封装 + components/ 布局、脚本编辑器、输出查看器、弹窗等 + views/ 页面(接口详情的五个标签页在 views/hook/) +deploy/ systemd 单元、环境变量示例、Nginx / Caddy 示例 +docs/ 部署说明、Lucky 对接示例 +``` + +## 本地开发 + +需要 Node.js 22.13+ 和 Bash(Windows 下可使用 Git Bash,仅用于开发调试;进程组清理等行为以 Linux 为准)。 + +```bash +npm install + +# 终端 1:后端(默认 http://127.0.0.1:8787,数据写入 ./data) +HOOKPILOT_ADMIN_PASSWORD=dev-password npm run dev:server + +# 终端 2:前端开发服务器(http://localhost:5173,/api 与 /hooks 代理到后端) +npm run dev:web +``` + +首次启动若未设置 `HOOKPILOT_ADMIN_PASSWORD`,初始密码会随机生成并打印在后端日志中。 + +## 构建与运行 + +```bash +npm run build # 构建 web/dist 与 server/dist +npm start # 后端同时托管前端静态文件 +npm run typecheck # 前后端静态类型检查 +``` + +生产部署见 [docs/deployment.md](docs/deployment.md),Lucky 对接见 [docs/lucky.md](docs/lucky.md)。 + +## 调用接口 + +```bash +curl -X POST 'https://hooks.example.com/hooks/home-ip' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"ip":"203.0.113.10"}' +``` + +Token 也可通过 `X-Hook-Token` 请求头或 `?token=` 传递。 + +| 结果 | 同步模式 | 异步模式 | +|---|---|---| +| 已接收 | — | `202`,返回任务 ID | +| 脚本成功 | `200` | — | +| 脚本失败 / 超时 / 被终止 | `500` / `504` / `409` | — | +| Token 错误 / 参数无效 / 已停用 / 方法不符 | `401` / `400` / `403` / `405`(不执行脚本) | 同左 | + +响应只包含任务 ID、状态与退出码,脚本输出只在管理界面查看。 + +## 脚本可用的环境变量 + +| 变量 | 说明 | +|---|---| +| `HOOK_*` | 参数映射得到的变量,名称由管理员配置 | +| `HOOK_EXECUTION_ID` | 任务 ID | +| `HOOK_SLUG` | 接口标识 | +| `HOOK_TRIGGER` | 触发方式:`webhook` / `test` / `rerun` | +| `HOOK_REQUEST_FILE` | 本次任务专属的请求 JSON 文件(method、contentType、query、headers、bodyKind、body),执行结束后删除 | +| `PATH`、`HOME`、`USER`、`LOGNAME`、`SHELL`、`LANG`、`TZ` | 基础运行环境;不继承服务自身的其他环境变量 | + +请求数据不会替换进脚本文本,也不会拼接成 Shell 命令;含引号、换行或 Shell 特殊字符的参数都作为数据原样传递。脚本中请始终用双引号引用:`"$HOOK_IP"`。 + +## 执行规则 + +- 任务先持久化到 SQLite,再由调度器按接收顺序启动;同时受全局并发(默认 4)与接口并发(默认 1,即串行)限制。 +- 入队时固定参数与脚本版本,之后修改脚本不影响已入队任务。 +- 默认超时 30 秒;超时或手动终止时向整个进程组发送 SIGTERM,3 秒后 SIGKILL。 +- 不自动重试。服务重启后排队任务继续执行,运行中的任务标记为“中断”,不自动重跑。 +- 停用接口会拒绝新请求并取消该接口的排队任务;运行中的任务可单独终止。 +- 单次执行输出上限默认 5 MiB(stdout + stderr),超出部分继续读取并丢弃,页面标记截断。 +- 鉴权头、Cookie 与 query 中的 token 不写入执行记录;敏感参数在界面中以掩码显示,但脚本自己打印的内容仍会出现在输出里。 + +## 后续扩展(未实现) + +第三方签名验证(如 GitHub)、定时执行与通知、可复用脚本库、配置导入导出、多节点与多用户、可视化工作流。 diff --git a/deploy/Caddyfile.example b/deploy/Caddyfile.example new file mode 100644 index 0000000..640af24 --- /dev/null +++ b/deploy/Caddyfile.example @@ -0,0 +1,16 @@ +# Caddy 反向代理示例:/etc/caddy/Caddyfile +# Caddy 会自动申请并续期 HTTPS 证书 + +hooks.example.com { + request_body { + max_size 2MB + } + + reverse_proxy 127.0.0.1:8787 { + # 同步模式会等待脚本执行结束 + transport http { + read_timeout 600s + write_timeout 600s + } + } +} diff --git a/deploy/hookpilot.env.example b/deploy/hookpilot.env.example new file mode 100644 index 0000000..ba88b10 --- /dev/null +++ b/deploy/hookpilot.env.example @@ -0,0 +1,38 @@ +# HookPilot 环境配置,复制到 /etc/hookpilot/hookpilot.env 后按需修改 +# 修改后执行:sudo systemctl restart hookpilot + +# 监听地址。由反向代理对外提供 HTTPS,服务本身只监听本机 +HOOKPILOT_HOST=127.0.0.1 +HOOKPILOT_PORT=8787 + +# 数据目录:SQLite 数据库、脚本文件、任务临时目录、默认工作目录 +HOOKPILOT_DATA_DIR=/var/lib/hookpilot + +# 前端构建产物目录(默认为程序目录下的 web/dist) +# HOOKPILOT_WEB_DIR=/opt/hookpilot/web/dist + +# 首次启动时创建的管理员。不设置密码时会随机生成并打印到日志(journalctl -u hookpilot) +HOOKPILOT_ADMIN_USER=admin +# HOOKPILOT_ADMIN_PASSWORD=请替换为强密码 + +# 信任的反向代理:loopback(默认,代理在本机)、true、false,或逗号分隔的 IP/网段 +HOOKPILOT_TRUST_PROXY=loopback + +# 登录 Cookie 是否带 Secure:auto(按请求协议判断)、true、false +HOOKPILOT_COOKIE_SECURE=auto + +# 登录会话有效期(小时) +HOOKPILOT_SESSION_TTL_HOURS=168 + +# 请求体上限(字节),默认 1 MiB +HOOKPILOT_BODY_LIMIT=1048576 + +# 脚本执行使用的 Bash 与 PATH +HOOKPILOT_BASH=/bin/bash +HOOKPILOT_EXEC_PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# 日志级别:fatal / error / warn / info / debug +HOOKPILOT_LOG_LEVEL=info + +# 脚本执行时区(可选) +# TZ=Asia/Shanghai diff --git a/deploy/hookpilot.service b/deploy/hookpilot.service new file mode 100644 index 0000000..abf3c04 --- /dev/null +++ b/deploy/hookpilot.service @@ -0,0 +1,26 @@ +[Unit] +Description=HookPilot Webhook 脚本管理平台 +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=hookpilot +Group=hookpilot +WorkingDirectory=/opt/hookpilot +EnvironmentFile=-/etc/hookpilot/hookpilot.env +ExecStart=/usr/bin/node /opt/hookpilot/server/dist/index.js +Restart=on-failure +RestartSec=3 + +# 停止时只向主进程发送 SIGTERM,由服务终止运行中的任务并标记为“中断”; +# 超过 TimeoutStopSec 仍未退出时,systemd 再清理整个 cgroup。 +KillMode=mixed +TimeoutStopSec=15 + +# 脚本需要 sudo 提权时不能启用 NoNewPrivileges;如确定不需要提权,可取消下面的注释加固 +# NoNewPrivileges=true +# ProtectSystem=full + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example new file mode 100644 index 0000000..2fc321b --- /dev/null +++ b/deploy/nginx.conf.example @@ -0,0 +1,37 @@ +# Nginx 反向代理示例:/etc/nginx/conf.d/hookpilot.conf +# 证书可使用 certbot 等工具申请,替换 hooks.example.com 与证书路径 + +server { + listen 80; + server_name hooks.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + http2 on; + server_name hooks.example.com; + + ssl_certificate /etc/letsencrypt/live/hooks.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/hooks.example.com/privkey.pem; + + # 与 HOOKPILOT_BODY_LIMIT 保持一致或略大 + client_max_body_size 2m; + + # 不在访问日志中记录 query(?token= 方式调用时避免 Token 落盘) + log_format hookpilot '$remote_addr - [$time_local] "$request_method $uri" $status $body_bytes_sent'; + access_log /var/log/nginx/hookpilot.access.log hookpilot; + + location / { + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + # 同步模式会等待脚本执行结束,超时时间应大于最长的脚本超时 + 排队时间 + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } +} diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..e481754 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,155 @@ +# 宿主机部署说明 + +HookPilot 直接部署在 Linux 宿主机上,由 systemd 管理,通过 HTTPS 反向代理对外提供服务。脚本以服务账户身份运行,可以操作该账户有权访问的文件和命令。 + +## 1. 环境要求 + +- Linux(systemd),x86_64 或 arm64 +- Node.js **22.13 及以上**(使用内置的 `node:sqlite`,无需编译原生模块) +- Bash +- Nginx 或 Caddy(提供 HTTPS) + +```bash +node -v # 应输出 v22.13.0 或更高 +``` + +## 2. 构建 + +可以在服务器上直接构建,也可以在其他机器构建后复制整个目录。 + +```bash +git clone <仓库地址> /opt/hookpilot # 或将项目目录上传到 /opt/hookpilot +cd /opt/hookpilot +npm ci +npm run build # 构建前端(web/dist)与后端(server/dist) +npm prune --omit=dev # 可选:移除开发依赖 +``` + +## 3. 创建服务账户与目录 + +默认使用专用账户 `hookpilot`,数据目录 `/var/lib/hookpilot`: + +```bash +sudo useradd --system --home-dir /var/lib/hookpilot --create-home --shell /usr/sbin/nologin hookpilot +sudo chmod 700 /var/lib/hookpilot +sudo chown -R root:root /opt/hookpilot # 程序目录只读,服务账户无需写权限 +``` + +数据目录结构(服务启动时自动创建): + +| 路径 | 内容 | +|---|---| +| `hookpilot.db` | SQLite 数据库:管理员、会话、接口、脚本版本、任务与输出、系统设置 | +| `scripts/<接口ID>/v<版本>.sh` | 各脚本版本的文件,执行时由数据库内容校验并重写 | +| `runs/<任务ID>/request.json` | 任务执行期间的请求文件(`HOOK_REQUEST_FILE`),结束后删除 | +| `workspace/` | 未配置工作目录时的默认工作目录 | + +脚本需要访问的其他目录,请用常规的文件权限授予 `hookpilot` 账户,例如: + +```bash +sudo chown -R hookpilot:hookpilot /srv/my-app +``` + +## 4. 配置 + +```bash +sudo mkdir -p /etc/hookpilot +sudo cp deploy/hookpilot.env.example /etc/hookpilot/hookpilot.env +sudo chmod 600 /etc/hookpilot/hookpilot.env +sudo nano /etc/hookpilot/hookpilot.env +``` + +建议设置 `HOOKPILOT_ADMIN_PASSWORD` 作为初始密码(只在首次启动、数据库中还没有管理员时生效)。未设置时会随机生成,并打印在服务日志中。 + +## 5. 安装 systemd 服务 + +```bash +sudo cp deploy/hookpilot.service /etc/systemd/system/hookpilot.service +# 如果 node 不在 /usr/bin/node,请修改 ExecStart(可用 `which node` 查看) +sudo systemctl daemon-reload +sudo systemctl enable --now hookpilot +sudo systemctl status hookpilot +journalctl -u hookpilot -f # 查看日志,未设置初始密码时在这里找到随机密码 +``` + +服务停止或重启时: + +- 运行中的任务会被终止(整个进程组),状态记为“中断”,**不会自动重跑**; +- 排队中的任务保留在数据库中,服务启动后按原顺序继续执行; +- 服务异常退出(如被 `kill -9`、断电)后再启动,上次运行中的任务同样标记为“中断”。 + +## 6. 配置 HTTPS 反向代理 + +服务默认只监听 `127.0.0.1:8787`,由反向代理提供统一域名和 HTTPS。 + +- Nginx:参考 [`deploy/nginx.conf.example`](../deploy/nginx.conf.example) +- Caddy:参考 [`deploy/Caddyfile.example`](../deploy/Caddyfile.example)(自动申请证书) + +注意事项: + +1. **同步模式**的接口会等脚本执行完才响应,代理的读取超时要大于最长脚本超时加排队时间。 +2. 代理的请求体上限应不小于 `HOOKPILOT_BODY_LIMIT`(默认 1 MiB)。 +3. 代理与服务不在同一台机器时,把 `HOOKPILOT_TRUST_PROXY` 设置为代理的 IP,否则执行记录中的请求来源会显示为代理地址。 +4. 使用 HTTPS 访问时登录 Cookie 自动带 `Secure` 标记(`HOOKPILOT_COOKIE_SECURE=auto`)。 + +## 7. 按命令授予提权 + +脚本默认只有 `hookpilot` 账户的权限。确实需要重启系统服务或修改受保护文件时,由部署者在 sudoers 中**按具体命令**授权,不要授予全部权限: + +```bash +sudo visudo -f /etc/sudoers.d/hookpilot +``` + +```text +# 只允许无密码执行这两条命令 +hookpilot ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx +hookpilot ALL=(root) NOPASSWD: /usr/bin/systemctl reload caddy +``` + +脚本中使用 `sudo -n`(不交互询问密码,未授权时立即失败): + +```bash +sudo -n /usr/bin/systemctl restart nginx +``` + +平台页面不提供切换系统用户的功能。启用提权后请保持 systemd 单元中 `NoNewPrivileges` 处于关闭状态。 + +## 8. 安全要点 + +- 管理端使用登录会话,Webhook 使用每个接口独立的 Token,两种凭证互不通用;Token 只保存哈希。 +- 执行子进程使用显式构造的环境(`PATH`、`HOME`、`USER`、`LANG` 与 `HOOK_` 变量),不继承服务的环境变量与凭证。 +- 请求数据只通过环境变量和请求文件传给脚本,不会替换进脚本文本或拼接成 Shell 命令。脚本中请始终用双引号引用变量(`"$HOOK_IP"`)。 +- 平台面向可信管理员:任何能登录管理界面的人都能以服务账户身份执行任意命令,请使用强密码并限制管理界面的访问来源(如在代理层对 `/` 做 IP 白名单、仅放行 `/hooks/`)。 +- 连续登录失败 10 次后,该来源 IP 锁定 15 分钟。 + +## 9. 升级、备份与维护 + +**升级:** + +```bash +cd /opt/hookpilot +git pull # 或替换为新版本文件 +npm ci && npm run build +sudo systemctl restart hookpilot +``` + +数据库结构变更会在启动时自动迁移。 + +**备份:** 停止服务后复制整个数据目录;或在运行中使用 SQLite 在线备份: + +```bash +sudo -u hookpilot sqlite3 /var/lib/hookpilot/hookpilot.db ".backup '/var/backups/hookpilot.db'" +``` + +**忘记管理员密码:** + +```bash +cd /opt/hookpilot +sudo -u hookpilot env HOOKPILOT_DATA_DIR=/var/lib/hookpilot node server/dist/cli.js reset-password +# 或指定新密码: +sudo -u hookpilot env HOOKPILOT_DATA_DIR=/var/lib/hookpilot HOOKPILOT_NEW_PASSWORD='新密码' node server/dist/cli.js reset-password +``` + +重置后所有登录会话失效。 + +**日志清理:** 超过“日志保留天数”(默认 30 天)的已结束执行记录及其输出每小时自动清理一次,可在“系统设置”中调整。 diff --git a/docs/lucky.md b/docs/lucky.md new file mode 100644 index 0000000..9ec3ec2 --- /dev/null +++ b/docs/lucky.md @@ -0,0 +1,101 @@ +# Lucky 对接示例:DDNS 更新后同步家庭 IP + +本示例演示设计文档中的验收场景:Lucky 检测到公网 IP 变化后调用 HookPilot,脚本把新 IP 写入指定文件。你可以在此基础上改为更新防火墙白名单、重写 Nginx 配置等。 + +## 1. 在 HookPilot 中创建接口 + +进入“接口管理 → 新建接口”: + +**基本配置** + +| 配置项 | 值 | +|---|---| +| 接口名称 | 更新家庭 IP | +| 接口标识 | `home-ip`(调用地址为 `/hooks/home-ip`) | +| 请求方式 | POST | + +**参数** + +| 环境变量 | 来源 | 字段 | 类型 | 必填 | +|---|---|---|---|---| +| `HOOK_IP` | JSON 请求体 | `ip` | IP 地址 | ✓ | + +**脚本**:在“插入示例模板”中选择“更新家庭 IP”,按需修改 `TARGET_FILE`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +TARGET_FILE="/var/lib/hookpilot/workspace/home-ip.txt" + +old_ip="" +if [[ -f "$TARGET_FILE" ]]; then + old_ip="$(cat "$TARGET_FILE")" +fi + +if [[ "$old_ip" == "$HOOK_IP" ]]; then + echo "IP 未变化:$HOOK_IP" + exit 0 +fi + +printf '%s\n' "$HOOK_IP" > "$TARGET_FILE.tmp" +mv "$TARGET_FILE.tmp" "$TARGET_FILE" +echo "IP 已更新:${old_ip:-无} -> $HOOK_IP" +``` + +**执行策略** + +| 配置项 | 值 | 说明 | +|---|---|---| +| 响应模式 | 同步 | Lucky 可以根据响应判断脚本是否成功 | +| 超时时间 | 30 秒 | | +| 接口并发数 | 1 | 连续多次调用按接收顺序执行 | + +点击“创建接口”,**立即复制弹窗中的 Token**(只显示一次)。 + +创建后切到“测试”标签,点击“按参数生成示例”→“预览参数映射”确认映射正确,再点击“执行(真实运行)”验证脚本。 + +## 2. 在 Lucky 中配置 Webhook + +在 Lucky 的“动态域名”任务中打开 Webhook 设置(不同版本界面位置和名称可能略有差异,以实际界面为准): + +| Lucky 配置项 | 值 | +|---|---| +| 接口地址 | `https://hooks.example.com/hooks/home-ip` | +| 请求方法 | POST | +| 请求头 | `Authorization: Bearer hp_xxxxxxxx`
`Content-Type: application/json` | +| 请求主体 | `{"ip":"#{ipAddr}"}` | +| 接口调用成功包含的字符串 | `"ok":true` | + +- `#{ipAddr}` 是 Lucky 提供的“当前 IP”变量,具体变量名请以你所用 Lucky 版本 Webhook 设置页的提示为准。 +- 如果 Lucky 版本不支持自定义请求头,可以把 Token 放在地址中:`https://hooks.example.com/hooks/home-ip?token=hp_xxxxxxxx`。这种方式的 Token 可能出现在代理访问日志中,示例 Nginx 配置已去掉日志中的 query。 + +## 3. 响应说明 + +同步模式下 HookPilot 的响应: + +| 情况 | HTTP 状态 | 响应体示例 | +|---|---|---| +| 脚本成功(退出码 0) | 200 | `{"ok":true,"executionId":12,"status":"success","exitCode":0,"durationMs":25}` | +| 脚本失败(非零退出码) | 500 | `{"ok":false,"executionId":13,"status":"failed","exitCode":1,...}` | +| 脚本超时 | 504 | `{"ok":false,...,"status":"timeout"}` | +| 被手动终止 / 取消 | 409 | `{"ok":false,...,"status":"killed"}` | +| Token 错误 | 401 | `{"ok":false,"error":"unauthorized","message":"Token 无效"}` | +| 参数缺失或 IP 非法 | 400 | `{"ok":false,"error":"invalid_params","details":["HOOK_IP:不是合法的 IP 地址"]}` | +| 接口已停用 | 403 | `{"ok":false,"error":"disabled",...}` | +| 请求方式不符 | 405 | `{"ok":false,"error":"method_not_allowed",...}` | + +Token 错误、参数校验失败等情况**不会执行脚本**,也不会生成执行记录。脚本输出只在管理界面查看,不返回给 Lucky。 + +异步模式下只要任务入队就返回 `202 {"ok":true,"executionId":12,"status":"queued"}`,表示已接收,不代表脚本执行成功。 + +## 4. 用 curl 手动验证 + +```bash +curl -i -X POST 'https://hooks.example.com/hooks/home-ip' \ + -H 'Authorization: Bearer hp_xxxxxxxx' \ + -H 'Content-Type: application/json' \ + -d '{"ip":"203.0.113.10"}' +``` + +然后在“执行记录”中查看本次执行的参数、脚本版本、stdout / stderr 与退出码。 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0e8da4b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3281 @@ +{ + "name": "hookpilot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hookpilot", + "version": "0.1.0", + "workspaces": [ + "server", + "web" + ], + "engines": { + "node": ">=22.13" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.4", + "resolved": "https://registry.npmmirror.com/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz", + "integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.13", + "resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.43.13.tgz", + "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz", + "integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", + "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/cookie": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@fastify/cookie/-/cookie-11.1.2.tgz", + "integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^2.0.0", + "fastify-plugin": "^6.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/formbody": { + "version": "9.0.0", + "resolved": "https://registry.npmmirror.com/@fastify/formbody/-/formbody-9.0.0.tgz", + "integrity": "sha512-T/af26CSrUARBCvsEmv+DJLPfZlrRKESzqironxP1j7qzuLyKcoZtj6MuTGShuKx1THXugoie2oFbUJxXfGFzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-querystring": "^1.1.2", + "fastify-plugin": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/@fastify/proxy-addr/-/proxy-addr-5.1.1.tgz", + "integrity": "sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/@fastify/send/-/send-4.1.1.tgz", + "integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "10.1.5", + "resolved": "https://registry.npmmirror.com/@fastify/static/-/static-10.1.5.tgz", + "integrity": "sha512-3r0ZFXo82nKlPzySOUYhwWVfPZStfiwjmyevkX7cJWL2yXmEzl0Rpxv+L6L1R4OxqvlDGqf3g8OdBzILKgqe6w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/error": "^4.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^3.0.0", + "fastify-plugin": "^6.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@hookpilot/server": { + "resolved": "server", + "link": true + }, + "node_modules/@hookpilot/web": { + "resolved": "web", + "link": true + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.3", + "resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.3.tgz", + "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.4.tgz", + "integrity": "sha512-4UeRVFO0lsBaM7GA32zOqbmqNPme9jedo8Ey+5c5656MU3MLitMLIA8aCZTYjUMEpEsuSeXEm78Uea6Iq3bvSg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.9", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz", + "integrity": "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.4", + "resolved": "https://registry.npmmirror.com/@vue-macros/common/-/common-3.1.4.tgz", + "integrity": "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.2.1", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-8.2.1.tgz", + "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.11.tgz", + "integrity": "sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.9.1", + "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.9.1.tgz", + "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 5.8", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.9.0", + "resolved": "https://registry.npmmirror.com/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz", + "integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@babel/types": "^7.29.0", + "ast-kit": "^2.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmmirror.com/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-3.0.0.tgz", + "integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-4.2.1.tgz", + "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.5", + "resolved": "https://registry.npmmirror.com/fastify/-/fastify-5.12.5.tgz", + "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "9.9.0", + "resolved": "https://registry.npmmirror.com/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmmirror.com/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmmirror.com/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "devOptional": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nostics": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/nostics/-/nostics-1.3.0.tgz", + "integrity": "sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==", + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.3.tgz", + "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", + "license": "MIT", + "dependencies": { + "confbox": "^0.3.1", + "exsolve": "^1.1.1", + "pathe": "^2.0.3" + } + }, + "node_modules/pkg-types/node_modules/confbox": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmmirror.com/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmmirror.com/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-3.4.0.tgz", + "integrity": "sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.7", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rsbuild/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rsbuild/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/unplugin-utils/-/unplugin-utils-0.3.2.tgz", + "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmmirror.com/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "5.3.1", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-5.3.1.tgz", + "integrity": "sha512-GDBZzgmILxA/kFnkFbJjQZdZ2QQbngnIMMuoUcjhZIfH1RGMaPjPwX5ASnV38qamuA9uhO0RDjSBHTDNG2uXyQ==", + "license": "MIT", + "dependencies": { + "@vue-macros/common": "^3.1.3", + "@vue/devtools-api": "^8.1.5", + "ast-walker-scope": "^0.9.0", + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "local-pkg": "^1.2.1", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "muggle-string": "^0.4.1", + "nostics": "^1.1.4", + "pathe": "^2.0.3", + "picomatch": "^4.0.5", + "scule": "^1.3.0", + "tinyglobby": "^0.2.17", + "unplugin": "^3.3.0", + "unplugin-utils": "^0.3.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", + "pinia": "^3.0.4 || ^4.0.2", + "vite": "^7.3.0 || ^8.0.0", + "vue": "^3.5.34 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.11.tgz", + "integrity": "sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.11" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "server": { + "name": "@hookpilot/server", + "version": "0.1.0", + "dependencies": { + "@fastify/cookie": "^11.1.2", + "@fastify/formbody": "^9.0.0", + "@fastify/static": "^10.1.5", + "fastify": "^5.12.5" + }, + "devDependencies": { + "@types/node": "^22.20.4", + "tsx": "^4.23.15", + "typescript": "~5.9.3" + }, + "engines": { + "node": ">=22.13" + } + }, + "web": { + "name": "@hookpilot/web", + "version": "0.1.0", + "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.4", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.13", + "codemirror": "^6.0.2", + "vue": "^3.5.43", + "vue-router": "^5.3.1" + }, + "devDependencies": { + "@types/node": "^22.20.4", + "@vitejs/plugin-vue": "^6.0.9", + "@vue/tsconfig": "^0.9.1", + "typescript": "~5.9.3", + "vite": "^8.3.1", + "vue-tsc": "^3.3.11" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d02c42e --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "hookpilot", + "version": "0.1.0", + "private": true, + "description": "单管理员 Webhook 脚本管理平台", + "workspaces": [ + "server", + "web" + ], + "engines": { + "node": ">=22.13" + }, + "scripts": { + "dev:server": "npm run dev -w server", + "dev:web": "npm run dev -w web", + "build": "npm run build -w web && npm run build -w server", + "start": "npm run start -w server", + "typecheck": "npm run typecheck -w server && npm run typecheck -w web", + "reset-password": "npm run reset-password -w server" + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..5c18472 --- /dev/null +++ b/server/package.json @@ -0,0 +1,28 @@ +{ + "name": "@hookpilot/server", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "engines": { + "node": ">=22.13" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "typecheck": "tsc -p tsconfig.json --noEmit", + "reset-password": "node dist/cli.js reset-password" + }, + "dependencies": { + "@fastify/cookie": "^11.1.2", + "@fastify/formbody": "^9.0.0", + "@fastify/static": "^10.1.5", + "fastify": "^5.12.5" + }, + "devDependencies": { + "@types/node": "^22.20.4", + "tsx": "^4.23.15", + "typescript": "~5.9.3" + } +} diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..e389098 --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,106 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import cookie from '@fastify/cookie'; +import fastifyStatic from '@fastify/static'; +import Fastify, { type FastifyError, type FastifyInstance } from 'fastify'; +import type { AppConfig } from './config.js'; +import type { Db } from './db.js'; +import type { Scheduler } from './executor/scheduler.js'; +import type { ExecutionStore } from './executor/store.js'; +import type { HookRepo } from './hooks/repo.js'; +import { authRoutes, registerSessionGuard } from './routes/auth.js'; +import { executionRoutes } from './routes/executions.js'; +import { hookRoutes } from './routes/hooks.js'; +import { systemRoutes } from './routes/system.js'; +import { webhookRoutes } from './routes/webhook.js'; +import type { SettingsStore } from './settings.js'; +import { HttpError } from './util/http-error.js'; + +declare module 'fastify' { + interface FastifyRequest { + sessionId: string; + } + interface FastifyContextConfig { + /** 无需登录即可访问的管理 API */ + public?: boolean; + } +} + +export interface AppContext { + config: AppConfig; + db: Db; + settings: SettingsStore; + hooks: HookRepo; + store: ExecutionStore; + scheduler: Scheduler; +} + +/** 日志中去掉 query 里的 token,避免凭证写入日志 */ +function redactUrl(url: string): string { + return url.replace(/([?&]token=)[^&]*/gi, '$1***'); +} + +export async function buildApp(ctx: AppContext): Promise { + const app = Fastify({ + trustProxy: ctx.config.trustProxy, + bodyLimit: ctx.config.bodyLimit, + logger: { + level: ctx.config.logLevel, + serializers: { + req: (req) => ({ method: req.method, url: redactUrl(req.url), remoteAddress: req.ip }), + }, + }, + }); + + app.decorateRequest('sessionId', ''); + await app.register(cookie); + + await app.register( + async (api) => { + registerSessionGuard(api, ctx); + api.setErrorHandler((err: FastifyError, req, reply) => { + if (err instanceof HttpError) { + return reply.code(err.statusCode).send({ error: err.message, details: err.details }); + } + if (err.validation) return reply.code(400).send({ error: `请求参数无效:${err.message}` }); + if (err.statusCode && err.statusCode < 500) return reply.code(err.statusCode).send({ error: err.message }); + req.log.error({ err }, '管理 API 处理失败'); + return reply.code(500).send({ error: '服务器内部错误' }); + }); + await authRoutes(api, ctx); + await hookRoutes(api, ctx); + await executionRoutes(api, ctx); + await systemRoutes(api, ctx); + }, + { prefix: '/api' }, + ); + + await app.register(async (scope) => webhookRoutes(scope, ctx)); + + const indexFile = path.join(ctx.config.webDir, 'index.html'); + const hasWeb = fs.existsSync(indexFile); + if (hasWeb) { + await app.register(fastifyStatic, { + root: ctx.config.webDir, + wildcard: false, + setHeaders: (res, filePath) => { + const immutable = filePath.includes(`${path.sep}assets${path.sep}`); + res.header('Cache-Control', immutable ? 'public, max-age=31536000, immutable' : 'no-cache'); + }, + }); + } else { + app.log.warn({ webDir: ctx.config.webDir }, '未找到前端构建产物,仅提供 API(开发时请使用 Vite 开发服务器)'); + } + + // 前端路由回退到 index.html;API 与 Webhook 路径保持 JSON 404 + app.setNotFoundHandler((req, reply) => { + const url = req.url.split('?')[0]!; + const isBackend = url.startsWith('/api/') || url === '/api' || url.startsWith('/hooks/'); + if (hasWeb && req.method === 'GET' && !isBackend) { + return reply.header('Cache-Control', 'no-cache').sendFile('index.html'); + } + return reply.code(404).send({ error: '资源不存在' }); + }); + + return app; +} diff --git a/server/src/auth/admin.ts b/server/src/auth/admin.ts new file mode 100644 index 0000000..4b6ce5b --- /dev/null +++ b/server/src/auth/admin.ts @@ -0,0 +1,70 @@ +import type { Db } from '../db.js'; +import { randomToken, sha256 } from '../util/crypto.js'; +import { hashPassword } from './password.js'; + +export const SESSION_COOKIE = 'hp_session'; +export const MIN_PASSWORD_LENGTH = 8; + +export interface AdminRow { + id: number; + username: string; + password_hash: string; + updated_at: number; +} + +export function getAdmin(db: Db): AdminRow | undefined { + return db.get('SELECT * FROM admin WHERE id = 1'); +} + +export async function setAdminCredentials(db: Db, username: string, password: string): Promise { + const hash = await hashPassword(password); + db.run( + `INSERT INTO admin (id, username, password_hash, updated_at) VALUES (1, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET username = excluded.username, password_hash = excluded.password_hash, updated_at = excluded.updated_at`, + username, + hash, + Date.now(), + ); +} + +/** + * 首次启动时创建管理员。未通过环境变量指定密码时生成随机密码并输出到日志。 + * 返回生成的随机密码(若有)。 + */ +export async function ensureAdmin(db: Db, username: string, password: string | undefined): Promise { + if (getAdmin(db)) return null; + const actual = password ?? randomToken(12); + await setAdminCredentials(db, username, actual); + return password ? null : actual; +} + +export function createSession(db: Db, ttlMs: number, ip: string, userAgent: string): string { + const token = randomToken(32); + const now = Date.now(); + db.run( + 'INSERT INTO sessions (id, created_at, expires_at, ip, user_agent) VALUES (?, ?, ?, ?, ?)', + sha256(token), + now, + now + ttlMs, + ip, + userAgent.slice(0, 300), + ); + return token; +} + +export function findSession(db: Db, token: string): { id: string } | undefined { + return db.get<{ id: string }>('SELECT id FROM sessions WHERE id = ? AND expires_at > ?', sha256(token), Date.now()); +} + +export function deleteSession(db: Db, id: string): void { + db.run('DELETE FROM sessions WHERE id = ?', id); +} + +export function deleteOtherSessions(db: Db, keepId: string | null): void { + if (keepId) db.run('DELETE FROM sessions WHERE id <> ?', keepId); + else db.run('DELETE FROM sessions'); +} + +export function purgeExpiredSessions(db: Db): void { + db.run('DELETE FROM sessions WHERE expires_at <= ?', Date.now()); +} diff --git a/server/src/auth/password.ts b/server/src/auth/password.ts new file mode 100644 index 0000000..e4c5f3f --- /dev/null +++ b/server/src/auth/password.ts @@ -0,0 +1,28 @@ +import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto'; + +const PARAMS = { N: 16384, r: 8, p: 1 }; +const KEY_LENGTH = 64; + +function scryptAsync(password: string, salt: Buffer, keyLength: number, options: ScryptOptions): Promise { + return new Promise((resolve, reject) => { + scrypt(password, salt, keyLength, options, (err, key) => (err ? reject(err) : resolve(key))); + }); +} + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16); + const key = await scryptAsync(password, salt, KEY_LENGTH, PARAMS); + return ['scrypt', PARAMS.N, PARAMS.r, PARAMS.p, salt.toString('base64'), key.toString('base64')].join('$'); +} + +export async function verifyPassword(password: string, stored: string): Promise { + const [scheme, n, r, p, saltB64, keyB64] = stored.split('$'); + if (scheme !== 'scrypt' || !saltB64 || !keyB64) return false; + const expected = Buffer.from(keyB64, 'base64'); + const actual = await scryptAsync(password, Buffer.from(saltB64, 'base64'), expected.length, { + N: Number(n), + r: Number(r), + p: Number(p), + }); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} diff --git a/server/src/auth/rate-limit.ts b/server/src/auth/rate-limit.ts new file mode 100644 index 0000000..5bfe9af --- /dev/null +++ b/server/src/auth/rate-limit.ts @@ -0,0 +1,45 @@ +const WINDOW_MS = 15 * 60 * 1000; +const MAX_FAILURES = 10; + +interface Entry { + failures: number; + firstAt: number; + lockedUntil: number; +} + +/** 登录失败限流:同一来源 15 分钟内失败 10 次后锁定 15 分钟 */ +export class LoginLimiter { + private readonly entries = new Map(); + + /** 返回剩余锁定秒数,未锁定返回 0 */ + lockedFor(ip: string): number { + const entry = this.entries.get(ip); + if (!entry) return 0; + const now = Date.now(); + if (entry.lockedUntil > now) return Math.ceil((entry.lockedUntil - now) / 1000); + if (now - entry.firstAt > WINDOW_MS) this.entries.delete(ip); + return 0; + } + + fail(ip: string): void { + const now = Date.now(); + let entry = this.entries.get(ip); + if (!entry || now - entry.firstAt > WINDOW_MS) { + entry = { failures: 0, firstAt: now, lockedUntil: 0 }; + this.entries.set(ip, entry); + } + entry.failures++; + if (entry.failures >= MAX_FAILURES) entry.lockedUntil = now + WINDOW_MS; + if (this.entries.size > 10_000) this.prune(now); + } + + succeed(ip: string): void { + this.entries.delete(ip); + } + + private prune(now: number): void { + for (const [ip, entry] of this.entries) { + if (entry.lockedUntil <= now && now - entry.firstAt > WINDOW_MS) this.entries.delete(ip); + } + } +} diff --git a/server/src/cli.ts b/server/src/cli.ts new file mode 100644 index 0000000..66e4395 --- /dev/null +++ b/server/src/cli.ts @@ -0,0 +1,42 @@ +import './warnings.js'; +import fs from 'node:fs'; +import { deleteOtherSessions, getAdmin, MIN_PASSWORD_LENGTH, setAdminCredentials } from './auth/admin.js'; +import { config, dataPath } from './config.js'; +import { Db } from './db.js'; +import { randomToken } from './util/crypto.js'; + +const USAGE = `用法: + node dist/cli.js reset-password [用户名] + + 重置管理员密码并使所有登录会话失效。 + 新密码取自环境变量 HOOKPILOT_NEW_PASSWORD,未设置时随机生成并输出。`; + +async function resetPassword(username: string | undefined): Promise { + fs.mkdirSync(config.dataDir, { recursive: true, mode: 0o700 }); + const db = new Db(dataPath('hookpilot.db')); + try { + const provided = process.env.HOOKPILOT_NEW_PASSWORD; + if (provided !== undefined && provided.length < MIN_PASSWORD_LENGTH) { + throw new Error(`HOOKPILOT_NEW_PASSWORD 至少需要 ${MIN_PASSWORD_LENGTH} 个字符`); + } + const password = provided ?? randomToken(12); + const name = username ?? getAdmin(db)?.username ?? config.initialAdminUser; + await setAdminCredentials(db, name, password); + deleteOtherSessions(db, null); + console.log(`管理员 ${name} 的密码已重置。`); + if (!provided) console.log(`新密码:${password}`); + } finally { + db.close(); + } +} + +const [command, arg] = process.argv.slice(2); +if (command === 'reset-password') { + resetPassword(arg).catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} else { + console.log(USAGE); + process.exit(command ? 1 : 0); +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..8f5e1bd --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,62 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(here, '..', '..'); +const isWindows = process.platform === 'win32'; + +function env(name: string): string | undefined { + const value = process.env[name]; + return value === undefined || value.trim() === '' ? undefined : value.trim(); +} + +function intEnv(name: string, fallback: number): number { + const raw = env(name); + if (raw === undefined) return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`环境变量 ${name} 必须是正整数,当前值:${raw}`); + } + return value; +} + +function trustProxyEnv(raw: string): boolean | string { + if (raw === 'true') return true; + if (raw === 'false') return false; + return raw; +} + +function cookieSecureEnv(raw: string): 'auto' | boolean { + if (raw === 'true') return true; + if (raw === 'false') return false; + return 'auto'; +} + +export const config = { + host: env('HOOKPILOT_HOST') ?? '127.0.0.1', + port: intEnv('HOOKPILOT_PORT', 8787), + dataDir: path.resolve(env('HOOKPILOT_DATA_DIR') ?? path.join(projectRoot, 'data')), + webDir: path.resolve(env('HOOKPILOT_WEB_DIR') ?? path.join(projectRoot, 'web', 'dist')), + logLevel: env('HOOKPILOT_LOG_LEVEL') ?? 'info', + /** 反向代理信任设置,决定如何解析客户端 IP 与协议 */ + trustProxy: trustProxyEnv(env('HOOKPILOT_TRUST_PROXY') ?? 'loopback'), + /** Webhook 与管理 API 的请求体上限,默认 1 MiB */ + bodyLimit: intEnv('HOOKPILOT_BODY_LIMIT', 1024 * 1024), + cookieSecure: cookieSecureEnv(env('HOOKPILOT_COOKIE_SECURE') ?? 'auto'), + sessionTtlMs: intEnv('HOOKPILOT_SESSION_TTL_HOURS', 168) * 3600 * 1000, + bash: env('HOOKPILOT_BASH') ?? (isWindows ? 'bash' : '/bin/bash'), + /** 脚本执行时的 PATH,子进程不继承服务自身的完整环境 */ + execPath: + env('HOOKPILOT_EXEC_PATH') ?? + (isWindows ? (process.env.PATH ?? '') : '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'), + initialAdminUser: env('HOOKPILOT_ADMIN_USER') ?? 'admin', + initialAdminPassword: env('HOOKPILOT_ADMIN_PASSWORD'), + /** 终止任务时 SIGTERM 与 SIGKILL 之间的等待时间 */ + killGraceMs: 3000, +}; + +export type AppConfig = typeof config; + +export function dataPath(...parts: string[]): string { + return path.join(config.dataDir, ...parts); +} diff --git a/server/src/db.ts b/server/src/db.ts new file mode 100644 index 0000000..870e865 --- /dev/null +++ b/server/src/db.ts @@ -0,0 +1,168 @@ +import { createRequire } from 'node:module'; +import type { DatabaseSync as DatabaseSyncType, StatementSync } from 'node:sqlite'; + +// ESM 会在任何代码执行前链接内置模块,静态 import 会让 warnings.ts 的过滤来不及生效,因此在运行时加载 +const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as typeof import('node:sqlite'); + +export type SqlParam = null | number | bigint | string | Uint8Array; + +const MIGRATIONS: string[] = [ + ` + CREATE TABLE admin ( + id INTEGER PRIMARY KEY CHECK (id = 1), + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE hooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + group_name TEXT NOT NULL DEFAULT '', + method TEXT NOT NULL DEFAULT 'POST' CHECK (method IN ('GET', 'POST')), + enabled INTEGER NOT NULL DEFAULT 1, + token_hash TEXT NOT NULL, + token_hint TEXT NOT NULL DEFAULT '', + params TEXT NOT NULL DEFAULT '[]', + current_version_id INTEGER, + working_dir TEXT NOT NULL DEFAULT '', + timeout_sec INTEGER NOT NULL DEFAULT 30, + response_mode TEXT NOT NULL DEFAULT 'async' CHECK (response_mode IN ('sync', 'async')), + concurrency INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE script_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hook_id INTEGER NOT NULL REFERENCES hooks(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE (hook_id, version) + ); + + -- 任务表兼作持久化队列,保存参数、请求与执行配置快照 + CREATE TABLE executions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hook_id INTEGER NOT NULL REFERENCES hooks(id) ON DELETE CASCADE, + script_version_id INTEGER NOT NULL, + trigger_type TEXT NOT NULL, + source_execution_id INTEGER, + status TEXT NOT NULL, + env TEXT NOT NULL, + param_defs TEXT NOT NULL, + request TEXT NOT NULL, + config TEXT NOT NULL, + request_ip TEXT NOT NULL DEFAULT '', + exit_code INTEGER, + signal TEXT, + error TEXT, + stdout_bytes INTEGER NOT NULL DEFAULT 0, + stderr_bytes INTEGER NOT NULL DEFAULT 0, + output_truncated INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + started_at INTEGER, + finished_at INTEGER + ); + CREATE INDEX idx_executions_status ON executions (status, id); + CREATE INDEX idx_executions_hook ON executions (hook_id, id); + CREATE INDEX idx_executions_finished ON executions (finished_at); + + CREATE TABLE execution_output ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_id INTEGER NOT NULL REFERENCES executions(id) ON DELETE CASCADE, + stream TEXT NOT NULL CHECK (stream IN ('stdout', 'stderr')), + byte_offset INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE INDEX idx_execution_output ON execution_output (execution_id, stream, byte_offset); + + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `, +]; + +export class Db { + readonly raw: DatabaseSyncType; + private readonly statements = new Map(); + private inTransaction = false; + + constructor(file: string) { + this.raw = new DatabaseSync(file); + this.raw.exec('PRAGMA journal_mode = WAL'); + this.raw.exec('PRAGMA synchronous = NORMAL'); + this.raw.exec('PRAGMA foreign_keys = ON'); + this.raw.exec('PRAGMA busy_timeout = 5000'); + this.migrate(); + } + + private prepare(sql: string): StatementSync { + let statement = this.statements.get(sql); + if (!statement) { + statement = this.raw.prepare(sql); + this.statements.set(sql, statement); + } + return statement; + } + + get(sql: string, ...params: SqlParam[]): T | undefined { + return this.prepare(sql).get(...params) as T | undefined; + } + + all(sql: string, ...params: SqlParam[]): T[] { + return this.prepare(sql).all(...params) as T[]; + } + + run(sql: string, ...params: SqlParam[]): { changes: number; lastInsertRowid: number } { + const result = this.prepare(sql).run(...params); + return { changes: Number(result.changes), lastInsertRowid: Number(result.lastInsertRowid) }; + } + + exec(sql: string): void { + this.raw.exec(sql); + } + + /** 同步事务;嵌套调用时并入外层事务 */ + transaction(fn: () => T): T { + if (this.inTransaction) return fn(); + this.raw.exec('BEGIN IMMEDIATE'); + this.inTransaction = true; + try { + const result = fn(); + this.raw.exec('COMMIT'); + return result; + } catch (err) { + this.raw.exec('ROLLBACK'); + throw err; + } finally { + this.inTransaction = false; + } + } + + close(): void { + this.raw.close(); + } + + private migrate(): void { + const current = this.get<{ user_version: number }>('PRAGMA user_version')?.user_version ?? 0; + for (let i = current; i < MIGRATIONS.length; i++) { + this.transaction(() => { + this.raw.exec(MIGRATIONS[i]!); + this.raw.exec(`PRAGMA user_version = ${i + 1}`); + }); + } + } +} diff --git a/server/src/executor/runner.ts b/server/src/executor/runner.ts new file mode 100644 index 0000000..2400867 --- /dev/null +++ b/server/src/executor/runner.ts @@ -0,0 +1,300 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; +import type { RequestSnapshot } from '../hooks/params.js'; +import type { ExecutionOutcome, FinalStatus, OutputStream } from './store.js'; + +const isWindows = process.platform === 'win32'; +/** 主进程退出后等待输出管道关闭的时间;后台子进程仍占用管道时不再无限等待 */ +const PIPE_CLOSE_WAIT_MS = 2000; +const FLUSH_INTERVAL_MS = 250; +const FLUSH_THRESHOLD_CHARS = 64 * 1024; +const WINDOWS_ENV_PASSTHROUGH = ['SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP', 'USERPROFILE', 'COMSPEC', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA']; + +export type KillReason = 'timeout' | 'killed' | 'shutdown'; + +export interface RunSpec { + executionId: number; + hookSlug: string; + trigger: string; + scriptPath: string; + /** 参数映射得到的 HOOK_ 变量 */ + env: Record; + request: RequestSnapshot; + workingDir: string; + timeoutSec: number; + outputLimitBytes: number; +} + +export interface RunnerOptions { + bash: string; + execPath: string; + dataDir: string; + killGraceMs: number; + writeOutput: (stream: OutputStream, offset: number, data: string) => void; + onError: (err: unknown) => void; +} + +/** + * 按块缓存输出并写入数据库。超出上限后继续读取并丢弃,避免管道写满阻塞脚本。 + * 偏移量按已保存文本的 UTF-8 字节数计算,块边界不会切断多字节字符。 + */ +class OutputCollector { + private readonly decoders = { stdout: new StringDecoder('utf8'), stderr: new StringDecoder('utf8') }; + private readonly pending = { stdout: '', stderr: '' }; + private readonly offsets = { stdout: 0, stderr: 0 }; + private stored = 0; + private timer: NodeJS.Timeout | null = null; + private closed = false; + truncated = false; + + constructor( + private readonly limit: number, + private readonly write: RunnerOptions['writeOutput'], + private readonly onError: RunnerOptions['onError'], + ) {} + + push(stream: OutputStream, chunk: Buffer): void { + if (this.closed || this.truncated) return; + let data = chunk; + const remaining = this.limit - this.stored; + if (data.length > remaining) { + data = data.subarray(0, remaining); + this.truncated = true; + } + this.stored += data.length; + this.pending[stream] += this.decoders[stream].write(data); + if (this.pending[stream].length >= FLUSH_THRESHOLD_CHARS) this.flush(stream); + else if (!this.timer) { + this.timer = setTimeout(() => { + this.timer = null; + this.flushAll(); + }, FLUSH_INTERVAL_MS); + } + } + + flushAll(): void { + this.flush('stdout'); + this.flush('stderr'); + } + + close(): void { + if (this.closed) return; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + this.pending.stdout += this.decoders.stdout.end(); + this.pending.stderr += this.decoders.stderr.end(); + this.flushAll(); + this.closed = true; + } + + bytes(stream: OutputStream): number { + return this.offsets[stream]; + } + + private flush(stream: OutputStream): void { + const text = this.pending[stream]; + if (!text) return; + this.pending[stream] = ''; + const offset = this.offsets[stream]; + this.offsets[stream] += Buffer.byteLength(text); + try { + this.write(stream, offset, text); + } catch (err) { + this.onError(err); + } + } +} + +export class Runner { + private child: ChildProcess | null = null; + private killReason: KillReason | null = null; + + constructor( + private readonly spec: RunSpec, + private readonly options: RunnerOptions, + ) {} + + /** 终止任务进程组:先 SIGTERM,宽限期后 SIGKILL(即使主进程已退出,也清理残留子进程) */ + kill(reason: KillReason): void { + if (this.killReason) return; + this.killReason = reason; + if (!this.child || this.child.pid === undefined) return; + this.signalGroup('SIGTERM'); + setTimeout(() => this.signalGroup('SIGKILL'), this.options.killGraceMs).unref(); + } + + async run(): Promise { + const output = new OutputCollector(this.spec.outputLimitBytes, this.options.writeOutput, this.options.onError); + const runDir = path.join(this.options.dataDir, 'runs', String(this.spec.executionId)); + try { + if (this.killReason) return this.outcome(output, null, null, null); + + await fs.mkdir(runDir, { recursive: true, mode: 0o700 }); + const requestFile = path.join(runDir, 'request.json'); + await fs.writeFile(requestFile, JSON.stringify(this.spec.request, null, 2), { mode: 0o600 }); + + const cwd = this.spec.workingDir || path.join(this.options.dataDir, 'workspace'); + const stat = await fs.stat(cwd).catch(() => null); + if (!stat?.isDirectory()) return this.outcome(output, null, null, `工作目录不存在或不可访问:${cwd}`); + + return await this.spawnAndWait(cwd, this.buildEnv(requestFile), output); + } catch (err) { + return this.outcome(output, null, null, err instanceof Error ? err.message : String(err)); + } finally { + output.close(); + await fs.rm(runDir, { recursive: true, force: true }).catch(() => undefined); + } + } + + /** 显式构造子进程环境,不继承服务自身的配置与凭证 */ + private buildEnv(requestFile: string): Record { + let username = process.env.USER ?? ''; + try { + username = os.userInfo().username; + } catch { + // 部分容器环境没有 passwd 记录 + } + const env: Record = { + PATH: this.options.execPath, + HOME: process.env.HOME ?? os.homedir(), + USER: username, + LOGNAME: username, + SHELL: this.options.bash, + LANG: process.env.LANG || 'C.UTF-8', + }; + if (process.env.TZ) env.TZ = process.env.TZ; + if (isWindows) { + for (const key of WINDOWS_ENV_PASSTHROUGH) { + const value = process.env[key]; + if (value) env[key] = value; + } + } + for (const [key, value] of Object.entries(this.spec.env)) { + if (key.startsWith('HOOK_')) env[key] = value; + } + env.HOOK_EXECUTION_ID = String(this.spec.executionId); + env.HOOK_SLUG = this.spec.hookSlug; + env.HOOK_TRIGGER = this.spec.trigger; + env.HOOK_REQUEST_FILE = requestFile; + return env; + } + + private spawnAndWait(cwd: string, env: Record, output: OutputCollector): Promise { + return new Promise((resolve) => { + let child: ChildProcess; + try { + // 请求数据只通过环境变量传入,脚本文件原样执行,不做任何文本替换 + child = spawn(this.options.bash, [this.spec.scriptPath], { + cwd, + env, + detached: !isWindows, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + } catch (err) { + resolve(this.outcome(output, null, null, `无法启动 Bash:${err instanceof Error ? err.message : String(err)}`)); + return; + } + this.child = child; + + // 准备阶段收到的终止请求在进程启动后立即执行 + if (this.killReason) { + const reason = this.killReason; + this.killReason = null; + this.kill(reason); + } + + let exitCode: number | null = null; + let signal: string | null = null; + let spawnError: string | null = null; + let settled = false; + let closeTimer: NodeJS.Timeout | undefined; + const timeout = setTimeout(() => this.kill('timeout'), this.spec.timeoutSec * 1000); + + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (closeTimer) clearTimeout(closeTimer); + output.flushAll(); + resolve(this.outcome(output, exitCode, signal, spawnError ? `无法启动 Bash:${spawnError}` : null)); + }; + + child.stdout?.on('data', (chunk: Buffer) => output.push('stdout', chunk)); + child.stderr?.on('data', (chunk: Buffer) => output.push('stderr', chunk)); + child.on('error', (err) => { + spawnError = err.message; + if (child.pid === undefined) finish(); + }); + child.on('exit', (code, sig) => { + exitCode = code; + signal = sig; + closeTimer = setTimeout(() => { + child.stdout?.destroy(); + child.stderr?.destroy(); + finish(); + }, PIPE_CLOSE_WAIT_MS); + }); + child.on('close', (code, sig) => { + if (exitCode === null && signal === null) { + exitCode = code; + signal = sig; + } + finish(); + }); + }); + } + + private signalGroup(signal: NodeJS.Signals): void { + const pid = this.child?.pid; + if (pid === undefined) return; + if (isWindows) { + spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }).on('error', () => undefined); + return; + } + try { + process.kill(-pid, signal); + } catch { + // 进程组已全部退出 + } + } + + private outcome( + output: OutputCollector, + exitCode: number | null, + signal: string | null, + error: string | null, + ): ExecutionOutcome { + let status: FinalStatus; + let message = error; + switch (this.killReason) { + case 'timeout': + status = 'timeout'; + message = `执行超过 ${this.spec.timeoutSec} 秒,已终止进程组`; + break; + case 'killed': + status = 'killed'; + message = '已被管理员手动终止'; + break; + case 'shutdown': + status = 'interrupted'; + message = '服务停止,执行被中断'; + break; + default: + status = !error && exitCode === 0 ? 'success' : 'failed'; + if (!message && signal) message = `进程被信号 ${signal} 结束`; + } + return { + status, + exitCode, + signal, + error: message, + stdoutBytes: output.bytes('stdout'), + stderrBytes: output.bytes('stderr'), + truncated: output.truncated, + }; + } +} diff --git a/server/src/executor/scheduler.ts b/server/src/executor/scheduler.ts new file mode 100644 index 0000000..974721e --- /dev/null +++ b/server/src/executor/scheduler.ts @@ -0,0 +1,187 @@ +import type { AppConfig } from '../config.js'; +import type { HookRepo } from '../hooks/repo.js'; +import type { RequestSnapshot } from '../hooks/params.js'; +import type { SettingsStore } from '../settings.js'; +import { Runner } from './runner.js'; +import type { ExecConfig, ExecutionOutcome, ExecutionStore, NewExecution } from './store.js'; + +interface RunningTask { + hookId: number; + runner: Runner; + done: Promise; +} + +export interface SchedulerDeps { + config: AppConfig; + store: ExecutionStore; + hooks: HookRepo; + settings: SettingsStore; + log: { error: (obj: unknown, msg: string) => void }; +} + +function failed(error: string): ExecutionOutcome { + return { status: 'failed', exitCode: null, signal: null, error, stdoutBytes: 0, stderrBytes: 0, truncated: false }; +} + +/** + * 任务调度:SQLite 中的排队任务按接收顺序启动, + * 同时受全局并发上限与各接口并发数限制。 + */ +export class Scheduler { + private readonly running = new Map(); + private readonly waiters = new Map void)[]>(); + private scheduled = false; + private stopping = false; + + constructor(private readonly deps: SchedulerDeps) {} + + /** 启动时调用,返回被标记为中断的任务数 */ + recover(): number { + return this.deps.store.markInterrupted(); + } + + enqueue(input: NewExecution): number { + const id = this.deps.store.insert(input); + this.schedule(); + return id; + } + + schedule(): void { + if (this.scheduled || this.stopping) return; + this.scheduled = true; + setImmediate(() => { + this.scheduled = false; + try { + this.tick(); + } catch (err) { + this.deps.log.error({ err }, '任务调度失败'); + } + }); + } + + /** 取消排队任务或终止运行中的任务 */ + cancel(id: number): 'cancelled' | 'killing' | 'inactive' { + if (this.deps.store.cancelQueued(id)) { + this.notify(id); + this.schedule(); + return 'cancelled'; + } + const task = this.running.get(id); + if (task) { + task.runner.kill('killed'); + return 'killing'; + } + return 'inactive'; + } + + cancelQueuedForHook(hookId: number, reason: string): number { + const ids = this.deps.store.cancelQueuedForHook(hookId, reason); + ids.forEach((id) => this.notify(id)); + return ids.length; + } + + hasRunning(hookId: number): boolean { + for (const task of this.running.values()) if (task.hookId === hookId) return true; + return false; + } + + /** 等待任务进入终态(成功、失败、取消等) */ + waitFor(id: number): Promise { + const row = this.deps.store.get(id); + if (!row || (row.status !== 'queued' && row.status !== 'running')) return Promise.resolve(); + return new Promise((resolve) => { + const list = this.waiters.get(id) ?? []; + list.push(resolve); + this.waiters.set(id, list); + }); + } + + /** 服务停止:终止运行中的任务并标记为中断,排队任务保留到下次启动 */ + async shutdown(): Promise { + this.stopping = true; + const tasks = [...this.running.values()]; + tasks.forEach((task) => task.runner.kill('shutdown')); + const deadline = new Promise((resolve) => setTimeout(resolve, this.deps.config.killGraceMs + 2000).unref()); + await Promise.race([Promise.all(tasks.map((t) => t.done)), deadline]); + } + + private tick(): void { + if (this.stopping) return; + const limit = this.deps.settings.get().globalConcurrency; + if (this.running.size >= limit) return; + + const perHook = new Map(); + for (const task of this.running.values()) perHook.set(task.hookId, (perHook.get(task.hookId) ?? 0) + 1); + + for (const item of this.deps.store.queued()) { + if (this.running.size >= limit) break; + const count = perHook.get(item.hook_id) ?? 0; + if (count >= item.concurrency) continue; + if (this.start(item.id)) perHook.set(item.hook_id, count + 1); + } + } + + private start(id: number): boolean { + const { store, hooks, settings, config, log } = this.deps; + if (!store.markRunning(id)) return false; + + let runner: Runner; + let hookId: number; + try { + const execution = store.get(id)!; + hookId = execution.hook_id; + const hook = hooks.get(execution.hook_id); + const version = hooks.version(execution.script_version_id); + if (!hook || !version) throw new Error('接口或脚本版本不存在'); + const execConfig = JSON.parse(execution.config) as ExecConfig; + runner = new Runner( + { + executionId: id, + hookSlug: hook.slug, + trigger: execution.trigger_type, + scriptPath: hooks.ensureScriptFile(version), + env: JSON.parse(execution.env) as Record, + request: JSON.parse(execution.request) as RequestSnapshot, + workingDir: execConfig.workingDir, + timeoutSec: execConfig.timeoutSec, + outputLimitBytes: settings.get().outputLimitBytes, + }, + { + bash: config.bash, + execPath: config.execPath, + dataDir: config.dataDir, + killGraceMs: config.killGraceMs, + writeOutput: (stream, offset, data) => store.appendOutput(id, stream, offset, data), + onError: (err) => log.error({ err, executionId: id }, '写入任务输出失败'), + }, + ); + } catch (err) { + store.finish(id, failed(err instanceof Error ? err.message : String(err))); + this.notify(id); + return false; + } + + const done = runner + .run() + .catch((err: unknown) => failed(err instanceof Error ? err.message : String(err))) + .then((outcome) => { + try { + store.finish(id, outcome); + } catch (err) { + log.error({ err, executionId: id }, '更新任务状态失败'); + } + this.running.delete(id); + this.notify(id); + this.schedule(); + }); + this.running.set(id, { hookId, runner, done }); + return true; + } + + private notify(id: number): void { + const list = this.waiters.get(id); + if (!list) return; + this.waiters.delete(id); + list.forEach((resolve) => resolve()); + } +} diff --git a/server/src/executor/store.ts b/server/src/executor/store.ts new file mode 100644 index 0000000..bddc529 --- /dev/null +++ b/server/src/executor/store.ts @@ -0,0 +1,285 @@ +import type { Db, SqlParam } from '../db.js'; +import type { ParamDef, RequestSnapshot } from '../hooks/params.js'; + +export type ExecutionStatus = + | 'queued' + | 'running' + | 'success' + | 'failed' + | 'timeout' + | 'cancelled' + | 'killed' + | 'interrupted'; +export type FinalStatus = Exclude; +export type TriggerType = 'webhook' | 'test' | 'rerun'; +export type OutputStream = 'stdout' | 'stderr'; + +export const EXECUTION_STATUSES: ExecutionStatus[] = [ + 'queued', + 'running', + 'success', + 'failed', + 'timeout', + 'cancelled', + 'killed', + 'interrupted', +]; +export const TRIGGER_TYPES: TriggerType[] = ['webhook', 'test', 'rerun']; + +export interface ExecConfig { + workingDir: string; + timeoutSec: number; + responseMode: 'sync' | 'async'; +} + +export interface ExecutionRow { + id: number; + hook_id: number; + script_version_id: number; + trigger_type: TriggerType; + source_execution_id: number | null; + status: ExecutionStatus; + env: string; + param_defs: string; + request: string; + config: string; + request_ip: string; + exit_code: number | null; + signal: string | null; + error: string | null; + stdout_bytes: number; + stderr_bytes: number; + output_truncated: number; + created_at: number; + started_at: number | null; + finished_at: number | null; +} + +/** 列表与详情查询附带的接口与版本信息 */ +export interface ExecutionListRow extends ExecutionRow { + hook_slug: string; + hook_name: string; + script_version: number; +} + +export interface NewExecution { + hookId: number; + scriptVersionId: number; + trigger: TriggerType; + sourceExecutionId: number | null; + env: Record; + paramDefs: ParamDef[]; + request: RequestSnapshot; + config: ExecConfig; + requestIp: string; +} + +export interface ExecutionOutcome { + status: FinalStatus; + exitCode: number | null; + signal: string | null; + error: string | null; + stdoutBytes: number; + stderrBytes: number; + truncated: boolean; +} + +export interface ExecutionFilter { + hookId?: number; + status?: ExecutionStatus; + trigger?: TriggerType; +} + +const LIST_SELECT = ` + SELECT e.*, h.slug AS hook_slug, h.name AS hook_name, v.version AS script_version + FROM executions e + JOIN hooks h ON h.id = e.hook_id + JOIN script_versions v ON v.id = e.script_version_id +`; + +export class ExecutionStore { + constructor(private readonly db: Db) {} + + insert(input: NewExecution): number { + const { lastInsertRowid } = this.db.run( + `INSERT INTO executions (hook_id, script_version_id, trigger_type, source_execution_id, status, env, param_defs, + request, config, request_ip, created_at) + VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?)`, + input.hookId, + input.scriptVersionId, + input.trigger, + input.sourceExecutionId, + JSON.stringify(input.env), + JSON.stringify(input.paramDefs), + JSON.stringify(input.request), + JSON.stringify(input.config), + input.requestIp, + Date.now(), + ); + return lastInsertRowid; + } + + get(id: number): ExecutionRow | undefined { + return this.db.get('SELECT * FROM executions WHERE id = ?', id); + } + + getDetailed(id: number): ExecutionListRow | undefined { + return this.db.get(`${LIST_SELECT} WHERE e.id = ?`, id); + } + + list(filter: ExecutionFilter, limit: number, offset: number): { items: ExecutionListRow[]; total: number } { + const where: string[] = []; + const params: SqlParam[] = []; + if (filter.hookId) { + where.push('e.hook_id = ?'); + params.push(filter.hookId); + } + if (filter.status) { + where.push('e.status = ?'); + params.push(filter.status); + } + if (filter.trigger) { + where.push('e.trigger_type = ?'); + params.push(filter.trigger); + } + const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''; + const total = this.db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM executions e ${clause}`, ...params)?.n ?? 0; + const items = this.db.all( + `${LIST_SELECT} ${clause} ORDER BY e.id DESC LIMIT ? OFFSET ?`, + ...params, + limit, + offset, + ); + return { items, total }; + } + + recent(limit: number, statuses?: ExecutionStatus[]): ExecutionListRow[] { + if (!statuses?.length) return this.db.all(`${LIST_SELECT} ORDER BY e.id DESC LIMIT ?`, limit); + const marks = statuses.map(() => '?').join(', '); + return this.db.all( + `${LIST_SELECT} WHERE e.status IN (${marks}) ORDER BY e.id DESC LIMIT ?`, + ...statuses, + limit, + ); + } + + countByStatus(since?: number): Record { + const rows = since + ? this.db.all<{ status: string; n: number }>( + 'SELECT status, COUNT(*) AS n FROM executions WHERE created_at >= ? GROUP BY status', + since, + ) + : this.db.all<{ status: string; n: number }>('SELECT status, COUNT(*) AS n FROM executions GROUP BY status'); + return Object.fromEntries(rows.map((r) => [r.status, r.n])); + } + + /** 按接收顺序列出排队任务及其接口当前的并发配置 */ + queued(): { id: number; hook_id: number; concurrency: number }[] { + return this.db.all( + `SELECT e.id, e.hook_id, h.concurrency FROM executions e JOIN hooks h ON h.id = e.hook_id + WHERE e.status = 'queued' ORDER BY e.id`, + ); + } + + markRunning(id: number): boolean { + return ( + this.db.run("UPDATE executions SET status = 'running', started_at = ? WHERE id = ? AND status = 'queued'", Date.now(), id) + .changes > 0 + ); + } + + finish(id: number, outcome: ExecutionOutcome): void { + this.db.run( + `UPDATE executions SET status = ?, exit_code = ?, signal = ?, error = ?, stdout_bytes = ?, stderr_bytes = ?, + output_truncated = ?, finished_at = ? + WHERE id = ?`, + outcome.status, + outcome.exitCode, + outcome.signal, + outcome.error, + outcome.stdoutBytes, + outcome.stderrBytes, + outcome.truncated ? 1 : 0, + Date.now(), + id, + ); + } + + cancelQueued(id: number): boolean { + return ( + this.db.run( + "UPDATE executions SET status = 'cancelled', finished_at = ?, error = '排队中被取消' WHERE id = ? AND status = 'queued'", + Date.now(), + id, + ).changes > 0 + ); + } + + cancelQueuedForHook(hookId: number, reason: string): number[] { + return this.db.transaction(() => { + const ids = this.db + .all<{ id: number }>("SELECT id FROM executions WHERE hook_id = ? AND status = 'queued'", hookId) + .map((r) => r.id); + this.db.run( + "UPDATE executions SET status = 'cancelled', finished_at = ?, error = ? WHERE hook_id = ? AND status = 'queued'", + Date.now(), + reason, + hookId, + ); + return ids; + }); + } + + /** 服务启动时调用:上次异常退出时仍在运行的任务标记为中断,不自动重跑 */ + markInterrupted(): number { + return this.db.run( + "UPDATE executions SET status = 'interrupted', finished_at = ?, error = '服务重启,执行被中断' WHERE status = 'running'", + Date.now(), + ).changes; + } + + appendOutput(id: number, stream: OutputStream, offset: number, data: string): void { + this.db.run( + 'INSERT INTO execution_output (execution_id, stream, byte_offset, data) VALUES (?, ?, ?, ?)', + id, + stream, + offset, + data, + ); + } + + /** 从指定字节偏移量起读取输出,单次最多返回约 maxBytes 字节 */ + readOutput(id: number, stream: OutputStream, offset: number, maxBytes: number): { data: string; nextOffset: number; more: boolean } { + const batch = 200; + const rows = this.db.all<{ byte_offset: number; data: string }>( + `SELECT byte_offset, data FROM execution_output + WHERE execution_id = ? AND stream = ? AND byte_offset >= ? ORDER BY byte_offset LIMIT ?`, + id, + stream, + offset, + batch, + ); + let data = ''; + let size = 0; + let nextOffset = offset; + let more = rows.length === batch; + for (const row of rows) { + const length = Buffer.byteLength(row.data); + if (size > 0 && size + length > maxBytes) { + more = true; + break; + } + data += row.data; + size += length; + nextOffset = row.byte_offset + length; + } + return { data, nextOffset, more }; + } + + purgeFinishedBefore(cutoff: number): number { + return this.db.run( + "DELETE FROM executions WHERE finished_at IS NOT NULL AND finished_at < ? AND status NOT IN ('queued', 'running')", + cutoff, + ).changes; + } +} diff --git a/server/src/hooks/params.ts b/server/src/hooks/params.ts new file mode 100644 index 0000000..07e85f5 --- /dev/null +++ b/server/src/hooks/params.ts @@ -0,0 +1,326 @@ +import net from 'node:net'; +import { badRequest } from '../util/http-error.js'; + +export const PARAM_SOURCES = ['json', 'form', 'query', 'header'] as const; +export const PARAM_TYPES = ['string', 'number', 'boolean', 'ip'] as const; +export type ParamSource = (typeof PARAM_SOURCES)[number]; +export type ParamType = (typeof PARAM_TYPES)[number]; + +export interface ParamDef { + /** 脚本中的环境变量名,统一以 HOOK_ 开头 */ + env: string; + source: ParamSource; + /** JSON 使用点路径(如 data.ip、items.0.name),其余为字段名或请求头名 */ + key: string; + type: ParamType; + required: boolean; + default: string; + sensitive: boolean; + description: string; +} + +export interface RequestSnapshot { + method: string; + contentType: string; + query: Record; + headers: Record; + bodyKind: 'json' | 'form' | 'text' | 'none'; + body: unknown; +} + +export interface ParamValue { + env: string; + value: string; + origin: 'request' | 'default' | 'empty'; + sensitive: boolean; +} + +export interface ExtractResult { + env: Record; + values: ParamValue[]; + errors: string[]; +} + +/** 平台自动注入的变量,不允许参数占用 */ +export const RESERVED_ENV = new Set(['HOOK_REQUEST_FILE', 'HOOK_EXECUTION_ID', 'HOOK_SLUG', 'HOOK_TRIGGER']); +/** 鉴权相关请求头:不写入执行记录,也不能作为参数来源 */ +export const SECRET_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie', 'x-hook-token']); +export const TOKEN_QUERY_KEY = 'token'; +export const MASK = '******'; + +const ENV_PATTERN = /^HOOK_[A-Z0-9_]+$/; +const SOURCE_LABELS: Record = { json: 'JSON', form: '表单', query: 'Query', header: 'Header' }; +const TRUE_VALUES = new Set(['true', '1', 'yes', 'on']); +const FALSE_VALUES = new Set(['false', '0', 'no', 'off']); +const NUMBER_PATTERN = /^[-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?$/; + +type Coerced = { ok: true; value: string } | { ok: false; reason: string }; + +function coerce(raw: unknown, type: ParamType): Coerced { + switch (type) { + case 'string': + if (typeof raw === 'string') return { ok: true, value: raw }; + return { ok: true, value: typeof raw === 'object' ? JSON.stringify(raw) : String(raw) }; + case 'number': + if (typeof raw === 'number' && Number.isFinite(raw)) return { ok: true, value: String(raw) }; + if (typeof raw === 'string' && NUMBER_PATTERN.test(raw.trim())) return { ok: true, value: raw.trim() }; + return { ok: false, reason: '不是有效的数字' }; + case 'boolean': { + if (typeof raw === 'boolean') return { ok: true, value: String(raw) }; + const text = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + if (TRUE_VALUES.has(text)) return { ok: true, value: 'true' }; + if (FALSE_VALUES.has(text)) return { ok: true, value: 'false' }; + return { ok: false, reason: '不是有效的布尔值(可用 true/false、1/0、yes/no、on/off)' }; + } + case 'ip': + if (typeof raw === 'string' && net.isIP(raw.trim()) !== 0) return { ok: true, value: raw.trim() }; + return { ok: false, reason: '不是合法的 IP 地址' }; + } +} + +export function normalizeParamDefs(input: unknown, method: string): ParamDef[] { + if (!Array.isArray(input)) throw badRequest('参数配置格式错误'); + if (input.length > 100) throw badRequest('参数数量不能超过 100 个'); + const seen = new Set(); + + return input.map((item, index) => { + const label = `第 ${index + 1} 个参数`; + if (!item || typeof item !== 'object') throw badRequest(`${label}格式错误`); + const raw = item as Record; + + const env = String(raw.env ?? '').trim().toUpperCase(); + if (!ENV_PATTERN.test(env)) { + throw badRequest(`${label}:变量名需以 HOOK_ 开头,且只包含大写字母、数字和下划线`); + } + if (RESERVED_ENV.has(env)) throw badRequest(`${label}:${env} 是平台保留变量`); + if (seen.has(env)) throw badRequest(`${label}:变量名 ${env} 重复`); + seen.add(env); + + const source = raw.source as ParamSource; + if (!PARAM_SOURCES.includes(source)) throw badRequest(`${label}:参数来源无效`); + if (method === 'GET' && (source === 'json' || source === 'form')) { + throw badRequest(`${label}:GET 接口只能从 Query 或 Header 提取参数`); + } + + let key = String(raw.key ?? '').trim(); + if (!key || key.length > 200) throw badRequest(`${label}:请填写来源字段名(最多 200 字符)`); + if (source === 'header') { + key = key.toLowerCase(); + if (SECRET_HEADERS.has(key)) throw badRequest(`${label}:不能从鉴权相关请求头 ${key} 提取参数`); + } + if (source === 'query' && key === TOKEN_QUERY_KEY) { + throw badRequest(`${label}:Query 字段 token 用于接口鉴权,不能作为参数`); + } + + const type = (raw.type ?? 'string') as ParamType; + if (!PARAM_TYPES.includes(type)) throw badRequest(`${label}:参数类型无效`); + + const defaultValue = raw.default === undefined || raw.default === null ? '' : String(raw.default); + if (defaultValue !== '') { + const result = coerce(defaultValue, type); + if (!result.ok) throw badRequest(`${label}:默认值${result.reason}`); + if (result.value.includes('\0')) throw badRequest(`${label}:默认值包含非法字符`); + } + + return { + env, + source, + key, + type, + required: Boolean(raw.required), + default: defaultValue, + sensitive: Boolean(raw.sensitive), + description: String(raw.description ?? '').slice(0, 500), + }; + }); +} + +function splitPath(path: string): string[] { + return path.split('.').filter((segment) => segment !== ''); +} + +function readPath(value: unknown, path: string): unknown { + let current = value; + for (const segment of splitPath(path)) { + if (Array.isArray(current) && /^\d+$/.test(segment)) { + current = current[Number(segment)]; + } else if (current !== null && typeof current === 'object' && Object.hasOwn(current, segment)) { + current = (current as Record)[segment]; + } else { + return undefined; + } + } + return current; +} + +function first(value: unknown): unknown { + return Array.isArray(value) ? value[0] : value; +} + +function lookup(def: ParamDef, request: RequestSnapshot): unknown { + switch (def.source) { + case 'json': + return request.bodyKind === 'json' ? readPath(request.body, def.key) : undefined; + case 'form': { + if (request.bodyKind !== 'form' || !request.body || typeof request.body !== 'object') return undefined; + const body = request.body as Record; + return Object.hasOwn(body, def.key) ? first(body[def.key]) : undefined; + } + case 'query': + return Object.hasOwn(request.query, def.key) ? first(request.query[def.key]) : undefined; + case 'header': + return Object.hasOwn(request.headers, def.key) ? request.headers[def.key] : undefined; + } +} + +/** + * 按参数配置从请求中提取并校验参数。 + * 每个参数只从管理员指定的来源读取,不在 Body、Query、Header 之间隐式回退。 + */ +export function extractParams(defs: ParamDef[], request: RequestSnapshot): ExtractResult { + const env: Record = {}; + const values: ParamValue[] = []; + const errors: string[] = []; + + for (const def of defs) { + const raw = lookup(def, request); + const missing = raw === undefined || raw === null || raw === ''; + let value = ''; + let origin: ParamValue['origin'] = 'request'; + + if (missing) { + if (def.default !== '') { + const result = coerce(def.default, def.type); + value = result.ok ? result.value : def.default; + origin = 'default'; + } else if (def.required) { + errors.push(`${def.env}:缺少必填参数(${SOURCE_LABELS[def.source]} 字段 ${def.key})`); + continue; + } else { + origin = 'empty'; + } + } else { + const result = coerce(raw, def.type); + if (!result.ok) { + errors.push(`${def.env}:${result.reason}`); + continue; + } + value = result.value; + } + + if (value.includes('\0')) { + errors.push(`${def.env}:包含非法的空字符`); + continue; + } + env[def.env] = value; + values.push({ env: def.env, value, origin, sensitive: def.sensitive }); + } + + return { env, values, errors }; +} + +export function maskEnv(env: Record, defs: ParamDef[]): Record { + const sensitive = new Set(defs.filter((d) => d.sensitive).map((d) => d.env)); + const masked: Record = {}; + for (const [key, value] of Object.entries(env)) { + masked[key] = sensitive.has(key) && value !== '' ? MASK : value; + } + return masked; +} + +function maskPath(value: unknown, path: string): void { + const segments = splitPath(path); + let current = value; + for (let i = 0; i < segments.length; i++) { + if (current === null || typeof current !== 'object') return; + const segment = segments[i]!; + const container = current as Record; + if (!Object.hasOwn(container, segment)) return; + if (i === segments.length - 1) container[segment] = MASK; + else current = container[segment]; + } +} + +/** 返回隐藏了敏感参数原值的请求快照副本,用于在管理界面展示 */ +export function maskRequest(request: RequestSnapshot, defs: ParamDef[]): RequestSnapshot { + const copy = structuredClone(request); + for (const def of defs) { + if (!def.sensitive) continue; + switch (def.source) { + case 'json': + if (copy.bodyKind === 'json') maskPath(copy.body, def.key); + break; + case 'form': + if (copy.bodyKind === 'form' && copy.body && typeof copy.body === 'object') { + const body = copy.body as Record; + if (Object.hasOwn(body, def.key)) body[def.key] = MASK; + } + break; + case 'query': + if (Object.hasOwn(copy.query, def.key)) copy.query[def.key] = MASK; + break; + case 'header': + if (Object.hasOwn(copy.headers, def.key)) copy.headers[def.key] = MASK; + break; + } + } + return copy; +} + +/** 由原始字符串组装请求快照,用于在线测试 */ +export function buildSnapshot(input: { + method: string; + query: string; + headers: Record; + bodyType: RequestSnapshot['bodyKind']; + body: string; +}): RequestSnapshot { + const query: Record = {}; + for (const [key, value] of new URLSearchParams(input.query.replace(/^\?/, ''))) { + if (key === TOKEN_QUERY_KEY) continue; + appendValue(query, key, value); + } + + const headers: Record = {}; + for (const [name, value] of Object.entries(input.headers)) { + const key = name.trim().toLowerCase(); + if (!key || SECRET_HEADERS.has(key)) continue; + headers[key] = String(value); + } + + let body: unknown = null; + let contentType = ''; + switch (input.bodyType) { + case 'json': + contentType = 'application/json'; + try { + body = JSON.parse(input.body); + } catch { + throw badRequest('请求体不是合法的 JSON'); + } + break; + case 'form': { + contentType = 'application/x-www-form-urlencoded'; + const form: Record = {}; + for (const [key, value] of new URLSearchParams(input.body)) appendValue(form, key, value); + body = form; + break; + } + case 'text': + contentType = 'text/plain'; + body = input.body; + break; + case 'none': + break; + } + if (contentType) headers['content-type'] = contentType; + + return { method: input.method, contentType, query, headers, bodyKind: input.bodyType, body }; +} + +function appendValue(target: Record, key: string, value: string): void { + const existing = target[key]; + if (existing === undefined) target[key] = value; + else if (Array.isArray(existing)) existing.push(value); + else target[key] = [existing, value]; +} diff --git a/server/src/hooks/repo.ts b/server/src/hooks/repo.ts new file mode 100644 index 0000000..257873c --- /dev/null +++ b/server/src/hooks/repo.ts @@ -0,0 +1,315 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { Db } from '../db.js'; +import { generateHookToken, sha256, tokenHint } from '../util/crypto.js'; +import { badRequest, conflict, notFound } from '../util/http-error.js'; +import { normalizeParamDefs, type ParamDef } from './params.js'; + +export type HttpMethod = 'GET' | 'POST'; +export type ResponseMode = 'sync' | 'async'; + +export interface HookRow { + id: number; + slug: string; + name: string; + description: string; + group_name: string; + method: HttpMethod; + enabled: number; + token_hash: string; + token_hint: string; + params: string; + current_version_id: number | null; + working_dir: string; + timeout_sec: number; + response_mode: ResponseMode; + concurrency: number; + created_at: number; + updated_at: number; +} + +export interface HookListRow extends HookRow { + current_version: number | null; + last_status: string | null; + last_at: number | null; +} + +export interface ScriptVersionRow { + id: number; + hook_id: number; + version: number; + content: string; + created_at: number; +} + +export interface HookInput { + slug: string; + name: string; + description: string; + group: string; + method: HttpMethod; + params: ParamDef[]; + script: string; + workingDir: string; + timeoutSec: number; + responseMode: ResponseMode; + concurrency: number; +} + +const SLUG_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; +const MAX_SCRIPT_BYTES = 256 * 1024; + +function intInRange(value: unknown, min: number, max: number, label: string): number { + const n = Number(value); + if (!Number.isInteger(n) || n < min || n > max) throw badRequest(`${label}需为 ${min} 到 ${max} 之间的整数`); + return n; +} + +export function validateSlug(value: unknown): string { + const slug = String(value ?? '').trim(); + if (!SLUG_PATTERN.test(slug)) { + throw badRequest('接口标识只能包含小写字母、数字、下划线和连字符,以字母或数字开头,最多 64 个字符'); + } + return slug; +} + +export function normalizeHookInput(body: unknown): HookInput { + if (!body || typeof body !== 'object') throw badRequest('请求内容格式错误'); + const raw = body as Record; + + const name = String(raw.name ?? '').trim(); + if (!name || name.length > 100) throw badRequest('请填写接口名称(最多 100 个字符)'); + + const method = String(raw.method ?? 'POST').toUpperCase(); + if (method !== 'GET' && method !== 'POST') throw badRequest('请求方式只能是 GET 或 POST'); + + const script = String(raw.script ?? '').replace(/\r\n?/g, '\n'); + if (!script.trim()) throw badRequest('脚本内容不能为空'); + if (Buffer.byteLength(script) > MAX_SCRIPT_BYTES) throw badRequest('脚本内容不能超过 256 KiB'); + + const workingDir = String(raw.workingDir ?? '').trim(); + if (workingDir && !path.isAbsolute(workingDir)) throw badRequest('工作目录必须是绝对路径'); + + const responseMode = String(raw.responseMode ?? 'async'); + if (responseMode !== 'sync' && responseMode !== 'async') throw badRequest('响应模式无效'); + + const description = String(raw.description ?? '').trim(); + if (description.length > 1000) throw badRequest('描述最多 1000 个字符'); + const group = String(raw.group ?? '').trim(); + if (group.length > 50) throw badRequest('分组名称最多 50 个字符'); + + return { + slug: validateSlug(raw.slug), + name, + description, + group, + method, + params: normalizeParamDefs(raw.params ?? [], method), + script, + workingDir, + timeoutSec: intInRange(raw.timeoutSec ?? 30, 1, 86400, '超时时间(秒)'), + responseMode, + concurrency: intInRange(raw.concurrency ?? 1, 1, 64, '接口并发数'), + }; +} + +export function parseParamDefs(row: HookRow): ParamDef[] { + return JSON.parse(row.params) as ParamDef[]; +} + +export class HookRepo { + constructor( + private readonly db: Db, + private readonly scriptsDir: string, + ) {} + + list(): HookListRow[] { + return this.db.all(` + SELECT h.*, v.version AS current_version, + (SELECT e.status FROM executions e WHERE e.hook_id = h.id ORDER BY e.id DESC LIMIT 1) AS last_status, + (SELECT e.created_at FROM executions e WHERE e.hook_id = h.id ORDER BY e.id DESC LIMIT 1) AS last_at + FROM hooks h + LEFT JOIN script_versions v ON v.id = h.current_version_id + ORDER BY h.group_name, h.name, h.id + `); + } + + get(id: number): HookRow | undefined { + return this.db.get('SELECT * FROM hooks WHERE id = ?', id); + } + + require(id: number): HookRow { + const hook = this.get(id); + if (!hook) throw notFound('接口不存在'); + return hook; + } + + getBySlug(slug: string): HookRow | undefined { + return this.db.get('SELECT * FROM hooks WHERE slug = ?', slug); + } + + groups(): string[] { + return this.db + .all<{ group_name: string }>("SELECT DISTINCT group_name FROM hooks WHERE group_name <> '' ORDER BY group_name") + .map((r) => r.group_name); + } + + create(input: HookInput, enabled = true): { hook: HookRow; token: string } { + if (this.getBySlug(input.slug)) throw conflict(`接口标识 ${input.slug} 已被使用`); + const token = generateHookToken(); + const now = Date.now(); + const hook = this.db.transaction(() => { + const { lastInsertRowid: id } = this.db.run( + `INSERT INTO hooks (slug, name, description, group_name, method, enabled, token_hash, token_hint, params, + working_dir, timeout_sec, response_mode, concurrency, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + input.slug, + input.name, + input.description, + input.group, + input.method, + enabled ? 1 : 0, + sha256(token), + tokenHint(token), + JSON.stringify(input.params), + input.workingDir, + input.timeoutSec, + input.responseMode, + input.concurrency, + now, + now, + ); + const versionId = this.addVersion(id, input.script); + this.db.run('UPDATE hooks SET current_version_id = ? WHERE id = ?', versionId, id); + return this.get(id)!; + }); + return { hook, token }; + } + + /** 更新配置;脚本内容变化时生成新版本,已入队任务仍使用各自固定的版本 */ + update(id: number, input: HookInput): HookRow { + const current = this.require(id); + if (input.slug !== current.slug && this.getBySlug(input.slug)) { + throw conflict(`接口标识 ${input.slug} 已被使用`); + } + return this.db.transaction(() => { + let versionId = current.current_version_id; + const currentVersion = versionId ? this.version(versionId) : undefined; + if (!currentVersion || currentVersion.content !== input.script) { + versionId = this.addVersion(id, input.script); + } + this.db.run( + `UPDATE hooks SET slug = ?, name = ?, description = ?, group_name = ?, method = ?, params = ?, + working_dir = ?, timeout_sec = ?, response_mode = ?, concurrency = ?, current_version_id = ?, updated_at = ? + WHERE id = ?`, + input.slug, + input.name, + input.description, + input.group, + input.method, + JSON.stringify(input.params), + input.workingDir, + input.timeoutSec, + input.responseMode, + input.concurrency, + versionId, + Date.now(), + id, + ); + return this.get(id)!; + }); + } + + copy(id: number, slug: string, name: string): { hook: HookRow; token: string } { + const source = this.require(id); + const script = source.current_version_id ? this.version(source.current_version_id)?.content : undefined; + return this.create( + { + slug: validateSlug(slug), + name: name.trim() || `${source.name}(副本)`, + description: source.description, + group: source.group_name, + method: source.method, + params: parseParamDefs(source), + script: script ?? '#!/usr/bin/env bash\n', + workingDir: source.working_dir, + timeoutSec: source.timeout_sec, + responseMode: source.response_mode, + concurrency: source.concurrency, + }, + false, + ); + } + + delete(id: number): void { + this.require(id); + this.db.transaction(() => { + this.db.run('DELETE FROM executions WHERE hook_id = ?', id); + this.db.run('DELETE FROM script_versions WHERE hook_id = ?', id); + this.db.run('DELETE FROM hooks WHERE id = ?', id); + }); + fs.rmSync(path.join(this.scriptsDir, String(id)), { recursive: true, force: true }); + } + + setEnabled(id: number, enabled: boolean): HookRow { + this.require(id); + this.db.run('UPDATE hooks SET enabled = ?, updated_at = ? WHERE id = ?', enabled ? 1 : 0, Date.now(), id); + return this.get(id)!; + } + + regenerateToken(id: number): string { + this.require(id); + const token = generateHookToken(); + this.db.run( + 'UPDATE hooks SET token_hash = ?, token_hint = ?, updated_at = ? WHERE id = ?', + sha256(token), + tokenHint(token), + Date.now(), + id, + ); + return token; + } + + versions(hookId: number): Omit[] { + return this.db.all( + 'SELECT id, hook_id, version, created_at FROM script_versions WHERE hook_id = ? ORDER BY version DESC', + hookId, + ); + } + + version(id: number): ScriptVersionRow | undefined { + return this.db.get('SELECT * FROM script_versions WHERE id = ?', id); + } + + /** 确保版本对应的脚本文件存在且与数据库内容一致,返回文件路径 */ + ensureScriptFile(version: ScriptVersionRow): string { + const dir = path.join(this.scriptsDir, String(version.hook_id)); + const file = path.join(dir, `v${version.version}.sh`); + let existing: string | null = null; + try { + existing = fs.readFileSync(file, 'utf8'); + } catch { + existing = null; + } + if (existing !== version.content) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(file, version.content, { mode: 0o700 }); + } + return file; + } + + private addVersion(hookId: number, content: string): number { + const latest = this.db.get<{ v: number | null }>( + 'SELECT MAX(version) AS v FROM script_versions WHERE hook_id = ?', + hookId, + ); + const { lastInsertRowid } = this.db.run( + 'INSERT INTO script_versions (hook_id, version, content, created_at) VALUES (?, ?, ?, ?)', + hookId, + (latest?.v ?? 0) + 1, + content, + Date.now(), + ); + return lastInsertRowid; + } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..04c51f5 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,82 @@ +import './warnings.js'; +import fs from 'node:fs'; +import { ensureAdmin, purgeExpiredSessions } from './auth/admin.js'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from './app.js'; +import { config, dataPath } from './config.js'; +import { Db } from './db.js'; +import { Scheduler } from './executor/scheduler.js'; +import { ExecutionStore } from './executor/store.js'; +import { HookRepo } from './hooks/repo.js'; +import { SettingsStore } from './settings.js'; + +const MAINTENANCE_INTERVAL_MS = 3600 * 1000; + +async function main(): Promise { + fs.mkdirSync(config.dataDir, { recursive: true, mode: 0o700 }); + fs.mkdirSync(dataPath('scripts'), { recursive: true, mode: 0o700 }); + fs.mkdirSync(dataPath('workspace'), { recursive: true }); + // 任务临时目录只在执行期间存在,启动时清理上次残留 + fs.rmSync(dataPath('runs'), { recursive: true, force: true }); + fs.mkdirSync(dataPath('runs'), { recursive: true, mode: 0o700 }); + + const db = new Db(dataPath('hookpilot.db')); + const settings = new SettingsStore(db); + const hooks = new HookRepo(db, dataPath('scripts')); + const store = new ExecutionStore(db); + + // 路由注册时就会取用调度器,因此先创建调度器;其日志在应用构建完成后才会被调用 + let app: FastifyInstance | null = null; + const scheduler = new Scheduler({ + config, + store, + hooks, + settings, + log: { error: (obj: unknown, msg: string) => app?.log.error(obj, msg) }, + }); + app = await buildApp({ config, db, settings, hooks, store, scheduler }); + + const generatedPassword = await ensureAdmin(db, config.initialAdminUser, config.initialAdminPassword); + if (generatedPassword) { + app.log.warn(`已创建管理员账户 ${config.initialAdminUser},初始密码:${generatedPassword}(请登录后立即修改)`); + } + + const interrupted = scheduler.recover(); + if (interrupted) app.log.warn(`${interrupted} 个任务在上次服务停止时仍在运行,已标记为中断`); + + const maintenance = () => { + try { + const cutoff = Date.now() - settings.get().retentionDays * 86400 * 1000; + const purged = store.purgeFinishedBefore(cutoff); + purgeExpiredSessions(db); + if (purged) app.log.info(`已清理 ${purged} 条过期执行记录`); + } catch (err) { + app.log.error({ err }, '定期清理失败'); + } + }; + maintenance(); + const timer = setInterval(maintenance, MAINTENANCE_INTERVAL_MS); + timer.unref(); + + await app.listen({ host: config.host, port: config.port }); + scheduler.schedule(); + + let shuttingDown = false; + const shutdown = async (signal: string) => { + if (shuttingDown) return; + shuttingDown = true; + app.log.info(`收到 ${signal},正在停止服务`); + clearInterval(timer); + await scheduler.shutdown(); + await app.close(); + db.close(); + process.exit(0); + }; + process.on('SIGINT', () => void shutdown('SIGINT')); + process.on('SIGTERM', () => void shutdown('SIGTERM')); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..fb7a8d1 --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,98 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import type { AppContext } from '../app.js'; +import { + createSession, + deleteOtherSessions, + deleteSession, + findSession, + getAdmin, + MIN_PASSWORD_LENGTH, + SESSION_COOKIE, + setAdminCredentials, +} from '../auth/admin.js'; +import { verifyPassword } from '../auth/password.js'; +import { LoginLimiter } from '../auth/rate-limit.js'; +import { badRequest, HttpError } from '../util/http-error.js'; + +function cookieSecure(ctx: AppContext, req: FastifyRequest): boolean { + return ctx.config.cookieSecure === 'auto' ? req.protocol === 'https' : ctx.config.cookieSecure; +} + +/** 管理 API 鉴权:除标记为 public 的路由外,都要求有效的登录会话 */ +export function registerSessionGuard(app: FastifyInstance, ctx: AppContext): void { + app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { + if (req.routeOptions.config?.public) return; + const token = req.cookies[SESSION_COOKIE]; + const session = token ? findSession(ctx.db, token) : undefined; + if (!session) { + return reply.code(401).send({ error: '未登录或登录已过期' }); + } + req.sessionId = session.id; + }); +} + +export async function authRoutes(app: FastifyInstance, ctx: AppContext): Promise { + const limiter = new LoginLimiter(); + + app.post<{ Body: { username?: string; password?: string } }>( + '/auth/login', + { config: { public: true } }, + async (req, reply) => { + const locked = limiter.lockedFor(req.ip); + if (locked > 0) throw new HttpError(429, `登录失败次数过多,请 ${Math.ceil(locked / 60)} 分钟后再试`); + + const username = String(req.body?.username ?? ''); + const password = String(req.body?.password ?? ''); + const admin = getAdmin(ctx.db); + const ok = !!admin && admin.username === username && (await verifyPassword(password, admin.password_hash)); + if (!ok) { + limiter.fail(req.ip); + req.log.warn({ ip: req.ip }, '管理员登录失败'); + throw new HttpError(401, '用户名或密码错误'); + } + limiter.succeed(req.ip); + + const token = createSession(ctx.db, ctx.config.sessionTtlMs, req.ip, String(req.headers['user-agent'] ?? '')); + reply.setCookie(SESSION_COOKIE, token, { + path: '/', + httpOnly: true, + sameSite: 'strict', + secure: cookieSecure(ctx, req), + maxAge: Math.floor(ctx.config.sessionTtlMs / 1000), + }); + return { username: admin!.username }; + }, + ); + + app.post('/auth/logout', async (req, reply) => { + deleteSession(ctx.db, req.sessionId); + reply.clearCookie(SESSION_COOKIE, { path: '/' }); + return { ok: true }; + }); + + app.get('/auth/me', async () => { + return { username: getAdmin(ctx.db)?.username ?? '' }; + }); + + /** 修改用户名或密码;修改密码后其他会话全部失效 */ + app.put<{ Body: { currentPassword?: string; username?: string; newPassword?: string } }>( + '/auth/account', + async (req) => { + const admin = getAdmin(ctx.db)!; + const currentPassword = String(req.body?.currentPassword ?? ''); + if (!(await verifyPassword(currentPassword, admin.password_hash))) throw badRequest('当前密码不正确'); + + const username = req.body?.username === undefined ? admin.username : String(req.body.username).trim(); + if (!/^[A-Za-z0-9_.-]{1,32}$/.test(username)) throw badRequest('用户名只能包含字母、数字、下划线、点和连字符,最多 32 个字符'); + + const newPassword = req.body?.newPassword ? String(req.body.newPassword) : ''; + if (newPassword && newPassword.length < MIN_PASSWORD_LENGTH) { + throw badRequest(`新密码至少 ${MIN_PASSWORD_LENGTH} 个字符`); + } + + await setAdminCredentials(ctx.db, username, newPassword || currentPassword); + if (newPassword) deleteOtherSessions(ctx.db, req.sessionId); + return { username }; + }, + ); +} diff --git a/server/src/routes/dto.ts b/server/src/routes/dto.ts new file mode 100644 index 0000000..609a2bc --- /dev/null +++ b/server/src/routes/dto.ts @@ -0,0 +1,98 @@ +import { maskEnv, maskRequest, type ParamDef, type RequestSnapshot } from '../hooks/params.js'; +import { parseParamDefs, type HookListRow, type HookRow, type ScriptVersionRow } from '../hooks/repo.js'; +import type { ExecConfig, ExecutionListRow } from '../executor/store.js'; + +export function hookDto(row: HookRow, script: ScriptVersionRow | undefined) { + return { + id: row.id, + slug: row.slug, + name: row.name, + description: row.description, + group: row.group_name, + method: row.method, + enabled: row.enabled === 1, + tokenHint: row.token_hint, + params: parseParamDefs(row), + workingDir: row.working_dir, + timeoutSec: row.timeout_sec, + responseMode: row.response_mode, + concurrency: row.concurrency, + currentVersion: script ? { id: script.id, version: script.version, createdAt: script.created_at } : null, + script: script?.content ?? '', + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export function hookListDto(row: HookListRow) { + return { + id: row.id, + slug: row.slug, + name: row.name, + description: row.description, + group: row.group_name, + method: row.method, + enabled: row.enabled === 1, + responseMode: row.response_mode, + concurrency: row.concurrency, + currentVersion: row.current_version, + lastStatus: row.last_status, + lastAt: row.last_at, + updatedAt: row.updated_at, + }; +} + +function paramList(row: ExecutionListRow, truncate: number | null) { + const defs = JSON.parse(row.param_defs) as ParamDef[]; + const sensitive = new Set(defs.filter((d) => d.sensitive).map((d) => d.env)); + const masked = maskEnv(JSON.parse(row.env) as Record, defs); + return Object.entries(masked).map(([env, value]) => ({ + env, + value: truncate !== null && value.length > truncate ? `${value.slice(0, truncate)}…` : value, + sensitive: sensitive.has(env), + })); +} + +function durationMs(row: ExecutionListRow): number | null { + if (!row.started_at) return null; + return (row.finished_at ?? Date.now()) - row.started_at; +} + +export function executionSummaryDto(row: ExecutionListRow) { + return { + id: row.id, + hookId: row.hook_id, + hookSlug: row.hook_slug, + hookName: row.hook_name, + scriptVersionId: row.script_version_id, + scriptVersion: row.script_version, + trigger: row.trigger_type, + sourceExecutionId: row.source_execution_id, + status: row.status, + requestIp: row.request_ip, + params: paramList(row, 80), + exitCode: row.exit_code, + signal: row.signal, + error: row.error, + stdoutBytes: row.stdout_bytes, + stderrBytes: row.stderr_bytes, + truncated: row.output_truncated === 1, + createdAt: row.created_at, + startedAt: row.started_at, + finishedAt: row.finished_at, + durationMs: durationMs(row), + }; +} + +/** 执行详情:敏感参数与请求中对应字段均以掩码展示 */ +export function executionDetailDto(row: ExecutionListRow, script: ScriptVersionRow | undefined) { + const defs = JSON.parse(row.param_defs) as ParamDef[]; + return { + ...executionSummaryDto(row), + params: paramList(row, null), + paramDefs: defs, + request: maskRequest(JSON.parse(row.request) as RequestSnapshot, defs), + config: JSON.parse(row.config) as ExecConfig, + script: script?.content ?? '', + }; +} diff --git a/server/src/routes/executions.ts b/server/src/routes/executions.ts new file mode 100644 index 0000000..6984a01 --- /dev/null +++ b/server/src/routes/executions.ts @@ -0,0 +1,121 @@ +import type { FastifyInstance } from 'fastify'; +import type { AppContext } from '../app.js'; +import { + EXECUTION_STATUSES, + TRIGGER_TYPES, + type ExecutionStatus, + type ExecConfig, + type TriggerType, +} from '../executor/store.js'; +import { badRequest, notFound } from '../util/http-error.js'; +import { executionDetailDto, executionSummaryDto } from './dto.js'; + +const idParams = { + type: 'object', + properties: { id: { type: 'integer', minimum: 1 } }, + required: ['id'], +} as const; + +const LOG_CHUNK_BYTES = 256 * 1024; + +export async function executionRoutes(app: FastifyInstance, ctx: AppContext): Promise { + const { store, hooks, scheduler } = ctx; + + app.get<{ + Querystring: { hookId?: number; status?: ExecutionStatus; trigger?: TriggerType; page?: number; pageSize?: number }; + }>( + '/executions', + { + schema: { + querystring: { + type: 'object', + properties: { + hookId: { type: 'integer', minimum: 1 }, + status: { type: 'string', enum: EXECUTION_STATUSES }, + trigger: { type: 'string', enum: TRIGGER_TYPES }, + page: { type: 'integer', minimum: 1, default: 1 }, + pageSize: { type: 'integer', minimum: 1, maximum: 200, default: 20 }, + }, + }, + }, + }, + async (req) => { + const page = req.query.page ?? 1; + const pageSize = req.query.pageSize ?? 20; + const { items, total } = store.list( + { hookId: req.query.hookId, status: req.query.status, trigger: req.query.trigger }, + pageSize, + (page - 1) * pageSize, + ); + return { items: items.map(executionSummaryDto), total, page, pageSize }; + }, + ); + + app.get<{ Params: { id: number } }>('/executions/:id', { schema: { params: idParams } }, async (req) => { + const row = store.getDetailed(req.params.id); + if (!row) throw notFound('执行记录不存在'); + return executionDetailDto(row, hooks.version(row.script_version_id)); + }); + + /** 按字节偏移量增量读取 stdout / stderr,执行期间可持续轮询 */ + app.get<{ Params: { id: number }; Querystring: { stdout?: number; stderr?: number } }>( + '/executions/:id/logs', + { + schema: { + params: idParams, + querystring: { + type: 'object', + properties: { + stdout: { type: 'integer', minimum: 0, default: 0 }, + stderr: { type: 'integer', minimum: 0, default: 0 }, + }, + }, + }, + }, + async (req) => { + const row = store.get(req.params.id); + if (!row) throw notFound('执行记录不存在'); + const stdout = store.readOutput(row.id, 'stdout', req.query.stdout ?? 0, LOG_CHUNK_BYTES); + const stderr = store.readOutput(row.id, 'stderr', req.query.stderr ?? 0, LOG_CHUNK_BYTES); + return { + status: row.status, + finished: row.status !== 'queued' && row.status !== 'running', + truncated: row.output_truncated === 1, + stdout, + stderr, + }; + }, + ); + + app.post<{ Params: { id: number } }>('/executions/:id/cancel', { schema: { params: idParams } }, async (req) => { + if (!store.get(req.params.id)) throw notFound('执行记录不存在'); + const result = scheduler.cancel(req.params.id); + if (result === 'inactive') throw badRequest('任务已结束,无法取消'); + return { result }; + }); + + /** 使用原脚本版本与参数创建新任务;执行策略取接口当前配置 */ + app.post<{ Params: { id: number } }>('/executions/:id/rerun', { schema: { params: idParams } }, async (req, reply) => { + const source = store.get(req.params.id); + if (!source) throw notFound('执行记录不存在'); + const hook = hooks.require(source.hook_id); + const config: ExecConfig = { + workingDir: hook.working_dir, + timeoutSec: hook.timeout_sec, + responseMode: hook.response_mode, + }; + const executionId = scheduler.enqueue({ + hookId: hook.id, + scriptVersionId: source.script_version_id, + trigger: 'rerun', + sourceExecutionId: source.id, + env: JSON.parse(source.env) as Record, + paramDefs: JSON.parse(source.param_defs), + request: JSON.parse(source.request), + config, + requestIp: req.ip, + }); + reply.code(202); + return { executionId }; + }); +} diff --git a/server/src/routes/hooks.ts b/server/src/routes/hooks.ts new file mode 100644 index 0000000..90c450e --- /dev/null +++ b/server/src/routes/hooks.ts @@ -0,0 +1,161 @@ +import type { FastifyInstance } from 'fastify'; +import type { AppContext } from '../app.js'; +import { buildSnapshot, extractParams, normalizeParamDefs, type RequestSnapshot } from '../hooks/params.js'; +import { normalizeHookInput, parseParamDefs } from '../hooks/repo.js'; +import { badRequest, conflict, notFound } from '../util/http-error.js'; +import { hookDto, hookListDto } from './dto.js'; + +const idParams = { + type: 'object', + properties: { id: { type: 'integer', minimum: 1 } }, + required: ['id'], +} as const; + +interface TestBody { + dryRun?: boolean; + /** 仅预览时使用:以尚未保存的参数配置预览映射结果 */ + params?: unknown; + request?: { + query?: string; + headers?: Record; + bodyType?: RequestSnapshot['bodyKind']; + body?: string; + }; +} + +const BODY_TYPES: RequestSnapshot['bodyKind'][] = ['json', 'form', 'text', 'none']; + +export async function hookRoutes(app: FastifyInstance, ctx: AppContext): Promise { + const { hooks, scheduler } = ctx; + + const detail = (id: number) => { + const hook = hooks.require(id); + return hookDto(hook, hook.current_version_id ? hooks.version(hook.current_version_id) : undefined); + }; + + app.get('/hooks', async () => { + return { items: hooks.list().map(hookListDto), groups: hooks.groups() }; + }); + + app.post('/hooks', async (req, reply) => { + const { hook, token } = hooks.create(normalizeHookInput(req.body)); + reply.code(201); + return { hook: detail(hook.id), token }; + }); + + app.get<{ Params: { id: number } }>('/hooks/:id', { schema: { params: idParams } }, async (req) => { + return detail(req.params.id); + }); + + app.put<{ Params: { id: number } }>('/hooks/:id', { schema: { params: idParams } }, async (req) => { + hooks.update(req.params.id, normalizeHookInput(req.body)); + scheduler.schedule(); + return detail(req.params.id); + }); + + app.delete<{ Params: { id: number } }>('/hooks/:id', { schema: { params: idParams } }, async (req) => { + hooks.require(req.params.id); + if (scheduler.hasRunning(req.params.id)) throw conflict('该接口有正在运行的任务,请先终止后再删除'); + hooks.delete(req.params.id); + return { ok: true }; + }); + + app.post<{ Params: { id: number }; Body: { slug?: string; name?: string } }>( + '/hooks/:id/copy', + { schema: { params: idParams } }, + async (req, reply) => { + const { hook, token } = hooks.copy(req.params.id, String(req.body?.slug ?? ''), String(req.body?.name ?? '')); + reply.code(201); + return { hook: detail(hook.id), token }; + }, + ); + + /** 启用或停用;停用时取消该接口尚未开始的任务 */ + app.post<{ Params: { id: number }; Body: { enabled?: boolean } }>( + '/hooks/:id/enabled', + { schema: { params: idParams } }, + async (req) => { + const enabled = req.body?.enabled === true; + hooks.setEnabled(req.params.id, enabled); + const cancelled = enabled ? 0 : scheduler.cancelQueuedForHook(req.params.id, '接口已停用,排队任务被取消'); + return { hook: detail(req.params.id), cancelled }; + }, + ); + + app.post<{ Params: { id: number } }>('/hooks/:id/token', { schema: { params: idParams } }, async (req) => { + return { token: hooks.regenerateToken(req.params.id) }; + }); + + app.get<{ Params: { id: number } }>('/hooks/:id/versions', { schema: { params: idParams } }, async (req) => { + const hook = hooks.require(req.params.id); + return { + currentVersionId: hook.current_version_id, + items: hooks.versions(hook.id).map((v) => ({ id: v.id, version: v.version, createdAt: v.created_at })), + }; + }); + + app.get<{ Params: { id: number; versionId: number } }>( + '/hooks/:id/versions/:versionId', + { + schema: { + params: { + type: 'object', + properties: { id: { type: 'integer' }, versionId: { type: 'integer' } }, + required: ['id', 'versionId'], + }, + }, + }, + async (req) => { + const version = hooks.version(req.params.versionId); + if (!version || version.hook_id !== req.params.id) throw notFound('脚本版本不存在'); + return { id: version.id, version: version.version, content: version.content, createdAt: version.created_at }; + }, + ); + + /** + * 在线测试:dryRun 只预览参数映射;否则使用当前保存的脚本版本真实执行。 + * 测试任务跳过 Token 校验,其余流程与外部调用一致。 + */ + app.post<{ Params: { id: number }; Body: TestBody }>( + '/hooks/:id/test', + { schema: { params: idParams } }, + async (req, reply) => { + const hook = hooks.require(req.params.id); + const body = req.body ?? {}; + const input = body.request ?? {}; + const bodyType = input.bodyType ?? 'none'; + if (!BODY_TYPES.includes(bodyType)) throw badRequest('请求体类型无效'); + + const snapshot = buildSnapshot({ + method: hook.method, + query: String(input.query ?? ''), + headers: input.headers && typeof input.headers === 'object' ? input.headers : {}, + bodyType, + body: String(input.body ?? ''), + }); + const defs = + body.dryRun && body.params !== undefined ? normalizeParamDefs(body.params, hook.method) : parseParamDefs(hook); + const result = extractParams(defs, snapshot); + + if (body.dryRun) { + return { ok: result.errors.length === 0, values: result.values, errors: result.errors, request: snapshot }; + } + if (result.errors.length) throw badRequest('参数校验未通过,脚本未执行', result.errors); + if (!hook.current_version_id) throw badRequest('接口尚未保存脚本'); + + const executionId = scheduler.enqueue({ + hookId: hook.id, + scriptVersionId: hook.current_version_id, + trigger: 'test', + sourceExecutionId: null, + env: result.env, + paramDefs: defs, + request: snapshot, + config: { workingDir: hook.working_dir, timeoutSec: hook.timeout_sec, responseMode: hook.response_mode }, + requestIp: req.ip, + }); + reply.code(202); + return { executionId }; + }, + ); +} diff --git a/server/src/routes/system.ts b/server/src/routes/system.ts new file mode 100644 index 0000000..6c047d6 --- /dev/null +++ b/server/src/routes/system.ts @@ -0,0 +1,37 @@ +import type { FastifyInstance } from 'fastify'; +import type { AppContext } from '../app.js'; +import { executionSummaryDto } from './dto.js'; + +const FAILURE_STATUSES = ['failed', 'timeout', 'killed', 'interrupted'] as const; + +export async function systemRoutes(app: FastifyInstance, ctx: AppContext): Promise { + const { db, store, settings, scheduler } = ctx; + + app.get('/overview', async () => { + const hookCounts = db.get<{ total: number; enabled: number }>( + 'SELECT COUNT(*) AS total, COALESCE(SUM(enabled), 0) AS enabled FROM hooks', + )!; + const active = store.countByStatus(); + const last24h = store.countByStatus(Date.now() - 24 * 3600 * 1000); + return { + hooks: hookCounts, + running: active.running ?? 0, + queued: active.queued ?? 0, + last24h: { + success: last24h.success ?? 0, + failed: FAILURE_STATUSES.reduce((sum, s) => sum + (last24h[s] ?? 0), 0), + total: Object.values(last24h).reduce((sum, n) => sum + n, 0), + }, + recent: store.recent(10).map(executionSummaryDto), + recentFailures: store.recent(5, [...FAILURE_STATUSES]).map(executionSummaryDto), + }; + }); + + app.get('/settings', async () => settings.get()); + + app.put('/settings', async (req) => { + const updated = settings.update((req.body ?? {}) as Record); + scheduler.schedule(); + return updated; + }); +} diff --git a/server/src/routes/webhook.ts b/server/src/routes/webhook.ts new file mode 100644 index 0000000..c162e4a --- /dev/null +++ b/server/src/routes/webhook.ts @@ -0,0 +1,139 @@ +import formbody from '@fastify/formbody'; +import type { FastifyError, FastifyInstance, FastifyRequest } from 'fastify'; +import type { AppContext } from '../app.js'; +import type { ExecutionStatus } from '../executor/store.js'; +import { extractParams, SECRET_HEADERS, TOKEN_QUERY_KEY, type RequestSnapshot } from '../hooks/params.js'; +import { parseParamDefs } from '../hooks/repo.js'; +import { safeEqual, sha256 } from '../util/crypto.js'; + +/** 同步模式下各终态对应的 HTTP 状态码 */ +const SYNC_STATUS_CODES: Partial> = { + success: 200, + failed: 500, + timeout: 504, + killed: 409, + cancelled: 409, + interrupted: 503, +}; + +function extractToken(req: FastifyRequest): string { + const auth = req.headers.authorization; + if (typeof auth === 'string' && /^bearer\s+/i.test(auth)) return auth.replace(/^bearer\s+/i, '').trim(); + const header = req.headers['x-hook-token']; + if (typeof header === 'string' && header) return header.trim(); + const query = (req.query as Record)[TOKEN_QUERY_KEY]; + return typeof query === 'string' ? query : ''; +} + +/** 请求快照:不包含鉴权头、Cookie 与 query 中的 token */ +function snapshotRequest(req: FastifyRequest): RequestSnapshot { + const contentType = String(req.headers['content-type'] ?? '').toLowerCase(); + + const query: Record = {}; + for (const [key, value] of Object.entries((req.query ?? {}) as Record)) { + if (key === TOKEN_QUERY_KEY || value === undefined) continue; + query[key] = Array.isArray(value) ? value.map(String) : String(value); + } + + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (SECRET_HEADERS.has(key) || value === undefined) continue; + headers[key] = Array.isArray(value) ? value.join(', ') : String(value); + } + + let body: unknown = req.body ?? null; + let bodyKind: RequestSnapshot['bodyKind']; + if (body === null || body === '') { + body = null; + bodyKind = 'none'; + } else if (contentType.includes('json') && typeof body === 'object') { + bodyKind = 'json'; + } else if (contentType.includes('application/x-www-form-urlencoded') && typeof body === 'object') { + bodyKind = 'form'; + body = { ...(body as Record) }; + } else { + bodyKind = 'text'; + body = Buffer.isBuffer(body) ? body.toString('utf8') : typeof body === 'string' ? body : JSON.stringify(body); + } + + return { method: req.method, contentType, query, headers, bodyKind, body }; +} + +export async function webhookRoutes(app: FastifyInstance, ctx: AppContext): Promise { + const { hooks, scheduler, store } = ctx; + + await app.register(formbody); + // 其他类型的请求体按文本接收,脚本可通过 HOOK_REQUEST_FILE 读取 + app.addContentTypeParser('*', { parseAs: 'string' }, (_req, body, done) => done(null, body)); + + app.setErrorHandler((err: FastifyError, req, reply) => { + const statusCode = err.statusCode && err.statusCode >= 400 && err.statusCode < 500 ? err.statusCode : 500; + if (statusCode === 500) req.log.error({ err }, 'Webhook 处理失败'); + reply.code(statusCode).send({ + ok: false, + error: statusCode === 413 ? 'payload_too_large' : statusCode === 500 ? 'internal_error' : 'bad_request', + message: statusCode === 500 ? '服务器内部错误' : err.message, + }); + }); + + app.route<{ Params: { slug: string } }>({ + method: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], + url: '/hooks/:slug', + handler: async (req, reply) => { + // 1. 查找配置,检查启用状态与请求方式 + const hook = hooks.getBySlug(req.params.slug); + if (!hook) return reply.code(404).send({ ok: false, error: 'not_found', message: '接口不存在' }); + if (!hook.enabled) return reply.code(403).send({ ok: false, error: 'disabled', message: '接口已停用' }); + if (req.method !== hook.method) { + reply.header('Allow', hook.method); + return reply.code(405).send({ ok: false, error: 'method_not_allowed', message: `该接口只接受 ${hook.method} 请求` }); + } + + // 2. 校验 Token(请求大小由 bodyLimit 限制) + const token = extractToken(req); + if (!token || !safeEqual(sha256(token), hook.token_hash)) { + return reply.code(401).send({ ok: false, error: 'unauthorized', message: 'Token 无效' }); + } + + // 3. 提取并校验参数 + const snapshot = snapshotRequest(req); + const defs = parseParamDefs(hook); + const result = extractParams(defs, snapshot); + if (result.errors.length) { + return reply.code(400).send({ ok: false, error: 'invalid_params', message: '参数校验未通过', details: result.errors }); + } + if (!hook.current_version_id) { + return reply.code(500).send({ ok: false, error: 'no_script', message: '接口尚未配置脚本' }); + } + + // 4. 持久化任务,固定参数与脚本版本;5. 由调度器按并发限制执行 + const executionId = scheduler.enqueue({ + hookId: hook.id, + scriptVersionId: hook.current_version_id, + trigger: 'webhook', + sourceExecutionId: null, + env: result.env, + paramDefs: defs, + request: snapshot, + config: { workingDir: hook.working_dir, timeoutSec: hook.timeout_sec, responseMode: hook.response_mode }, + requestIp: req.ip, + }); + + // 7. 异步模式在任务持久化后立即返回 + if (hook.response_mode === 'async') { + return reply.code(202).send({ ok: true, executionId, status: 'queued' }); + } + + await scheduler.waitFor(executionId); + const row = store.get(executionId)!; + const durationMs = row.started_at && row.finished_at ? row.finished_at - row.started_at : null; + return reply.code(SYNC_STATUS_CODES[row.status] ?? 500).send({ + ok: row.status === 'success', + executionId, + status: row.status, + exitCode: row.exit_code, + durationMs, + }); + }, + }); +} diff --git a/server/src/settings.ts b/server/src/settings.ts new file mode 100644 index 0000000..0ef0776 --- /dev/null +++ b/server/src/settings.ts @@ -0,0 +1,72 @@ +import type { Db } from './db.js'; +import { badRequest } from './util/http-error.js'; + +export interface Settings { + /** 全局同时运行的任务上限 */ + globalConcurrency: number; + /** 执行记录保留天数 */ + retentionDays: number; + /** 单次执行输出保留上限(字节,stdout 与 stderr 合计) */ + outputLimitBytes: number; +} + +export const DEFAULT_SETTINGS: Settings = { + globalConcurrency: 4, + retentionDays: 30, + outputLimitBytes: 5 * 1024 * 1024, +}; + +const RANGES: Record = { + globalConcurrency: [1, 64, '全局并发上限'], + retentionDays: [1, 3650, '日志保留天数'], + outputLimitBytes: [64 * 1024, 100 * 1024 * 1024, '任务输出大小上限'], +}; + +const KEYS = Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[]; + +export class SettingsStore { + private cache: Settings; + + constructor(private readonly db: Db) { + this.cache = this.load(); + } + + get(): Settings { + return { ...this.cache }; + } + + update(patch: Record): Settings { + const next = { ...this.cache }; + for (const key of KEYS) { + if (patch[key] === undefined) continue; + const value = Number(patch[key]); + const [min, max, label] = RANGES[key]; + if (!Number.isInteger(value) || value < min || value > max) { + throw badRequest(`${label}需为 ${min} 到 ${max} 之间的整数`); + } + next[key] = value; + } + this.db.transaction(() => { + for (const key of KEYS) { + this.db.run( + 'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value', + key, + JSON.stringify(next[key]), + ); + } + }); + this.cache = next; + return this.get(); + } + + private load(): Settings { + const settings = { ...DEFAULT_SETTINGS }; + for (const row of this.db.all<{ key: string; value: string }>('SELECT key, value FROM settings')) { + if ((KEYS as string[]).includes(row.key)) { + const value = Number(JSON.parse(row.value)); + if (Number.isInteger(value)) settings[row.key as keyof Settings] = value; + } + } + return settings; + } +} diff --git a/server/src/util/crypto.ts b/server/src/util/crypto.ts new file mode 100644 index 0000000..be727d9 --- /dev/null +++ b/server/src/util/crypto.ts @@ -0,0 +1,24 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; + +export function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +export function randomToken(bytes = 24): string { + return randomBytes(bytes).toString('base64url'); +} + +export function safeEqual(a: string, b: string): boolean { + const left = Buffer.from(a); + const right = Buffer.from(b); + return left.length === right.length && timingSafeEqual(left, right); +} + +/** Webhook 调用凭证,数据库只保存其哈希 */ +export function generateHookToken(): string { + return `hp_${randomToken(24)}`; +} + +export function tokenHint(token: string): string { + return token.slice(-4); +} diff --git a/server/src/util/http-error.ts b/server/src/util/http-error.ts new file mode 100644 index 0000000..5fd8e5c --- /dev/null +++ b/server/src/util/http-error.ts @@ -0,0 +1,13 @@ +export class HttpError extends Error { + constructor( + public readonly statusCode: number, + message: string, + public readonly details?: unknown, + ) { + super(message); + } +} + +export const badRequest = (message: string, details?: unknown) => new HttpError(400, message, details); +export const notFound = (message = '资源不存在') => new HttpError(404, message); +export const conflict = (message: string) => new HttpError(409, message); diff --git a/server/src/warnings.ts b/server/src/warnings.ts new file mode 100644 index 0000000..c0c7604 --- /dev/null +++ b/server/src/warnings.ts @@ -0,0 +1,11 @@ +// node:sqlite 在 Node 22 中仍标记为实验特性,只屏蔽它的启动警告,其余警告照常输出。 +// 必须作为入口的第一个 import,才能在 node:sqlite 加载前生效。 +const originalEmitWarning = process.emitWarning.bind(process) as (...args: unknown[]) => void; + +process.emitWarning = ((warning: string | Error, ...rest: unknown[]) => { + const text = typeof warning === 'string' ? warning : warning?.message; + if (typeof text === 'string' && text.includes('SQLite is an experimental feature')) return; + originalEmitWarning(warning, ...rest); +}) as typeof process.emitWarning; + +export {}; diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..9a99f8c --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..79c696f --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + HookPilot + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..423239b --- /dev/null +++ b/web/package.json @@ -0,0 +1,30 @@ +{ + "name": "@hookpilot/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "vue-tsc --noEmit -p tsconfig.json", + "preview": "vite preview" + }, + "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.4", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.13", + "codemirror": "^6.0.2", + "vue": "^3.5.43", + "vue-router": "^5.3.1" + }, + "devDependencies": { + "@types/node": "^22.20.4", + "@vitejs/plugin-vue": "^6.0.9", + "@vue/tsconfig": "^0.9.1", + "typescript": "~5.9.3", + "vite": "^8.3.1", + "vue-tsc": "^3.3.11" + } +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..6d93c9c --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,10 @@ + + + diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..02d36a4 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,130 @@ +import type { + ExecutionDetail, + ExecutionStatus, + ExecutionSummary, + HookDetail, + HookForm, + HookSummary, + LogsResponse, + Overview, + Paged, + ParamDef, + PreviewResult, + ScriptVersion, + ScriptVersionSummary, + Settings, + TestRequest, + TriggerType, +} from './types'; + +export class ApiError extends Error { + constructor( + public readonly status: number, + message: string, + public readonly details?: unknown, + ) { + super(message); + } +} + +let unauthorizedHandler: (() => void) | null = null; + +export function onUnauthorized(handler: () => void): void { + unauthorizedHandler = handler; +} + +async function request(method: string, url: string, body?: unknown): Promise { + const init: RequestInit = { method, credentials: 'same-origin', headers: {} }; + if (method !== 'GET') { + init.headers = { 'Content-Type': 'application/json' }; + init.body = JSON.stringify(body ?? {}); + } + + let response: Response; + try { + response = await fetch(url, init); + } catch { + throw new ApiError(0, '无法连接到服务器'); + } + + const text = await response.text(); + let data: unknown = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = null; + } + } + + if (!response.ok) { + const payload = (data ?? {}) as { error?: string; details?: unknown }; + if (response.status === 401 && url !== '/api/auth/login') unauthorizedHandler?.(); + throw new ApiError(response.status, payload.error ?? `请求失败(HTTP ${response.status})`, payload.details); + } + return data as T; +} + +function query(params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== '') search.set(key, String(value)); + } + const text = search.toString(); + return text ? `?${text}` : ''; +} + +export const api = { + login: (username: string, password: string) => + request<{ username: string }>('POST', '/api/auth/login', { username, password }), + logout: () => request<{ ok: boolean }>('POST', '/api/auth/logout'), + me: () => request<{ username: string }>('GET', '/api/auth/me'), + updateAccount: (body: { currentPassword: string; username?: string; newPassword?: string }) => + request<{ username: string }>('PUT', '/api/auth/account', body), + + overview: () => request('GET', '/api/overview'), + + listHooks: () => request<{ items: HookSummary[]; groups: string[] }>('GET', '/api/hooks'), + getHook: (id: number) => request('GET', `/api/hooks/${id}`), + createHook: (form: HookForm) => request<{ hook: HookDetail; token: string }>('POST', '/api/hooks', form), + updateHook: (id: number, form: HookForm) => request('PUT', `/api/hooks/${id}`, form), + deleteHook: (id: number) => request<{ ok: boolean }>('DELETE', `/api/hooks/${id}`), + copyHook: (id: number, slug: string, name: string) => + request<{ hook: HookDetail; token: string }>('POST', `/api/hooks/${id}/copy`, { slug, name }), + setHookEnabled: (id: number, enabled: boolean) => + request<{ hook: HookDetail; cancelled: number }>('POST', `/api/hooks/${id}/enabled`, { enabled }), + regenerateToken: (id: number) => request<{ token: string }>('POST', `/api/hooks/${id}/token`), + listVersions: (id: number) => + request<{ currentVersionId: number | null; items: ScriptVersionSummary[] }>('GET', `/api/hooks/${id}/versions`), + getVersion: (id: number, versionId: number) => + request('GET', `/api/hooks/${id}/versions/${versionId}`), + previewTest: (id: number, requestBody: TestRequest, params: ParamDef[]) => + request('POST', `/api/hooks/${id}/test`, { dryRun: true, params, request: requestBody }), + runTest: (id: number, requestBody: TestRequest) => + request<{ executionId: number }>('POST', `/api/hooks/${id}/test`, { dryRun: false, request: requestBody }), + + listExecutions: (filter: { + hookId?: number; + status?: ExecutionStatus | ''; + trigger?: TriggerType | ''; + page?: number; + pageSize?: number; + }) => request>('GET', `/api/executions${query(filter)}`), + getExecution: (id: number) => request('GET', `/api/executions/${id}`), + getLogs: (id: number, stdout: number, stderr: number) => + request('GET', `/api/executions/${id}/logs${query({ stdout, stderr })}`), + cancelExecution: (id: number) => + request<{ result: 'cancelled' | 'killing' }>('POST', `/api/executions/${id}/cancel`), + rerunExecution: (id: number) => request<{ executionId: number }>('POST', `/api/executions/${id}/rerun`), + + getSettings: () => request('GET', '/api/settings'), + updateSettings: (settings: Settings) => request('PUT', '/api/settings', settings), +}; + +export function errorMessage(err: unknown): string { + if (err instanceof ApiError) { + if (Array.isArray(err.details) && err.details.length) return `${err.message}:${err.details.join(';')}`; + return err.message; + } + return err instanceof Error ? err.message : String(err); +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..49e410e --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,172 @@ +export type HttpMethod = 'GET' | 'POST'; +export type ResponseMode = 'sync' | 'async'; +export type ParamSource = 'json' | 'form' | 'query' | 'header'; +export type ParamType = 'string' | 'number' | 'boolean' | 'ip'; +export type BodyKind = 'json' | 'form' | 'text' | 'none'; +export type ExecutionStatus = + | 'queued' + | 'running' + | 'success' + | 'failed' + | 'timeout' + | 'cancelled' + | 'killed' + | 'interrupted'; +export type TriggerType = 'webhook' | 'test' | 'rerun'; + +export interface ParamDef { + env: string; + source: ParamSource; + key: string; + type: ParamType; + required: boolean; + default: string; + sensitive: boolean; + description: string; +} + +export interface HookForm { + slug: string; + name: string; + description: string; + group: string; + method: HttpMethod; + params: ParamDef[]; + script: string; + workingDir: string; + timeoutSec: number; + responseMode: ResponseMode; + concurrency: number; +} + +export interface HookDetail extends HookForm { + id: number; + enabled: boolean; + tokenHint: string; + currentVersion: { id: number; version: number; createdAt: number } | null; + createdAt: number; + updatedAt: number; +} + +export interface HookSummary { + id: number; + slug: string; + name: string; + description: string; + group: string; + method: HttpMethod; + enabled: boolean; + responseMode: ResponseMode; + concurrency: number; + currentVersion: number | null; + lastStatus: ExecutionStatus | null; + lastAt: number | null; + updatedAt: number; +} + +export interface ScriptVersionSummary { + id: number; + version: number; + createdAt: number; +} + +export interface ScriptVersion extends ScriptVersionSummary { + content: string; +} + +export interface ParamValue { + env: string; + value: string; + origin: 'request' | 'default' | 'empty'; + sensitive: boolean; +} + +export interface RequestSnapshot { + method: string; + contentType: string; + query: Record; + headers: Record; + bodyKind: BodyKind; + body: unknown; +} + +export interface ExecutionSummary { + id: number; + hookId: number; + hookSlug: string; + hookName: string; + scriptVersionId: number; + scriptVersion: number; + trigger: TriggerType; + sourceExecutionId: number | null; + status: ExecutionStatus; + requestIp: string; + params: { env: string; value: string; sensitive: boolean }[]; + exitCode: number | null; + signal: string | null; + error: string | null; + stdoutBytes: number; + stderrBytes: number; + truncated: boolean; + createdAt: number; + startedAt: number | null; + finishedAt: number | null; + durationMs: number | null; +} + +export interface ExecutionDetail extends ExecutionSummary { + paramDefs: ParamDef[]; + request: RequestSnapshot; + config: { workingDir: string; timeoutSec: number; responseMode: ResponseMode }; + script: string; +} + +export interface LogChunk { + data: string; + nextOffset: number; + more: boolean; +} + +export interface LogsResponse { + status: ExecutionStatus; + finished: boolean; + truncated: boolean; + stdout: LogChunk; + stderr: LogChunk; +} + +export interface Overview { + hooks: { total: number; enabled: number }; + running: number; + queued: number; + last24h: { success: number; failed: number; total: number }; + recent: ExecutionSummary[]; + recentFailures: ExecutionSummary[]; +} + +export interface Settings { + globalConcurrency: number; + retentionDays: number; + outputLimitBytes: number; +} + +export interface Paged { + items: T[]; + total: number; + page: number; + pageSize: number; +} + +export interface TestRequest { + query: string; + headers: Record; + bodyType: BodyKind; + body: string; +} + +export interface PreviewResult { + ok: boolean; + values: ParamValue[]; + errors: string[]; + request: RequestSnapshot; +} diff --git a/web/src/components/AppLayout.vue b/web/src/components/AppLayout.vue new file mode 100644 index 0000000..e49709d --- /dev/null +++ b/web/src/components/AppLayout.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/web/src/components/DialogHost.vue b/web/src/components/DialogHost.vue new file mode 100644 index 0000000..f97e30c --- /dev/null +++ b/web/src/components/DialogHost.vue @@ -0,0 +1,51 @@ + + + diff --git a/web/src/components/ModalDialog.vue b/web/src/components/ModalDialog.vue new file mode 100644 index 0000000..0923971 --- /dev/null +++ b/web/src/components/ModalDialog.vue @@ -0,0 +1,43 @@ + + + diff --git a/web/src/components/OutputViewer.vue b/web/src/components/OutputViewer.vue new file mode 100644 index 0000000..de645de --- /dev/null +++ b/web/src/components/OutputViewer.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/web/src/components/ScriptEditor.vue b/web/src/components/ScriptEditor.vue new file mode 100644 index 0000000..633ab7d --- /dev/null +++ b/web/src/components/ScriptEditor.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/web/src/components/StatusBadge.vue b/web/src/components/StatusBadge.vue new file mode 100644 index 0000000..8c82156 --- /dev/null +++ b/web/src/components/StatusBadge.vue @@ -0,0 +1,29 @@ + + + diff --git a/web/src/components/ToastHost.vue b/web/src/components/ToastHost.vue new file mode 100644 index 0000000..a17b942 --- /dev/null +++ b/web/src/components/ToastHost.vue @@ -0,0 +1,11 @@ + + + diff --git a/web/src/components/TokenDialog.vue b/web/src/components/TokenDialog.vue new file mode 100644 index 0000000..4130164 --- /dev/null +++ b/web/src/components/TokenDialog.vue @@ -0,0 +1,51 @@ + + + diff --git a/web/src/composables/auth.ts b/web/src/composables/auth.ts new file mode 100644 index 0000000..cdecb87 --- /dev/null +++ b/web/src/composables/auth.ts @@ -0,0 +1,22 @@ +import { ref } from 'vue'; +import { api } from '../api/client'; + +export const currentUser = ref(null); +let checked = false; + +/** 首次访问时向后端确认登录状态 */ +export async function ensureSession(): Promise { + if (checked) return currentUser.value !== null; + try { + currentUser.value = (await api.me()).username; + } catch { + currentUser.value = null; + } + checked = true; + return currentUser.value !== null; +} + +export function setUser(username: string | null): void { + currentUser.value = username; + checked = true; +} diff --git a/web/src/composables/dialog.ts b/web/src/composables/dialog.ts new file mode 100644 index 0000000..92795d5 --- /dev/null +++ b/web/src/composables/dialog.ts @@ -0,0 +1,48 @@ +import { reactive } from 'vue'; + +export interface DialogOptions { + title: string; + message: string; + confirmText?: string; + danger?: boolean; + /** 醒目提示,用于标明会真实操作服务器的动作 */ + warning?: string; + input?: { label: string; placeholder?: string; value?: string }; + input2?: { label: string; placeholder?: string; value?: string }; +} + +interface DialogState { + open: boolean; + options: DialogOptions | null; + value: string; + value2: string; + resolve: ((result: { value: string; value2: string } | null) => void) | null; +} + +export const dialogState = reactive({ open: false, options: null, value: '', value2: '', resolve: null }); + +function openDialog(options: DialogOptions): Promise<{ value: string; value2: string } | null> { + dialogState.resolve?.(null); + return new Promise((resolve) => { + dialogState.options = options; + dialogState.value = options.input?.value ?? ''; + dialogState.value2 = options.input2?.value ?? ''; + dialogState.resolve = resolve; + dialogState.open = true; + }); +} + +export function closeDialog(confirmed: boolean): void { + const resolve = dialogState.resolve; + dialogState.open = false; + dialogState.resolve = null; + resolve?.(confirmed ? { value: dialogState.value, value2: dialogState.value2 } : null); +} + +export async function confirmDialog(options: DialogOptions): Promise { + return (await openDialog(options)) !== null; +} + +export function promptDialog(options: DialogOptions): Promise<{ value: string; value2: string } | null> { + return openDialog(options); +} diff --git a/web/src/composables/toast.ts b/web/src/composables/toast.ts new file mode 100644 index 0000000..a324fa6 --- /dev/null +++ b/web/src/composables/toast.ts @@ -0,0 +1,27 @@ +import { reactive } from 'vue'; + +export interface Toast { + id: number; + type: 'success' | 'error' | 'info'; + message: string; +} + +let nextId = 1; +export const toasts = reactive([]); + +function push(type: Toast['type'], message: string, timeout: number): void { + const id = nextId++; + toasts.push({ id, type, message }); + setTimeout(() => dismissToast(id), timeout); +} + +export function dismissToast(id: number): void { + const index = toasts.findIndex((t) => t.id === id); + if (index >= 0) toasts.splice(index, 1); +} + +export const toast = { + success: (message: string) => push('success', message, 3000), + error: (message: string) => push('error', message, 6000), + info: (message: string) => push('info', message, 4000), +}; diff --git a/web/src/env.d.ts b/web/src/env.d.ts new file mode 100644 index 0000000..149f067 --- /dev/null +++ b/web/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + const component: DefineComponent; + export default component; +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..a0be4f5 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,16 @@ +import { createApp } from 'vue'; +import App from './App.vue'; +import router from './router'; +import { onUnauthorized } from './api/client'; +import { setUser } from './composables/auth'; +import './styles.css'; + +onUnauthorized(() => { + setUser(null); + const current = router.currentRoute.value; + if (current.name !== 'login') { + void router.push({ name: 'login', query: { redirect: current.fullPath } }); + } +}); + +createApp(App).use(router).mount('#app'); diff --git a/web/src/router.ts b/web/src/router.ts new file mode 100644 index 0000000..7a76c2f --- /dev/null +++ b/web/src/router.ts @@ -0,0 +1,72 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import { ensureSession } from './composables/auth'; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/login', name: 'login', component: () => import('./views/LoginView.vue'), meta: { public: true } }, + { + path: '/', + component: () => import('./components/AppLayout.vue'), + children: [ + { path: '', name: 'overview', component: () => import('./views/OverviewView.vue'), meta: { title: '概览' } }, + { + path: 'webhooks', + name: 'hooks', + component: () => import('./views/HookListView.vue'), + meta: { title: '接口管理' }, + }, + { + path: 'webhooks/new', + name: 'hook-new', + component: () => import('./views/HookDetailView.vue'), + meta: { title: '新建接口', nav: 'hooks' }, + }, + { + path: 'webhooks/:id(\\d+)', + name: 'hook-detail', + component: () => import('./views/HookDetailView.vue'), + meta: { title: '接口详情', nav: 'hooks' }, + }, + { + path: 'executions', + name: 'executions', + component: () => import('./views/ExecutionListView.vue'), + meta: { title: '执行记录' }, + }, + { + path: 'executions/:id(\\d+)', + name: 'execution-detail', + component: () => import('./views/ExecutionDetailView.vue'), + meta: { title: '执行详情', nav: 'executions' }, + }, + { + path: 'settings', + name: 'settings', + component: () => import('./views/SettingsView.vue'), + meta: { title: '系统设置' }, + }, + { + path: 'account', + name: 'account', + component: () => import('./views/AccountView.vue'), + meta: { title: '账户设置' }, + }, + ], + }, + { path: '/:pathMatch(.*)*', redirect: '/' }, + ], +}); + +router.beforeEach(async (to) => { + if (to.meta.public) return true; + if (await ensureSession()) return true; + return { name: 'login', query: to.fullPath !== '/' ? { redirect: to.fullPath } : {} }; +}); + +router.afterEach((to) => { + const title = to.meta.title as string | undefined; + document.title = title ? `${title} · HookPilot` : 'HookPilot'; +}); + +export default router; diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..3ee4376 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,663 @@ +:root { + --bg: #f5f7fb; + --surface: #ffffff; + --surface-2: #f8fafc; + --border: #e2e8f0; + --border-strong: #cbd5e1; + --text: #0f172a; + --text-2: #475569; + --text-3: #94a3b8; + --primary: #2563eb; + --primary-hover: #1d4ed8; + --primary-soft: #eff6ff; + --success: #16a34a; + --success-soft: #f0fdf4; + --danger: #dc2626; + --danger-hover: #b91c1c; + --danger-soft: #fef2f2; + --warning: #b45309; + --warning-soft: #fffbeb; + --warning-border: #fcd34d; + --neutral-soft: #f1f5f9; + --sidebar: #0f172a; + --sidebar-text: #cbd5e1; + --sidebar-active: #1e293b; + --radius: 8px; + --shadow: 0 1px 2px rgba(15, 23, 42, 0.05), 0 1px 3px rgba(15, 23, 42, 0.06); + --shadow-lg: 0 20px 45px rgba(15, 23, 42, 0.18); + --mono: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, 'Liberation Mono', monospace; + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + font-size: 14px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--primary); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +h1, +h2, +h3 { + margin: 0; + font-weight: 600; + line-height: 1.3; +} +h1 { + font-size: 20px; +} +h2 { + font-size: 16px; +} +h3 { + font-size: 14px; +} + +code, +pre, +.mono { + font-family: var(--mono); + font-size: 12.5px; +} + +code { + background: var(--neutral-soft); + border-radius: 4px; + padding: 1px 5px; +} + +pre { + margin: 0; +} + +.muted { + color: var(--text-2); +} +.faint { + color: var(--text-3); +} +.small { + font-size: 12px; +} +.nowrap { + white-space: nowrap; +} +.break { + word-break: break-all; +} +.row { + display: flex; + align-items: center; + gap: 8px; +} +.row.wrap { + flex-wrap: wrap; +} +.spacer { + flex: 1; +} +.stack { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* 按钮 */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 34px; + padding: 0 14px; + border: 1px solid var(--border-strong); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font: inherit; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s; +} +.btn:hover:not(:disabled) { + background: var(--surface-2); + border-color: var(--text-3); + text-decoration: none; +} +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.btn-primary { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} +.btn-primary:hover:not(:disabled) { + background: var(--primary-hover); + border-color: var(--primary-hover); +} +.btn-danger { + background: var(--danger); + border-color: var(--danger); + color: #fff; +} +.btn-danger:hover:not(:disabled) { + background: var(--danger-hover); + border-color: var(--danger-hover); +} +.btn-danger-outline { + color: var(--danger); + border-color: #fca5a5; +} +.btn-danger-outline:hover:not(:disabled) { + background: var(--danger-soft); + border-color: var(--danger); +} +.btn-ghost { + border-color: transparent; + background: transparent; +} +.btn-ghost:hover:not(:disabled) { + background: var(--neutral-soft); + border-color: transparent; +} +.btn-sm { + height: 28px; + padding: 0 10px; + font-size: 13px; +} + +/* 表单 */ +input[type='text'], +input[type='password'], +input[type='number'], +input:not([type]), +select, +textarea { + width: 100%; + height: 34px; + padding: 0 10px; + border: 1px solid var(--border-strong); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font: inherit; + transition: + border-color 0.15s, + box-shadow 0.15s; +} +textarea { + height: auto; + padding: 8px 10px; + resize: vertical; + line-height: 1.5; +} +textarea.mono { + font-family: var(--mono); + font-size: 12.5px; +} +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); +} +input:disabled, +select:disabled, +textarea:disabled { + background: var(--neutral-soft); + color: var(--text-2); +} +input[type='checkbox'], +input[type='radio'] { + accent-color: var(--primary); + width: 15px; + height: 15px; + margin: 0; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} +.field > label, +.field-label { + font-weight: 500; + color: var(--text); +} +.field .hint { + font-size: 12px; + color: var(--text-2); +} +.field .required::after { + content: ' *'; + color: var(--danger); +} +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px 20px; +} +.form-grid .full { + grid-column: 1 / -1; +} +.check { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + user-select: none; +} +.input-group { + display: flex; + align-items: stretch; +} +.input-group .addon { + display: flex; + align-items: center; + padding: 0 10px; + border: 1px solid var(--border-strong); + border-right: none; + border-radius: 6px 0 0 6px; + background: var(--neutral-soft); + color: var(--text-2); + font-family: var(--mono); + font-size: 12.5px; + white-space: nowrap; +} +.input-group input { + border-radius: 0 6px 6px 0; +} + +.radio-cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.radio-card { + display: flex; + gap: 10px; + padding: 12px 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + cursor: pointer; +} +.radio-card.active { + border-color: var(--primary); + background: var(--primary-soft); +} +.radio-card input { + margin-top: 3px; +} + +/* 卡片 */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.card-header { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); +} +.card-body { + padding: 18px; +} +.card-body.flush { + padding: 0; +} + +/* 页面头部 */ +.page-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; +} +.page-header .subtitle { + color: var(--text-2); + margin-top: 2px; +} + +/* 表格 */ +.table-wrap { + overflow-x: auto; +} +.table { + width: 100%; + border-collapse: collapse; +} +.table th, +.table td { + padding: 10px 14px; + text-align: left; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +.table th { + background: var(--surface-2); + color: var(--text-2); + font-weight: 500; + font-size: 12.5px; + white-space: nowrap; +} +.table tbody tr:last-child td { + border-bottom: none; +} +.table tbody tr.clickable { + cursor: pointer; +} +.table tbody tr.clickable:hover td { + background: var(--surface-2); +} +.table.compact th, +.table.compact td { + padding: 7px 10px; +} +.table .group-row td { + background: var(--neutral-soft); + color: var(--text-2); + font-weight: 500; + font-size: 12.5px; + padding-top: 6px; + padding-bottom: 6px; +} + +/* 徽标 */ +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 1px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + background: var(--neutral-soft); + color: var(--text-2); +} +.badge.dot::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +.badge-success { + background: var(--success-soft); + color: var(--success); +} +.badge-danger { + background: var(--danger-soft); + color: var(--danger); +} +.badge-warning { + background: var(--warning-soft); + color: var(--warning); +} +.badge-info { + background: var(--primary-soft); + color: var(--primary); +} +.badge-method { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.02em; + border-radius: 4px; +} +.badge.pulse::before { + animation: pulse 1.2s ease-in-out infinite; +} +@keyframes pulse { + 50% { + opacity: 0.25; + } +} + +/* 提示条 */ +.alert { + display: flex; + gap: 10px; + padding: 10px 14px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface-2); + line-height: 1.6; +} +.alert-warning { + background: var(--warning-soft); + border-color: var(--warning-border); + color: #78350f; +} +.alert-danger { + background: var(--danger-soft); + border-color: #fecaca; + color: #7f1d1d; +} +.alert-info { + background: var(--primary-soft); + border-color: #bfdbfe; + color: #1e3a8a; +} +.alert-success { + background: var(--success-soft); + border-color: #bbf7d0; + color: #14532d; +} +.alert .alert-icon { + font-weight: 700; + flex-shrink: 0; +} + +/* 标签页 */ +.tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--border); + overflow-x: auto; +} +.tab { + padding: 10px 16px; + border: none; + border-bottom: 2px solid transparent; + background: none; + color: var(--text-2); + font: inherit; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + margin-bottom: -1px; +} +.tab:hover { + color: var(--text); +} +.tab.active { + color: var(--primary); + border-bottom-color: var(--primary); +} +.tab .count { + margin-left: 4px; + color: var(--text-3); + font-weight: 400; +} + +/* 统计卡片 */ +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 14px; +} +.stat-card { + padding: 16px 18px; +} +.stat-card .label { + color: var(--text-2); + font-size: 13px; +} +.stat-card .value { + font-size: 26px; + font-weight: 600; + margin-top: 4px; + font-variant-numeric: tabular-nums; +} +.stat-card .sub { + color: var(--text-3); + font-size: 12px; +} + +/* 键值列表 */ +.kv { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 10px 16px; + margin: 0; +} +.kv dt { + color: var(--text-2); +} +.kv dd { + margin: 0; + word-break: break-all; +} + +/* 代码块 */ +.code-block { + background: #0f172a; + color: #e2e8f0; + border-radius: var(--radius); + padding: 12px 14px; + overflow: auto; + max-height: 520px; + white-space: pre-wrap; + word-break: break-all; + font-family: var(--mono); + font-size: 12.5px; + line-height: 1.6; +} +.code-block.light { + background: var(--surface-2); + color: var(--text); + border: 1px solid var(--border); +} + +.empty { + padding: 40px 20px; + text-align: center; + color: var(--text-3); +} + +.pagination { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--border); + color: var(--text-2); +} + +/* 弹窗 */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.45); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 10vh 16px 16px; + z-index: 100; + overflow-y: auto; +} +.modal { + width: 100%; + max-width: 480px; + background: var(--surface); + border-radius: 10px; + box-shadow: var(--shadow-lg); +} +.modal.wide { + max-width: 820px; +} +.modal-header { + display: flex; + align-items: center; + padding: 16px 20px 0; +} +.modal-body { + padding: 14px 20px; + display: flex; + flex-direction: column; + gap: 14px; +} +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 0 20px 18px; +} + +/* 通知 */ +.toast-host { + position: fixed; + top: 16px; + right: 16px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 200; + max-width: min(420px, calc(100vw - 32px)); +} +.toast { + padding: 10px 14px; + border-radius: var(--radius); + background: var(--surface); + border: 1px solid var(--border); + border-left: 4px solid var(--primary); + box-shadow: var(--shadow-lg); + cursor: pointer; + word-break: break-word; +} +.toast.success { + border-left-color: var(--success); +} +.toast.error { + border-left-color: var(--danger); +} + +@media (max-width: 860px) { + .form-grid, + .radio-cards { + grid-template-columns: minmax(0, 1fr); + } + .kv { + grid-template-columns: 90px minmax(0, 1fr); + } +} diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts new file mode 100644 index 0000000..f073df8 --- /dev/null +++ b/web/src/utils/format.ts @@ -0,0 +1,90 @@ +import type { ExecutionStatus, ParamSource, ParamType, TriggerType } from '../api/types'; + +export const STATUS_LABELS: Record = { + queued: '排队中', + running: '运行中', + success: '成功', + failed: '失败', + timeout: '超时', + cancelled: '已取消', + killed: '已终止', + interrupted: '中断', +}; + +export const TRIGGER_LABELS: Record = { + webhook: '外部调用', + test: '在线测试', + rerun: '再次执行', +}; + +export const SOURCE_LABELS: Record = { + json: 'JSON 请求体', + form: '表单请求体', + query: 'Query 参数', + header: '请求头', +}; + +export const TYPE_LABELS: Record = { + string: '字符串', + number: '数字', + boolean: '布尔值', + ip: 'IP 地址', +}; + +export function isActive(status: ExecutionStatus): boolean { + return status === 'queued' || status === 'running'; +} + +export function formatTime(ms: number | null | undefined): string { + if (!ms) return '—'; + const d = new Date(ms); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +export function formatRelative(ms: number | null | undefined): string { + if (!ms) return '—'; + const diff = Date.now() - ms; + if (diff < 60_000) return '刚刚'; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`; + if (diff < 30 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`; + return formatTime(ms); +} + +export function formatDuration(ms: number | null | undefined): string { + if (ms === null || ms === undefined) return '—'; + if (ms < 1000) return `${ms} ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`; + const minutes = Math.floor(ms / 60_000); + const seconds = Math.round((ms % 60_000) / 1000); + return `${minutes} 分 ${seconds} 秒`; +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MiB`; +} + +export function webhookUrl(slug: string): string { + return `${window.location.origin}/hooks/${slug || '<标识>'}`; +} + +export async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // 非 HTTPS 环境下 clipboard API 不可用,退回旧方法 + const area = document.createElement('textarea'); + area.value = text; + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.appendChild(area); + area.select(); + const ok = document.execCommand('copy'); + area.remove(); + return ok; + } +} diff --git a/web/src/utils/templates.ts b/web/src/utils/templates.ts new file mode 100644 index 0000000..8326adc --- /dev/null +++ b/web/src/utils/templates.ts @@ -0,0 +1,94 @@ +export interface ScriptTemplate { + name: string; + description: string; + content: string; +} + +export const SCRIPT_TEMPLATES: ScriptTemplate[] = [ + { + name: '基础模板', + description: '严格模式、打印平台注入的变量', + content: `#!/usr/bin/env bash +# 参数通过环境变量传入(HOOK_ 前缀),请始终用双引号引用,如 "$HOOK_NAME" +set -euo pipefail + +echo "执行 ID:$HOOK_EXECUTION_ID" +echo "接口标识:$HOOK_SLUG" +echo "触发方式:$HOOK_TRIGGER" +echo "完整请求:$HOOK_REQUEST_FILE" + +# 在这里编写你的操作 +`, + }, + { + name: '更新家庭 IP', + description: '接收 HOOK_IP 并写入文件(配合 Lucky DDNS Webhook)', + content: `#!/usr/bin/env bash +set -euo pipefail + +# 需要在「参数」中配置:HOOK_IP ← JSON 字段 ip,类型 IP,必填 +TARGET_FILE="/var/lib/hookpilot/workspace/home-ip.txt" + +old_ip="" +if [[ -f "$TARGET_FILE" ]]; then + old_ip="$(cat "$TARGET_FILE")" +fi + +if [[ "$old_ip" == "$HOOK_IP" ]]; then + echo "IP 未变化:$HOOK_IP" + exit 0 +fi + +# 先写临时文件再重命名,避免读到写了一半的内容 +printf '%s\\n' "$HOOK_IP" > "$TARGET_FILE.tmp" +mv "$TARGET_FILE.tmp" "$TARGET_FILE" +echo "IP 已更新:\${old_ip:-无} -> $HOOK_IP" +`, + }, + { + name: 'Git 拉取并部署', + description: '在工作目录中拉取代码后执行构建', + content: `#!/usr/bin/env bash +set -euo pipefail + +# 请在「执行策略」中把工作目录设置为仓库路径,并适当调大超时时间 +echo "当前目录:$(pwd)" +git fetch --prune +git reset --hard "origin/\${HOOK_BRANCH:-main}" +git log -1 --oneline + +# npm ci && npm run build +`, + }, + { + name: '重启系统服务', + description: '通过预先配置的 sudo 规则重启服务', + content: `#!/usr/bin/env bash +set -euo pipefail + +# 需要部署者在 sudoers 中为服务账户单独授权该命令,例如: +# hookpilot ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx +SERVICE="nginx" + +sudo -n /usr/bin/systemctl restart "$SERVICE" +systemctl is-active "$SERVICE" +`, + }, + { + name: '读取完整请求', + description: '用 jq 从 HOOK_REQUEST_FILE 读取复杂字段', + content: `#!/usr/bin/env bash +set -euo pipefail + +# HOOK_REQUEST_FILE 是本次任务专属的 JSON 文件,结构: +# { method, contentType, query, headers, bodyKind, body } +if ! command -v jq >/dev/null 2>&1; then + echo "需要安装 jq" >&2 + exit 1 +fi + +jq '.body' "$HOOK_REQUEST_FILE" +jq -r '.headers["user-agent"] // "unknown"' "$HOOK_REQUEST_FILE" +`, + }, +]; diff --git a/web/src/views/AccountView.vue b/web/src/views/AccountView.vue new file mode 100644 index 0000000..1ba6753 --- /dev/null +++ b/web/src/views/AccountView.vue @@ -0,0 +1,78 @@ + + + diff --git a/web/src/views/ExecutionDetailView.vue b/web/src/views/ExecutionDetailView.vue new file mode 100644 index 0000000..1749724 --- /dev/null +++ b/web/src/views/ExecutionDetailView.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/web/src/views/ExecutionListView.vue b/web/src/views/ExecutionListView.vue new file mode 100644 index 0000000..591e25e --- /dev/null +++ b/web/src/views/ExecutionListView.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/web/src/views/HookDetailView.vue b/web/src/views/HookDetailView.vue new file mode 100644 index 0000000..0cfe321 --- /dev/null +++ b/web/src/views/HookDetailView.vue @@ -0,0 +1,283 @@ + + + diff --git a/web/src/views/HookListView.vue b/web/src/views/HookListView.vue new file mode 100644 index 0000000..dc87032 --- /dev/null +++ b/web/src/views/HookListView.vue @@ -0,0 +1,196 @@ + + + diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue new file mode 100644 index 0000000..3ecde58 --- /dev/null +++ b/web/src/views/LoginView.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/web/src/views/OverviewView.vue b/web/src/views/OverviewView.vue new file mode 100644 index 0000000..72c510e --- /dev/null +++ b/web/src/views/OverviewView.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue new file mode 100644 index 0000000..44f3d62 --- /dev/null +++ b/web/src/views/SettingsView.vue @@ -0,0 +1,75 @@ + + + diff --git a/web/src/views/hook/BasicTab.vue b/web/src/views/hook/BasicTab.vue new file mode 100644 index 0000000..0a7934b --- /dev/null +++ b/web/src/views/hook/BasicTab.vue @@ -0,0 +1,87 @@ + + +